Skip to content

RFD: MCP meta server - #269

Open
Fluzko wants to merge 38 commits into
symposium-dev:mainfrom
Fluzko:rfd-mcp-meta-server
Open

RFD: MCP meta server#269
Fluzko wants to merge 38 commits into
symposium-dev:mainfrom
Fluzko:rfd-mcp-meta-server

Conversation

@Fluzko

@Fluzko Fluzko commented Jul 30, 2026

Copy link
Copy Markdown

MCP meta-server

Symposium now registers one MCP server with your agent instead of one per plugin. It exposes two tools: list_tools (what's reachable here) and execute (run JavaScript with those tools in scope).

A script composes several calls and returns only what matters, so intermediate data never reaches the agent's context.

What's in this PR

  • cargo agents mcp-serve: one "symposium" entry in agent config, replacing per-plugin entries
  • Servers are workspace-conditional: one appears only if its depends-on holds
  • list_tools answers with an index; full TypeScript declarations behind a filter or detail: "full"
  • JSON Schema <> TypeScript, type-checked by tsc in CI
  • QuickJS sandbox: no filesystem, network or process plus an uncatchable deadline and memory, stack and output limits
  • Backing servers start on first call and restart with backoff
  • The workspace is re-resolved mid-session when Cargo.lock changes; running servers are carried across

Not built: cancellation, progress notifications, tools/list_changed, an on-disk tool cache. HTTP/SSE backing servers are refused — stdio only.

Using it

Configure

A plugin declares the servers it provides:

# ~/.symposium/plugins/fs-tools/SYMPOSIUM.toml
name = "fs-tools"
depends-on = ["*"]

[[mcp_servers]]
name = "filesystem"
depends-on = ["serde"]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp/data"]

name becomes the global a script calls (filesystem.read_text_file(...)).
depends-on gates the whole entry: this server exists only in workspaces that
depend on serde. command/args are the child process.

Then:

cargo agents sync

That writes a single entry into your agent's MCP config, whatever the agent:

{ "mcpServers": { "symposium": { "command": "cargo-agents", "args": ["mcp-serve"] } } }

The filesystem server is not in there. Nothing about it reaches the agent until
something asks.

Interact

Nothing changes in how you talk to your agent. You ask for what you want:

which files under /tmp/data mention a TODO?

The agent sees two tools, calls list_tools for signatures, then sends one
execute with a program like:

const listing = await filesystem.list_directory({ path: "/tmp/data" });
const names = listing.content
  .split("\n")
  .filter((line) => line.startsWith("[FILE] "))
  .map((line) => line.slice("[FILE] ".length));

const hits = [];
for (const name of names) {
  const file = await filesystem.read_text_file({ path: `/tmp/data/${name}` });
  if (file.content.includes("TODO")) hits.push(name);
}
return hits;

What the server does with it

  1. Resolves the workspace and keeps the entries whose depends-on holds.
  2. Installs filesystem in the sandbox as a proxy. No child process yet.
  3. Runs the program in QuickJS under a deadline and memory/stack/output limits.
  4. The script reaches await filesystem.list_directory({ path: "/tmp/data" }).
    That suspends it and crosses to the host, which runs the manifest's command -- npx -y @modelcontextprotocol/server-filesystem /tmp/data -- completes the MCP handshake, and checks list_directory against that server's real tools/list.
  5. Dispatches tools/call, unwraps the result, resumes the script.
  6. Later calls reuse the running child.
  7. The return value is serialized back as the execute result.

The two file bodies are read at step 5, inside the sandbox. Only the matching
names come back, proxying one tool call at a time would have pulled every
file's contents through the agent's context.

A server the script never names is never started. Servers shut down when the
session ends.

Configuration

[mcp] in ~/.symposium/config.toml, all optional. script-timeout-secs (120), script-memory-limit-mb (64), max-result-bytes (32768), max-server-restarts (5), read-only (false), and a few more.

max-tool-calls, server-startup-timeout-secs and tool-call-timeout-secs parse but are not honored yet; startup and call timeouts are fixed at 30s and 60s. Per-server overrides on [[mcp_servers]] do work.

Design rationale: the RFD.

Disclosure questions

AI disclosure.

  • The AI tool authored large parts of the code

Questions for reviewers.

Fluzko added 30 commits July 29, 2026 10:05
@Fluzko Fluzko changed the title Rfd mcp meta server RFD: MCP meta server Jul 31, 2026

@nikomatsakis nikomatsakis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did a quick skim and left some thoughts. The tests looked pretty good!

Comment thread md/rfds/mcp-meta-server/README.md

### Tools are declared as an object, not a namespace

The design shows `declare namespace sqlx { function query(...) }`. Shipped declarations use an object instead: `declare const sqlx: { query(...) }`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm, perhaps we should instead convert the tool names into camel case so they read more like idiomatic typescript.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tool names come from third-party servers, so we don't control their spelling. So rather than change what's declared, I made the lookup tolerant.
Declarations are unchanged and still show the real name. The namespace is already a Proxy that forwards whatever property a script touches, so resolution now matches ignoring case and punctuation and maps back to the wire name, i.e: memory.create_entities, memory.createEntities, memory.create-entities.
All reach the same tool. Nothing added to the listing, and a model that reaches for camelCase out of habit gets a working call instead of a retry.

a few guards are:

  1. Normalized matching is a fallback only, so a server exposing both read_file and readFile still resolves each to itself.
  2. If two visible tools normalize alike and the key matches neither exactly, return an error naming both rather than silently picking one.

Comment thread md/rfds/mcp-meta-server/README.md Outdated
Both halves changed. Results are unwrapped through an explicit ladder that checks the error flag _first_ (a server can report an error _and_ structured content, and checking content first swallows the error), and one popular Python framework's extra result wrapper is unwrapped too, since it survives a proxy hop.

Return types are `Promise<unknown>`. `unknown` forces a model to narrow the value rather than assume a shape; `any` invites the assumption. Typing returns from a server's declared output schema is deliberately not built.
Adoption is roughly 3 tools in 330 surveyed, and it is bimodal rather than uniformly absent, so the seam is worth keeping and the compiler is not worth writing.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The claim is that it's not worth handling return types because they're rarely used? Not sure I buy that.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, typed returns are implemented as a tool declaring an outputSchema gets it as its return type.

-  }): Promise<unknown>;
+  }): Promise<{
+    message: string;
+    success: boolean;
+  }>;

