Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

MCP server guide

tungsten generate with an mcp target writes an MCP server that calls the API through the generated TypeScript SDK, so it inherits the same safety rules. This page covers how the tool surface is chosen, what the meta-tools do, and what a generated server's README says.

How the tool surface is chosen

Below disclosure.threshold tools (default 24) the MCP server exposes discrete tools. Above it, it exposes search_tools, describe_tool, invoke and the per-cluster index. The index is a keyword index built at compile time (BM25 over names, summaries, parameter names, tags). Embedding indexes are an optional plugin because they would make the MCP server depend on a model download.

As built: embedding index plugin. Off by default, no model shipped, nothing in the manifest changes. targets.mcp.search.embeddings in tungsten.yml names a provider; when tungsten generate writes the target it sends one document per tool (the terms of its BM25 document, in order, joined by spaces: name words, summary, parameter names, resources, tags, cluster) to that provider in batches and writes two files next to manifest.json: index.embeddings.bin (count x dimensions little-endian f32, row-major, tool order, each vector L2-normalized, so cosine is a dot product) and index.embeddings.json (a header: format 1, model, dimensions, count, encoding, normalized, the BLAKE3 checksum of the .bin file and docs, the tool name and content hash of each document). Providers: command (a command line run from the directory of tungsten.yml: one JSON request on standard input, {"embeddings": [[...], ...]} on standard output) and http (the common /embeddings shape: POST of {model, input, dimensions?, encoding_format}, answer {data: [{index, embedding}]}, Authorization: Bearer from the environment variable named by api_key_env; the key is never written anywhere). The compiler links no TLS stack, so its http provider is plain http:// only (a local gateway, or a command that runs curl); the server's own query embedder has no such limit. Vectors are cached by content hash in the previous run's files, so unchanged tools are not embedded again and a model or dimension change invalidates the cache; the provider is called only by a write (generate), never by --check, --dry-run, diff or report, which build the files from the cache alone (and leave them out, so --check reports them stale, when it does not cover every tool). A provider failure never breaks generation: it is a warning (TG0571 cannot run or too slow, TG0572 invalid answer, TG0573 refused, no key or https), the stale index is removed and the server searches with BM25; required: true makes the same problems errors. A bad search.embeddings is TG0570; TG0574 reports what was written.

Tools and behaviour

Stdio transport by default, Streamable HTTP optional. Protocol versions negotiated as the SDK of the day supports.

Discrete mode (operation count ≤ threshold): one tool per operation and per macro, name <namespace>_<resource>_<op> (Acme: public_webhooks_list), compact description, input schema from the IR with pruning, outputSchema, annotations from the safety tier.

outputSchema is bounded (fix/mcp-output-closure). A response type's transitive $defs closure is the whole API on specs with cyclic, densely connected response graphs (Stripe: every expandable field is anyOf [string, Resource]; one tool's closure was ~860 definitions and 1 MB, the discrete listing ~29M tokens). The rule, in tungsten-emit-mcp manifest::bound_closure: a $ref more than OUTPUT_HOPS (1) reference hops from the response body is replaced by {} (any value, so the real structuredContent still validates), then the unreachable $defs are dropped; hoisted shared_* scalars never count as a hop. When the result is still over OUTPUT_BUDGET_FACTOR (4) times defaults.disclosure. schema_budget_tokens, every reference is {} (0 hops). A tool still over that budget is TG0727. TG0721 keeps counting description plus input only (schemaTokens is that figure in the manifest). The runtime needs no change: advertisedOutputSchema passes $defs and the body through.

Progressive mode:

ToolPurpose
describe_tool(name)Full input/output schema, remediation table, example call
invoke(name, args, options?)Executes through the SDK; returns body or envelope; options.confirmation_token for destructive/irreversible
preview(name, args)Runs the preview mode; returns summary and confirmation_token
list_clusters()The manifest's clusters with counts
run_script(code)Optional sandbox

Behaviors:

  • Results carry structuredContent and a text rendering; errors are the envelope, never thrown as protocol errors, so the model sees remediation.
  • Sensitive fields (sensitive_response_fields) are returned once and replaced with "<redacted after first read>" on repeated identical calls within the session.
  • Confirmation tokens are HMAC over (session, tool, args digest) with a short TTL; the server never accepts a token for different arguments.
  • shown_once responses are flagged in the text rendering: "Store this secret now; it will not be shown again."

Details

The emitter is tungsten-emit-mcp (target mcp); it writes an McpManifest (manifest.json, the contract in the compiler) and a small server package on top of the generated TypeScript SDK; all behaviour lives in @elysra/tungsten-mcp.

  • src/server.ts reads the manifest with loadManifest(new URL("../manifest.json", import.meta.url)), builds one ClientCore per MCP session from the environment (one variable per credential, the base URL, <API>_MCP_MODE) and starts with serve(server, parseServeArgs(argv)): stdio by default, Streamable HTTP with --http <port> [--host <host>] (the URL is written to stderr; SIGINT and SIGTERM close every session). The command line selects the transport only; credentials never come from arguments.
  • invoke and preview take {name, arguments} (discrete preview takes {tool, arguments}); the reserved input fields idempotency_key and confirmation_token (prefixed _ when an argument has that name) are taken out before the SDK call.
  • The manifest carries instructionsByMode (additive): the runtime sends the instructions of the mode in effect, so a server whose mode is overridden does not describe the other mode's tools.
  • Descriptions name the fields a tool returns once (returns signing_secret_b64url once), so a client that has no output schema (a discrete macro, a search result) knows which value to store.
  • Macro previews: the text rendering lists every step (N. <kind> <operation> (<tier>, result as <as>): <method> <url> with body ..., then its effects) with the <from step NAME: path> placeholders; structuredContent is the PreviewResult with its steps.
  • Envelopes stay the runtime's; when their remediation or next_action names SDK operations, the text adds how this server calls them (Here public.listWebhookEndpoints is called as invoke({"name": "public_webhooks_list", ...})).
  • Token budgets count what the runtime serves: tungsten_emit_mcp:: tools_list renders tools/list of either mode exactly as @elysra/tungsten-mcp does (the advertised output schemas with the envelope branch, the redaction markers and the verification result; the preview meta tool; the cluster enum), and listing_tokens adds that mode's instructions. TG0722, the README table and tungsten report use it; the MCP gate (mcp-gate/listing.mjs) checks that the built runtime serves the rendering byte for byte for the gate cases in both modes.

The script sandbox

run_script executes agent-written TypeScript against the generated SDK. node:vm is not a security boundary, so the default implementation spawns a Deno subprocess with --allow-net=<api host> only, a wall-clock limit and a memory limit, and is disabled unless deno is on PATH and mcp.sandbox.enabled: true. A WASM-based isolate is a later option.

A generated server's README

This is the README that tungsten writes next to a generated server (here for a three-operation example API).

openapi-mcp

MCP server for the openapi API, generated by tungsten. It serves 3 tools (3 operations, 0 macros) over stdio through @elysra/tungsten-mcp, calling the API with the generated SDK openapi-sdk.

Run

npm install
npm run build
node dist/server.js    # or `openapi-mcp` once installed

The server speaks MCP over stdio by default. node dist/server.js --http 3000 serves Streamable HTTP at http://127.0.0.1:3000/mcp instead (one session, with its own confirmation tokens, per Mcp-Session-Id; --host <host> binds another interface, --help lists the flags).

Client configuration (the mcpServers entry of Claude Desktop, Claude Code and other MCP clients):

{
  "mcpServers": {
    "openapi": {
      "command": "npx",
      "args": [
        "-y",
        "openapi-mcp"
      ],
      "env": {
        "OPENAPI_BASE_URL": "..."
      }
    }
  }
}

For a local build use "command": "node" with the absolute path of dist/server.js as the argument.

Environment

Credentials are read from the environment only, never from arguments; .env.example lists every variable. Set only those of the schemes your operations need.

VariableMeaning
OPENAPI_BASE_URLBase URL of the API (default http://petstore.swagger.io/v1).
OPENAPI_MCP_MODEdiscrete or progressive: overrides the generated disclosure mode.

Modes

This server starts in discrete mode (3 tools, list budget 10000 tokens; agent.yml defaults.disclosure: auto is progressive when the discrete tool list is over the budget).

  • discrete: one MCP tool per operation and macro, with input and output schemas and annotations from the safety tier.
  • progressive: a few tools instead of the whole list. search_tools(query, cluster?, limit?) searches a BM25 index built at generation time, describe_tool(name) returns one tool's schemas and rules, invoke(name, arguments) calls it (invoke_read(name, arguments) calls read-only tools and is annotated readOnlyHint, so a host can allow it without confirmation; search_tools and describe_tool results carry each tool's safety tier and annotations, and invoke, invoke_read and preview results carry them in _meta["tungsten/tool"]), preview(name, arguments) shows a call without sending it, list_clusters() lists the groups of tools.

Set OPENAPI_MCP_MODE to discrete or progressive to override the mode.

Safety rules

  • Read-only tools are annotated readOnlyHint; destructive and irreversible ones destructiveHint.
  • Failures are returned as the diagnostic envelope (category, retryable, remediation, next_action), never as protocol errors. After OUTCOME_UNKNOWN, check the outcome before any retry.
  • Planned operations are not tools.

Token budget

What a client receives before its first call (the tools/list answer of each mode and its initialize instructions) and each tool's description and input schema, measured at generation time with the estimate counter of tungsten-tokens.

MeasureTokensBudget
Progressive tools/list and instructions (with the cluster index)8332000
Discrete tools/list and instructions (3 tools)529
Largest tool schema (openapi_pets_list)51600
Median tool schema48600