Generated from cat-harness/skills/folio-core/mcp-projection.md — do not edit here.

✎ Edit this page’s source

MCP projection — turning a Tool node into an MCP service

The harness does not require MCP. It knows how to emit it. That distinction is the whole design: agentic-harness runs on a filesystem alone (see skills-and-tools §”The harness assumes no MCP”), and MCP is something an instance produces from its Tool nodes when it wants agents to reach them over a protocol rather than by reading files.

So this is a bootstrap skill: it stands a service up. An agent with a CLI tool and a Tool node describing it should be able to produce a working MCP server without inventing the mapping each time.

Why the projection is nearly free

Because the pieces already line up, and that is not a coincidence — it is what the carrier decision bought:

schemas/tool.ts  (Zod, authoritative)
  └─→ JSON Schema                    ← already generated; MCP speaks JSON Schema
        └─→ MCP tool.inputSchema
ToolDefinition.io.inputs/outputs      ← already the I/O contract
        └─→ MCP tool.inputSchema / outputSchema
ToolDefinition.invoke.shell           ← already the mechanism
        └─→ what the server executes on tools/call

An MCP tool declaration is a name, a description and a JSON Schema for its input (plus an output schema in recent spec revisions). A ToolDefinition carries all four in id, summary and io. The projection is a rename, not a translation — which is the sign the Tool schema was shaped correctly rather than a sign that MCP is trivial.

The mapping

Tool node fieldMCPnotes
idtools[].namesubject to the naming rules — see mcp-assembly
titletools[].titlehuman-facing; omit rather than duplicate name
summarytools[].descriptionwhat the model reads to decide whether to call it
io.inputstools[].inputSchemaobject schema; required: true → the required array
io.outputstools[].outputSchemaomit entirely when the tool returns unstructured text
invoke.shellwhat the server runs on tools/callthe harness’s arm
io.inputs[].argposition in argv, or stdin{flag}, {positional} or {stdin} — explicit per input, never a template
io.inputs[].repeated"type": "array" in the input schemathe flag is emitted once per element; a repeated positional is the trailing words
invoke.containersame, in a containerfor a tool with host dependencies
invoke.inProcessthe function the server calls directlythe projection source, not something to proxy
invoke.mcpnothingalready MCP; projecting it would be circular
requiresnot expressiblebecomes a startup precondition, not a tool field
satisfiesnot expressiblea KG edge; it has no MCP counterpart and must not be smuggled into the description

Three rows are the ones that go wrong.

invoke.mcp is not a source for projection. A Tool already reachable over MCP is not re-wrapped; the projector skips it. A generator that treats every arm uniformly will emit a server that proxies itself.

invoke.inProcess is the opposite case, and looks deceptively similar. It is a function in the instance’s own code, so the server calls it directly rather than spawning anything — there is no argv, and asking for one is an error rather than a gap in the node. It is what most of a mature instance’s Tools turn out to be: of the twenty-four folio-assistant declares, twenty are in-process and only seven have a shell equivalent at all. A projector that handles shell alone will find nothing to project.

A repeated input’s elements are parsed individually, and that is the whole safety story. A list of an injection-safe type cannot contain an element that is a payload, because the element type already made one unrepresentable — so cardinality needs no new check. What it does need is the projection rule: emit the flag once per element, never comma-joined. Joining invents a separator the tool never agreed to, and makes an element containing that separator ambiguous.

requires does not become a tool. An agent cannot satisfy “needs network” by calling something. It is checked when the server starts, and a tool whose requirements are unmet is not listed rather than listed-and-failing — tools/list is what the model plans against, and advertising a tool that cannot run makes every plan containing it wrong.

Three states, at the boundary

Same rule as everywhere here, and it is easy to lose in a generator:

  • Projected — the Tool has a shell, container or inProcess arm and a complete io block. It is listed.
  • Deliberately not projected — invoke.mcp only, or requires unmet on this host. Omitted, and the omission is reported at startup.
  • Could not determine — the Tool node will not parse, or its io.*.schema reference does not resolve. This is not “omit quietly”. Fail the projection loudly: a server that silently drops a tool it could not read is indistinguishable from an instance that never had it.

What a projector must not do

  • Do not invent a schema. A Tool whose io.inputs is empty projects a tool taking no arguments. Guessing parameters from the shell string produces a contract nothing checks.
  • Do not shell-interpolate arguments. Model-supplied values reach the process as an argv array, never spliced into a string a shell parses. This is the whole attack surface of the projection, and the one place a bug is a vulnerability rather than a defect. src/mcp/project.ts has no code path that produces a command string, which is the point: a caller cannot hand one to a shell because it never has one.
  • Do not project a Tool whose argv input is unconstrained. The argv array is the second line of defence — it is a property of the caller, and a caller is one refactor away from a template literal. The first line is that the value cannot be a payload: BeanStatus is an enum, BeanId is ^[a-z0-9-]+$. A Tool with a free-text argv input is omitted with a reason, and free prose goes on stdin. Full rule, and the GitHub Actions form of the same defect: untrusted-input.
  • Do not let the projection become the source of truth. The generated server is a rendering, like the JSON Schema and the JSON-LD. A fix lands in schemas/tool.ts or the Tool node, never in the emitted server — see directory-conventions §”What lives in the schemas graph”.

Transport, and what the harness cares about

MCP servers speak stdio or streamable HTTP. The harness has no preference and should express none: stdio is what a local agent spawns, HTTP is what a shared service exposes, and the choice belongs to the instance deploying it. Record it in the deployment, not in the Tool node — a Tool that names a transport has swallowed a deployment decision the same way a skill that names a forge has swallowed a Tool.

Status

Built, as a library. src/mcp/project.ts implements this mapping: project() returns declarations plus the omissions and their reasons, buildArgv() produces argv (always an array) with every value parsed by its declared type first, and recordInvocation() writes the audit record.

It is deliberately not registered on a running server. The harness is meant to carry no tools of its own, and the twelve hand-written MCP tools in src/tools/ are content to be migrated OUT — wiring this to them would build on the thing being removed. Turning it on is one call, once that migration lands.

Three of the rules above are enforced rather than remembered: ToolDefinitionSchema refuses a Tool whose only invocation is invoke.mcp; project() omits a Tool with an unconstrained argv input and says why; and check:tools fails CI on one.


folio-assistant — code under the Apache License 2.0, documentation under CC BY 3.0. Generated schema and API references are rebuilt from source on every push to main.

deployed b08c961 · 2026-10-23T23:11:09Z