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:
| Tool | Purpose |
|---|---|
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
structuredContentand 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_onceresponses 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.tsreads the manifest withloadManifest(new URL("../manifest.json", import.meta.url)), builds oneClientCoreper MCP session from the environment (one variable per credential, the base URL,<API>_MCP_MODE) and starts withserve(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.invokeandpreviewtake{name, arguments}(discretepreviewtakes{tool, arguments}); the reserved input fieldsidempotency_keyandconfirmation_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;structuredContentis thePreviewResultwith itssteps. - Envelopes stay the runtime's; when their
remediationornext_actionnames 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_listrenderstools/listof either mode exactly as@elysra/tungsten-mcpdoes (the advertised output schemas with the envelope branch, the redaction markers and the verification result; thepreviewmeta tool; the cluster enum), andlisting_tokensadds that mode's instructions. TG0722, the README table andtungsten reportuse 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.
| Variable | Meaning |
|---|---|
OPENAPI_BASE_URL | Base URL of the API (default http://petstore.swagger.io/v1). |
OPENAPI_MCP_MODE | discrete 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 annotatedreadOnlyHint, so a host can allow it without confirmation;search_toolsanddescribe_toolresults carry each tool's safety tier and annotations, andinvoke,invoke_readandpreviewresults 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 onesdestructiveHint. - Failures are returned as the diagnostic envelope (
category,retryable,remediation,next_action), never as protocol errors. AfterOUTCOME_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.
| Measure | Tokens | Budget |
|---|---|---|
Progressive tools/list and instructions (with the cluster index) | 833 | 2000 |
Discrete tools/list and instructions (3 tools) | 529 | |
Largest tool schema (openapi_pets_list) | 51 | 600 |
| Median tool schema | 48 | 600 |