The real it's that nothing obliges a server to send what it declared. The schema is checked against the unwrapped value and a mismatch is tagged, not refused, in both failure modes:

# declared {count}, answered with text "count: 7"
[inventory.count: result off-shape, treat as unknown]
# declared {count, bin}, answered with struct {"bin":"A1"}
[inventory.count: result off-shape, treat as unknown]

So this way we support both the happy path and we don't throw on a return type error, so we let the model decide what's next to do.

A memory cap does not stop an infinite loop, and a timeout on the host does not stop a script that is spinning inside the interpreter.

Two layers: an interrupt that fires while the interpreter runs and raises an error the script **cannot catch**, plus an outer deadline for a script blocked awaiting a host call, where the interpreter is idle and the interrupt can never fire. Neither alone is sufficient. Result size and console output are bounded too (an unbounded tool result lands directly in the agent's context, defeating this document's own thesis).
The default budget is 120 seconds rather than 30. A script that composes several calls against servers that each take seconds is the case the feature exists for.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I don't think we need a timeout. The MCP client can decide to timeout all on its own.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scripts run on a dedicated OS thread inside a meta-server that lives for the whole session. When the client gives up, that thread keeps running. An OS thread can't be killed from outside, so the exit has to be sort of cooperative, and the deadline is what arms QuickJS's interrupt handler, the only thing that stops while (true) {}. Without it one runaway script pins a core until the session ends, and the next pins
another, while the client reports a clean timeout each time.


The design says the meta-server bridges whatever transport a backing server declares. HTTP and SSE entries parse but are refused with a reason.

Two causes: the Rust SDK has no legacy SSE client, and SSE is still the default for URL-configured servers in most comparable projects; and forwarding credentials to a remote endpoint is an exfiltration surface not considered.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is OK as an initial limitation but I'm not sure I buy the concern.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I can work on HTTP on a different branch and follow up with a PR, since it requires some more work. But keep in mind SSE is officially deprecated, and the recommended migration is Streamable HTTP link

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants