MCP & AI agents
The application generated by bun create stitchkit includes a working
stateless MCP endpoint and a separate bun run tools manifest command. Both
are assembled from the same implemented project contracts as HTTP and CLI, so
the generated project is the canonical end-to-end example for this chapter.
The same contract that drives the HTTP API also drives AI tooling. An endpoint
exposed on MCP becomes a Model Context Protocol
tool — callable from Claude, Cursor and other MCP clients. An endpoint exposed on
AGENT becomes a Vercel AI SDK tool — callable from an
agent loop. Contract operations are never re-described by hand; pathless
runtime operations use one framework definition shared by both tool transports.
Which endpoints become tools
By default every endpoint is a tool on every transport. expose narrows it:
{ method: 'GET', path: '/search', desc: 'Search the catalog' } // HTTP + MCP + AGENT
{ method: 'POST', path: '/sync', desc: 'Internal sync', expose: ['HTTP'] } // HTTP only
{ method: 'GET', path: '/lookup', desc: 'Look up a price', expose: ['MCP'] } // MCP tool only
Four kinds of endpoint are never tools, whatever expose says: a
multipart upload (not a tool call), a rawBody endpoint (HTTP-only by
construction), a responseMeta
endpoint (outbound HTTP headers mean nothing on a tool call), and a
rawResponse endpoint (its answer is
bytes — a tool result cannot carry them, and it would reach the model as {}).
Pin the full list with listToolNames in a snapshot test.
desc is the tool description the model reads — write it for the model, not
just for a human. The tool name defaults
to a verb-aware name from the method + prefix (list → list_widgets, get →
get_widget); set tool.name for an explicit one. Derivation normalises per half
(→ ADR 0035): the service half turns everything outside [a-zA-Z0-9_] into _,
hyphen included (bot-status ⇒ get_bot_status), while the method half keeps
its hyphen (get-user ⇒ get-user_notes) because such a name has always been
legal and may already be pinned in a client config. A name is accepted if it
matches [a-zA-Z0-9_-]. A name that
still cannot be delivered (illegal explicit tool.name, over 64 characters, or a
prefix with no usable character) throws at mount rather than at the first model
call —
→ ADR 0035. See
Contracts → transports.
Pinning tool names — listToolNames
Derived tool names are part of your public surface — an MCP client config or an
agent prompt refers to them by string. listToolNames({ services, runtimeTools })
resolves the complete mixed surface (the exact resolver the mounts use), with
its origin, (service, method) identity and transports, sorted — a stable shape
to snapshot:
import { listToolNames } from 'stitchkit/tools'
test('tool names have not drifted', () => {
expect(listToolNames({ services, runtimeTools })).toMatchSnapshot()
})
A stitchkit upgrade (or a contract refactor) that would shift a derived name now fails this test instead of silently breaking the clients that call the tool. It is also the mechanical diff when migrating a service: run it before and after, compare.
This is also the guard against a forgotten
expose. An endpoint that declares none is a tool on MCP and AGENT — the default is fail-open, and there is no contract-levelexposeto set once (→ ADR 0036). A snapshot oflistToolNamesfails the build the moment an endpoint you meant to keep HTTP-only shows up in the list, which is the one check that catches it however many places the line was forgotten.
In-process calls — createToolInvoker
When one application operation dispatches to a contract tool, do not mount an
AI SDK ToolSet and call its transport adapter manually. Compile an in-process
invoker once and call the shared framework runner directly:
import { createToolInvoker } from 'stitchkit/tools/invoker'
const invoker = createToolInvoker(services, {
transport: 'AGENT', // required exposure policy
})
const result = await invoker.invoke('update_entity', args, {
source: 'internal', // default; audit names the real call source
context: { identity },
lifecycle,
hooks,
})
// Composition path: validated data or the exact normalized AppError.
const data = await invoker.invokeOrThrow('update_entity', args, {
context: { identity },
lifecycle,
hooks,
})
transport is required and uses the exact existing MCP, AGENT or CLI
exposure rules; there is no internal bypass mode. The immutable name lookup is
compiled once. Call-specific identity, context, lifecycle and hooks are passed
to invoke / invokeOrThrow, so one registry can safely serve parallel and
recursive calls without retaining request state. Every invocation gets a fresh
tool-call context and runs the same extension resolution, input/output
validation, lifecycle, hooks and output-strip reporter as mounted tools.
The dedicated entrypoint needs only the normal stitchkit/zod install. Use
the broader stitchkit/tools barrel when the same program also mounts MCP or AI
adapters and has installed their optional peers. Both imports reach the same
runner; this is dependency isolation, not a second execution implementation.
invoke returns the canonical discriminated ToolResult, not an AI SDK or MCP
presentation envelope. invokeOrThrow returns validated data and throws the
runner’s normalized AppError on failure, preserving an application error’s
code, status, message, details and hint without reconstructing it from the
model-safe envelope. Unexpected throws remain scrubbed. An unknown name throws
AppError('NOT_FOUND') before the runner because there is no operation identity
against which hooks could run. Duplicate and provider-invalid names fail when
the invoker is created.
The invoker’s caller is code, so it gets an endpoint’s full answer even where a
tool view is declared. When the
invoker backs a transport a model reads, pass toolSurface: true: calls then
get the view’s defaults and projected answer, which is what collectTools and
buildToolManifest advertise.
MCP — createMcpHandler
An MCP-only server imports stitchkit/tools/mcp and installs
stitchkit, zod and @modelcontextprotocol/server. The leaf’s runtime and strict
NodeNext declarations require no ai. It exports the existing HTTP/stdio server,
schema policy, catalog and resource helpers; their implementation and security are shared
with the full stitchkit/tools barrel. Use that full barrel when the application also
needs its AI SDK features.
createMcpHandler builds a complete stateless Streamable-HTTP MCP endpoint on
the official TypeScript SDK v2 server package. It owns server creation,
protocol-era negotiation and shutdown; application code does not import the SDK
to mount a normal endpoint.
import { createMcpHandler, createMcpHttpRoute } from 'stitchkit/tools/mcp'
import { createServer } from 'stitchkit/server'
const mcp = createMcpHandler({
serverInfo: { name: 'my-app', version: '1.0.0' },
auth: (req) => resolveApiKey(req), // → an identity, or null for 401
services: [usersService, catalogService],
})
createServer({
services,
rawRoutes: [createMcpHttpRoute({ path: '/mcp', handler: mcp })],
})
// During graceful shutdown:
await mcp.close()
McpHandlerConfig
| Field | Purpose |
|---|---|
serverInfo |
MCP server identity — { name, version } |
auth |
(req) => identity | null — null rejects with 401 |
services |
the services to expose — an array, or (auth) => ServiceDef[] |
runtimeTools |
managed pathless operations — an array, or (auth) => RuntimeToolDefinition[] |
surfaces |
finite { key: { services, runtimeTools } } registry prepared eagerly |
selectSurface |
(auth) => declaredKey — required with surfaces |
context |
(auth) => {…} — values merged into every tool handler’s ctx |
lifecycle |
beforeHandle / afterHandle — the tool-side auth gate (see below) |
multiRound |
Typed MRTR state policy for guarded multi-round tool calls |
hooks |
tool-call observability hooks — afterToolCall fires on every result |
extend |
extra advertised arguments resolved into handler context |
schemaValidation |
compatibility policy, typed-property guard and portable-format guard |
logger |
a StitchLogger for the 'warn' policy |
rawTools |
(server, auth) => … — explicit SDK registration without framework guarantees |
resources |
MCP Apps ui:// resources mounted on every server |
instructions |
a short host-facing usage hint, surfaced to MCP tool-search |
coerceJsonArgs |
coerce JSON-stringified object/array arguments (default true) |
flattenUnionInput |
advertise discriminated unions as one object (default false) |
errorHint |
add a project-owned hint to failed tool results |
onOutputStrip |
observe output keys removed by contract validation |
protectedResource |
RFC 9728 metadata used by HTTP 401 responses |
legacy |
'serve' (default) or 'reject' for pre-2026 clients |
security |
optional Host/Origin allowlists at the Fetch boundary |
onTransportRejected |
observe HTTP/protocol rejections without entering lifecycle or tool hooks |
cache |
explicit operation cache hints; omitted means zero/private |
Direct services / runtimeTools factories, context, selectSurface and
rawTools receive the resolved identity, so a tenant can be shown only its own
tools and every handler can read ctx.tenantId.
Use direct factories only when the definitions are genuinely arbitrary per identity. For a bounded role/plan set, declare a finite registry instead:
const handleMcp = createMcpHandler({
serverInfo,
auth,
surfaces: {
admin: { services: allServices, runtimeTools: [renderPreview] },
member: { services: memberServices, runtimeTools: [renderPreview] },
},
selectSurface: (identity) => identity.isAdmin ? 'admin' : 'member',
context: (identity) => ({ identity }),
})
Every declared entry is schema-validated and prepared once when the handler is constructed. The selected immutable descriptors are shared; the SDK server, transport, auth-derived context, lifecycle runner and tool-call context are fresh for every request. Unknown keys fail before the server connects. The registry never retains auth values.
Stateless transport and protocol eras
Every request gets a fresh SDK server, resolved auth/context and tool-call
runner. Static direct and finite registry schemas are still prepared once when
the handler is constructed. There is no session map, event store,
Mcp-Session-Id or resumable cross-request SSE state. Process replacement and
load balancing therefore cannot strand a client on an in-memory session.
The default legacy: 'serve' lets the official SDK negotiate either modern
2026-07-28 or the supported legacy stateless opening on this same endpoint.
Set legacy: 'reject' when every host is modern. Legacy support is protocol
translation inside the SDK, not a second Stitchkit transport or compatibility
API. Stateful MCP and subscriptions are intentionally outside this layer.
createMcpHttpRoute is the framework-owned raw-route adapter. It registers
ALL because Streamable HTTP uses multiple HTTP methods, delegates only to
handler.fetch, and leaves lifecycle ownership with the returned handler.
By default, a present Host header must match the request URL and a present
browser Origin must be same-origin. Non-browser hosts may omit Origin. Behind
a trusted reverse proxy, reconstruct the public request URL before calling the
handler or configure exact security.allowedHosts / allowedOrigins; never
copy arbitrary forwarded headers into the trusted URL. If a browser calls MCP
cross-origin, its HTTP server or gateway must also answer CORS preflight and
expose the protocol headers. onTransportRejected is the audit boundary for
HTTP/security rejections; it is deliberately separate from tool lifecycle and
must not be used as authorization input.
Modern discovery, routing and cache hints
Stitchkit forwards the negotiated protocol era and validated MCP request data
to both RequestEvent.mcp and the current managed call’s typed context.mcp;
untrusted routing headers never become application identity. The SDK validates
MCP-Protocol-Version, JSON-RPC shape and routing
metadata before lifecycle or tool handlers run. Framework-owned tool and
resource registration preserves declaration order across HTTP and stdio. Tool
manifests preserve mount order; listToolNames is a sorted diagnostics view.
Consumer-owned raw SDK registrations are outside this guarantee.
Caching is opt-in. cache.operations maps directly to SDK v2 operation cache
hints, while each McpResourceDef may declare its own cacheHint. Omission
means zero/private: neither a prepared surface nor a deterministic response is
silently treated as public. Unbounded auth-selected surface factories are
always forced to zero/private even when an operation policy is present; use a
finite surfaces registry when identities share a provably immutable surface.
Stitchkit never advertises list-change or subscription capabilities
without an implementation.
Knowing your catalog is stale — the catalog stamp
A client lists the tools once and keeps that list. If the server’s contract moves afterwards, the client’s copy is wrong and nothing tells it so: calls are refused, and from inside the client a refusal looks like a broken server. This is the most expensive shape a contract disagreement takes, because the party able to fix it is the one with no evidence.
Every advertised tool and every tool result therefore carries the fingerprint of the catalog it came from:
import { readMcpCatalogStamp } from 'stitchkit/tools/mcp';
const listed = await client.listTools();
let known = readMcpCatalogStamp(listed.tools[0]?._meta); // { digest, tools }
const result = await client.callTool({ name: 'record_journal', arguments });
const live = readMcpCatalogStamp(result._meta);
if (live && known && live.digest !== known.digest) {
// The catalog moved under us. Re-list before blaming the result.
known = readMcpCatalogStamp((await client.listTools()).tools[0]?._meta);
}
The comparison costs nothing and runs on a response you were already receiving —
including a refusal, which is exactly when a stale catalog is the likeliest
explanation. notifications/tools/list_changed is the push-shaped answer to the
same problem and createMcpHandler cannot send it: it is stateless by
construction, so there is no retained session to push down.
The digest covers each advertised tool’s name, description, input schema, output schema and annotations, and nothing else — it moves when the contract moves and not when a handler does. Raw SDK registrations stay consumer-owned and are outside it.
Shaping arguments before validation — beforeToolCall
beforeToolCall sees the arguments as they arrived and may replace them:
hooks: {
beforeToolCall: async ({ args }) =>
typeof args.id === 'string' && args.id.startsWith('@')
? { ...args, id: await resolveReference(args.id) }
: undefined, // undefined / null / nothing → unchanged
}
The replacement is validated by the contract schema like any other input, so this
shapes a call and never bypasses the schema — a hook returning something the
contract refuses produces an ordinary VALIDATION_ERROR. It runs before the
params/input split, before argument coercion and before both schemas, which is
why a field declared z.string() can receive a reference and still be a string by
the time it is checked. lifecycle.beforeHandle is too late for this: the schemas
have already answered.
The audit trail is unaffected. afterToolCall reports what the caller sent as
args and the replacement separately as effectiveArgs, so a rewrite is visible
as a rewrite rather than by overwriting the evidence.
Knowing the outcome of a call — afterToolCall and toolCallId
afterToolCall receives the whole ToolResult — {ok: true, data} or
{ok: false, code, …} — on every exit, including a validation failure that never
reached the handler, and on all three transports. It is the answer to “did this
call fail”; deriving that from the serialized error text instead is the mistake
this section exists to prevent, and it is a mistake that does not announce itself:
one consumer had five places asking the question and getting five answers.
To correlate an outcome with the call the model made, read context.toolCallId:
hooks: {
afterToolCall: ({ context, result }) => {
loop.record(context.toolCallId, result.ok ? 'ok' : result.code);
},
}
Two exits are deliberately not reported: a runtime tool presenter that throws
on the agent surface (the AI SDK calls it after execute has already returned)
and ToolExecutionControlError, which is a run being stopped rather than a tool
failing — check it with isToolExecutionControlError.
Restartable tool bodies — durability
A tool that starts slow work and must report exactly once, including after a host restart, can keep that bookkeeping inside the tool:
mountAgent(services, {
durability: (toolCallId, signal) => myLedgerFor(toolCallId, signal),
});
// in the handler
const asset = await ctx.step('render', () => renderAsset(ctx.input));
await ctx.waitFor({ event: 'render-done', id: assetId });
ToolDurability is the port a tool body sees — step, sleep, waitFor. You
do not have to implement it: the engine ships from stitchkit/tools, and it needs
a ledger of two methods over storage you already have:
import { createLocalStepDurability, type StepDurabilityLedger } from 'stitchkit/tools';
const ledger: StepDurabilityLedger = {
appendEvent: (event) => db.durabilityEvents.insert(event), // returns the row with `seq`
readEvents: ({ conversationId, fromSeq, toSeq }) => db.durabilityEvents.page(…),
};
mountAgent(services, {
durability: (toolCallId, signal) =>
createLocalStepDurability({ store: ledger, conversationId, runId: toolCallId, signal }),
});
That gives replay, absolute deadlines and decode refusal without writing them. The port is there for an application that already has its own ledger engine.
One thing the port deliberately leaves out: delivery. waitFor parks until
{ event, id } arrives, and a tool body has no business delivering to itself —
so deliver lives on the engine object you constructed, not in the handler
context. Whatever produces the event (a webhook, a queue consumer, a cron) calls
engine.deliver({ event, id, payload }) against the same ledger; the parked
waitFor resolves, and a waitFor that starts after the delivery returns at
once. Taking agent-runtime is not required and nothing of the runtime proper
enters the stitchkit/tools graph. An application that does run on
agent-runtime gets durability through the run context and needs no option;
where both exist the runtime’s wins, because the run is recorded in its ledger.
An effect in another system — effect, at most once
Standalone callers must hold an application-owned exclusive execution lease for the conversation/run through the whole effect call. Sharing a ledger or separate ledger adapters is not an atomic claim; the runtime supplies its run lease. Process-local coalescing only covers the same ledger object.
step records its result after the body, so a body cut short by a crash
runs again. For sending a message or starting a turn at a provider, a repeat is
worse than a loss. The engine’s effect records the intent before it runs:
const sent = await engine.effect('reply', {
run: () => chat.send({ clientId, text }), // → { messageId }
reconcile: (signal) => chat.findByClientId(clientId, { signal }), // → { messageId } | null
})
if (sent.outcome === 'uncertain') reportPossiblyLost(clientId)
A later call — the same process or a restarted one — that finds the intent with
no outcome calls reconcile, never run. Found is accepted with the proof;
not found is uncertain. Both are recorded and final: run is never called for
that name again, and uncertain is not asked again — it is the honest third
answer, and retrying it is the caller’s decision under a new name. A run that
throws, or a reconcile that throws or overruns reconcileTimeoutMs (default
30 s), rejects with EffectUnresolvedError and leaves the intent for the next
call. The proof is JSON of at most 64 KiB — the identity the recipient assigned,
not a payload. → ADR 0200
Caller abort during reconcile releases the wait with StepAbortedError, leaves the intent and ignores
a late result. A coalesced caller can cancel its own await without cancelling the shared owner;
the first caller owns reconciliation handlers/deadline. reconcileTimeoutMs cannot exceed the native
timer range (2147483647 ms). It does not turn cancellation into proof of absence. Neither null nor an absence digest
proves that an earlier recipient request cannot complete later. Safe retries require recipient-side
idempotency or a verified fence. See ADR 0224.
Ledger reads are paged at 10 000 and incremental in one engine; a fresh engine replays the conversation
and retains its durable keys. This is not a bounded outstanding index/direct terminal receipt store.
For application delivery, defineDomainEventDelivery in stitchkit/primitives composes a caller-owned
atomic outbox claim with delivered/retryable/terminal/unknown states, bounded dispatch count (default
100). Storage retention, per-key lookup, recipient proof and transactions remain in that adapter;
it is not an automatic cutover for a different reconciliation protocol.
Typed MCP call metadata
Contract handlers, runtime-tool handlers/factories, lifecycle and tool hooks all
see the same optional McpCallContext for the current invocation:
const sourceLabel = context.mcp?.clientInfo?.name ?? context.source
context.mcp?.era // 'modern' | 'legacy'
context.mcp?.method // validated MCP method
context.mcp?.toolName // resolved framework tool name
context.mcp?.protocolVersion // when supplied by a modern host
context.mcp?.clientInfo // { name, version } when validated
context.mcp?.outcome // multi-round attempt outcome
context.mcp?.round // multi-round attempt number
The field is absent on HTTP, Agent, CLI and bring-your-own transports. A legacy
MCP call exposes era: 'legacy' but does not invent modern client information.
clientInfo is the host’s self-description: use it for labels or analytics,
never for authentication, RBAC, tenant selection or rate limiting. Verified
identity still comes from auth; the context(auth) callback remains the
application-context factory and receives no protocol metadata.
Telling the host what a long call is doing — ctx.reportProgress
A tool that takes ten minutes tells a text host nothing: the call is made, and
the next thing the host shows is the answer. The protocol has a channel for
this — the host sends a progressToken with the call, the server relates
progress notifications to it — and a handler reaches it through
ctx.reportProgress:
render: async (ctx) => {
await ctx.reportProgress?.({ message: 'queued' })
const job = await jobs.start(ctx.input)
await ctx.reportProgress?.({ message: 'rendering', progress: 1, total: 3 })
return await jobs.finish(job)
}
reportProgress is present on every tool call — MCP, agent and CLI — so a
handler never branches on transport, and it does nothing wherever nobody is
listening. It is absent on the HTTP transport, which has no channel for it, so a
handler that also serves HTTP writes ctx.reportProgress?.(…). It never throws and never
rejects: a message about work must not be able to kill the work it describes, so
a refused notification leaves the host exactly where a host that never asked for
progress already is.
progress is a number the protocol requires and a handler often does not have —
there is a stage to name and no scale to name it on. Omit it and the ordinal of
the update is sent, with no total, so the host shows a counter rather than a
bar stuck at 3%. That is a fact about what happened; a synthesised percentage
would be a claim nobody measured.
The framework sends nothing on its own. Progress is meaningful only where an
operation has observable stages, and manufacturing one per call would be
inventing a second transport beside the one the protocol already provides. The
one exception is the managed wait tool (mountWait), which already polls and
therefore already knows: when the host asked for progress it reports each poll
and the phase it last saw, which is the tool whose silence raised the question
in the first place.
The token itself is on ctx.mcp.progressToken when a caller wants to know
whether anyone is listening.
Multi-round tool input (input_required)
An MCP-only operation can require typed user input before its final side effect:
const DeleteConfirmationSchema = z.object({ confirmed: z.boolean() })
deleteProject: {
method: 'DELETE',
path: '/projects/:id',
expose: ['MCP'],
input: DeleteProjectSchema,
output: DeleteProjectResultSchema,
tool: {
mcp: {
inputRequired: [
{
key: 'confirmation',
schema: DeleteConfirmationSchema,
message: 'Confirm permanent deletion',
},
{
key: 'reason',
schema: z.object({ value: z.string().min(3) }),
message: 'Record the reason',
},
],
},
},
}
Each continuation accepts exactly the currently requested item. Only after the
ordered sequence completes does the handler receive the exact aggregate as
ctx.mcpInput.confirmation and ctx.mcpInput.reason; before then the framework
returns input_required without calling it. Set
multiRound.serving.maxRounds to the maximum declaration size you accept
(default 10); empty sequences, duplicate keys and overflow fail first. State
is bound to principal, full operation identity, current round, accepted
aggregate and canonical original-argument digest and is
expiry-checked. Tampered, expired, cross-user or cross-tool state fails before
the side effect. Output validation applies only to the final result.
Every attempt re-runs auth/lifecycle and emits its own tool hooks; audit metadata
contains the attempt’s mcp.outcome and mcp.round. Stitchkit does not merge
separate HTTP attempts into one logical trace. Parallel
rounds have isolated context. HTTP/stdio modern hosts and the supported legacy
stdio bridge may use this declaration; Agent, CLI and ordinary HTTP invocation
remain single-round and unchanged. Hosts without the capability receive an
ordinary unsupported result rather than a falsely advertised round. Signed
state is tamper-resistant, not an exactly-once replay store; destructive
handlers must still be idempotent where retries matter.
Questions chosen from the arguments
The list above is fixed when the contract is written, which is right when the
questions belong to the operation. It is not enough when they belong to the
arguments — one model takes aspect_ratio, another duration, a third an
input image — and there a declared list can only be empty. inputRequired also
accepts a function of the parsed call:
tool: {
mcp: {
inputRequired: async ({ input }) => {
const model = await catalog.get(input.model)
return model.needsRatio
? [{ key: 'ratio', schema: RatioSchema, message: 'Which aspect ratio?' }]
: []
},
},
}
It receives the same params / input the handler receives — parsed, so
defaults and coercions are already applied. Returning an empty array means this
call needs nothing and runs straight through. Everything else is unchanged: the
state is still signed and bound to principal, operation and argument digest, the
rounds are still counted against maxRounds, and a resolved list with duplicate
keys or more rounds than allowed is refused.
The plan is fingerprinted, and that matters more than it looks. Between two rounds there is a full round trip to the host, and a resolver that reads anything outside its arguments — a model catalog, a feature flag — can answer differently the second time. Every other check would still pass: same principal, same operation, same arguments, round still in range. Round 2’s question would be asked under round 1’s key, and if the schemas happened to be compatible the user’s answer would be accepted for a question nobody asked. So the resolved list is fingerprinted into the signed state and re-checked every round; a plan that moved is refused, not asked. A resolver that reads only its arguments never sees this.
What a dynamic declaration costs. One extra pass through the contract
pipeline per round — lifecycle.beforeHandle runs once more and one more
toolPhase: 'input-round' audit row is written. That is the price of ordering:
the resolver is your code and may reach a network, so it runs after
authorisation, never before, and the only thing that can authorise is a pipeline
pass. Resolving itself is free — the arguments are parsed by the same pure
function the call parses with, not by a second run of anything. A fixed list
pays none of this; its call sequence is unchanged.
One thing does change for a fixed list: the signed round state now carries a
fingerprint of the question plan, and it is checked on every round. A
conversation already in flight when you deploy this has no fingerprint in its
state and is refused once with INVALID_REQUEST_STATE; the host re-asks and the
retry succeeds.
Guarding tools — lifecycle
A tool call runs the same handler an HTTP request would. lifecycle makes it
run the same gate: a beforeHandle (throw to reject) and an afterHandle
(transform the result). Pass the same
createAuthHook result used as the HTTP
server’s authorize hook and tool calls are scope-checked by identical rules:
createMcpHandler({ serverInfo, auth, services, lifecycle: { beforeHandle: authHook } })
Without it, a tool call bypasses the HTTP authorize gate — the contract’s
scope is not enforced on the MCP / agent surface. mountMcp, mountAgent and
buildMcpServer take lifecycle too.
The observability hooks are symmetric with the HTTP side too: beforeToolCall
and afterToolCall receive the resolved MethodDef as endpoint —
the tool-side twin of afterHandle(ctx, result, endpoint). Read
endpoint.serviceName / .key / .meta directly for an audit row; you do not
need to rebuild a toolName → identity map:
hooks: {
afterToolCall: ({ result, durationMs, endpoint }) => {
audit({
service: endpoint.serviceName,
action: endpoint.key,
ok: result.ok,
ms: durationMs,
})
},
}
Tool-path identity. A tool call has no
req, socreateAuthHookresolves identity throughresolveFromContext, notresolve. Set it (read the identity yourauth/contextinjected) — without it a scoped tool call has no identity and fails closed. See Auth on the tool surface.
MCP schema validation profile
A contract schema that JSON Schema cannot represent (a z.date(), a z.map())
cannot become a tool. schemaValidation.policy decides what happens:
'throw'(default) — fail the build, listing every offending tool. A staticservicesarray is checked whencreateMcpHandleris constructed, so a bad schema fails the deploy, not the first request. Better than a tool that silently vanishes from the surface.'warn'— log throughloggerand drop the tool.'skip'— drop the tool silently.
validateMcpSchemas({ services }) runs the same check on its own — useful in a
startup assertion or a test.
For a static services array, collection, schema conversion and every enabled
validation guard run once when the handler is created. Each HTTP request still
receives a fresh McpServer, runner, context and native registration over that
immutable prepared surface. A services(auth) factory
is deliberately prepared after resolving each identity because its tool set may
change by tenant.
Is every property actually usable by a model?
A tool schema is the only instruction a model gets about the shape of its
arguments. A property that carries a description and no type / enum /
anyOf / $ref tells it nothing — and nothing fails: the schema converts, the
mount succeeds, the tool is advertised, and the model then guesses, retrying the
same wrong guess because the error does not say what the right one would be.
validateMcpSchemas({
services,
policy: 'throw',
logger,
extend,
flattenUnionInput: true,
requireTypedProperties: true,
requirePortableFormats: true,
allowFormats: [],
})
For an arbitrary JSON value, declare z.json(): its recursive JSON Schema
describes strings, numbers, booleans, null, arrays and objects, so strict schema
validation needs no exception. z.unknown() means an unknown runtime value —
including values JSON cannot represent — and therefore gives a model no usable
shape. The guard is off by default for existing contracts. allowUntyped takes
dotted tool.property paths and is reserved for a presentation value that is
genuinely impossible to represent as JSON Schema. On createMcpHandler, put the policy under
schemaValidation; the handler supplies its real extend and
flattenUnionInput, so the check cannot vet a different document from the one
advertised.
requirePortableFormats rejects custom format values common MCP/AJV clients
do not know, such as the cuid2 emitted by z.cuid2(). Use a portable
schema/pattern, or list it in allowFormats only when every client supports it.
stitchkit never removes or rewrites the keyword.
findUntypedProperties(jsonSchema) is the same walk, exported on its own if you
want to assert on a schema you built elsewhere.
Discriminated unions for weaker models — flattenUnionInput
A discriminated union in a tool’s input becomes a JSON Schema oneOf / anyOf.
Capable models (Claude, Gemini, GPT) handle that; weaker / cheaper ones can drop
the field or mangle its strings. Set flattenUnionInput: true (on
createMcpHandler / mountMcp / mountAgent) to advertise each discriminated
union as a single flat object instead — the discriminator becomes an enum and
each field required by every variant stays required. Other fields become optional
with Available if <disc> = … and, where applicable, Required if <disc> = … hints.
Branch explanations keep their meaning. A shared description appears once;
different descriptions are grouped by text and qualified with When <disc> = …:.
A branch without a description does not inherit another branch’s explanation.
For example, an optional object value in single and a required array value
in group advertise:
When kind = single: One reference.
When kind = group: Two to ten references.
Available if kind = single | group. Required if kind = group
A third branch without value is absent from those labels. The object and array
retain their separate shapes; the array’s minimum length does not apply to the object.
Discriminator values, branch explanation labels, enum values and joined
properties keep the order of their first appearance across the variants, so the
order you declare is the order the model reads. Alternatives of different kinds
keep one canonical order, so one declaration always yields one document. → ADR 0221.
A field several variants declare keeps its type. Flattening puts every
variant’s fields side by side, so a key two variants share has to be advertised
as one thing. Where they agree, that is what you get; where they disagree — a
.refine() only one of them carries, two different bounds on the same number, an
enum against a free string — the constraint is dropped and the type is not.
A field that is a number in every variant is advertised as a number, not as a
bare description. Scalar collisions keep every provable kind in a deterministic
JSON Schema type array. If any alternative is an object or array, the property
retains distinct structural alternatives in anyOf: object properties, typed
array items, bounds and nullable values remain available to the model. Each
alternative is described by the discriminator values that select it
(When type = image., or When type = photo | video. for a shape two values share),
so the model can pair a shape with its value; the order of the alternatives is
canonical, not the order of the variants. The
containing discriminated union still becomes one object; flat mode does not
promise to remove every nested union keyword. Only a branch whose kind is
actually unknowable, such as a free-form schema or unresolved reference, leaves
the collision unconstrained. → ADRs 0044,
0205
and 0267.
It is deep because the projection walks the generated JSON Schema document, including objects, arrays, tuples and schema-definition nodes. Structurally identifiable discriminated object unions are flattened wherever they occur. Plain unions and unions hidden behind unresolved external references remain unions because Stitchkit cannot soundly invent a discriminator.
Use the default flattenUnionInput: false when the model must see the exact
relationship between a discriminator and each branch. Joined property schemas
describe allowed values without reconstructing those correlations.
The flattened form is lossy but never executable. Per-variant refinements
and incompatible constraints are widened in the presentation document; the
original Zod contract enforces them exactly once inside executeToolMethod.
MCP and AI adapters forward the raw argument object unchanged, so defaults,
coercions and transforms cannot run before Stitchkit. Strict violations return
the normal VALIDATION_ERROR tool envelope and fire beforeToolCall /
afterToolCall. → ADR 0050.
mountMcp
If you already run an McpServer from the SDK, mountMcp adds contract tools
to it instead of owning the lifecycle:
import { mountMcp } from 'stitchkit/tools/mcp'
mountMcp(mcpServer, [usersService], { context: { source: 'mcp' } })
createMcpHandler is the batteries-included path; mountMcp is the building
block under it.
MCP over stdio — createStdioMcpServer
createMcpHandler serves MCP over HTTP. createStdioMcpServer serves the same
contract tools over stdio — the server runs as a subprocess of the MCP
client (Claude Desktop, Claude Code, Cursor, Codex), on the user’s machine, so
it can reach the local filesystem.
import { bindStdioProcessSignals, createStdioMcpServer } from 'stitchkit/tools/mcp'
const stdio = await createStdioMcpServer({
serverInfo: { name: 'my-app', version: '1.0.0' },
auth: resolveIdentity(), // resolved once at startup, not per request
services: [usersService],
})
const signals = bindStdioProcessSignals(stdio, {
onClose: () => stopWorkers(),
onComplete: () => { process.exitCode = 0 },
onError: (_phase, error) => {
console.error(error) // stderr only — stdout is JSON-RPC
process.exitCode = 1
},
})
await signals.promise
A stdio server is a single process serving one client, so auth is a value (or
a promise of one) resolved once at startup — typically from an env var — rather
than a per-request (req) => …. Keep all logging on stderr: stdout is the
JSON-RPC channel.
The default legacy: 'serve' negotiates the modern or supported legacy opening
through the official stdio adapter. Use legacy: 'reject' for a modern-only
binary. Each invocation builds a fresh server; the returned handle owns
transport shutdown.
Signal binding is explicit: createStdioMcpServer installs no global process
listeners by itself. The first SIGINT/SIGTERM starts exactly one official
close() chain. Stdio has no force primitive, so a later signal restores the
OS default disposition instead of reporting a fictional forced result.
signals.close() removes an idle binding; the framework never calls
process.exit() or chooses the exit code. See
deployment lifecycle.
Both transports build the server through the shared buildMcpServer — same
contract/runtime pipeline, same surface selection, context, hooks, raw escape
hatch and instructions.
OAuth 2.1 — a native remote connector
A remote MCP server is connectable from Claude (Desktop / web “custom
connector”) only through the MCP authorization spec: OAuth 2.1 with PKCE, plus
the discovery documents (RFC 9728 / 8414), Client ID Metadata Documents (CIMD)
and resource indicators (RFC 8707). A Bearer-only server returns a
bare 401 and the connector never establishes.
stitchkit ships the OAuth protocol mechanics; the app supplies only identity and storage. Three pieces wire it together:
import { createMcpHandler, createMcpHttpRoute, mountOAuthProvider, oauthProtectedResourceRoute } from 'stitchkit/tools'
import { createServer } from 'stitchkit/server'
const resource = 'https://api.example.com/mcp'
const issuer = 'https://api.example.com'
// 1. Resource server — the 401 now points at the metadata.
const mcp = createMcpHandler({
serverInfo: { name: 'my-app', version: '1.0.0' },
auth: resolveOAuthToken, // validate the Bearer JWT (verifyJwt + audience)
services,
protectedResource: { resource, authorizationServers: [issuer] },
})
// 2. Authorization server — CIMD, /authorize (PKCE), /token.
const oauthRoutes = mountOAuthProvider({
issuer,
resource,
signingSecret: env.OAUTH_SECRET,
clientRegistration: {
preRegistered: { get: getFirstPartyClient },
// CIMD is enabled with secure defaults; DCR remains disabled.
},
codes, refreshTokens, // your stores (DB or in-memory)
authorizeUser, // → { userId, approvedScopes } | Response
})
createServer({
services,
rawRoutes: [
createMcpHttpRoute({ path: '/mcp', handler: mcp }),
oauthProtectedResourceRoute({ resource, authorizationServers: [issuer] }),
...oauthRoutes,
],
})
Access tokens are signed HS256 JWTs (signJwt) whose aud is the resource and
whose iss is the issuer — validate both in auth with
verifyJwt(token, secret, { audience: resource, issuer }). authorizeUser is
where the app authenticates the user (reuse an existing session) and records
consent; return { userId, approvedScopes }, where approved scopes are a subset
of the requested/supported values, or a Response to redirect the browser to a
login page first. The AS and resource server can co-locate or live
on separate origins. See
ADR 0015.
Client resolution is deterministic: exact pre-registered client, then an HTTPS
URL client id resolved as CIMD, then explicitly enabled DCR. CIMD is on by
default; DCR is off unless clientRegistration.dcr supplies both register and
get. A CIMD-only deployment therefore exposes neither /register nor a
registration_endpoint. The metadata document must declare a client_id that
exactly equals its URL, explicit redirect URIs and
token_endpoint_auth_method: 'none'.
The token endpoint and /register read at most 64 KiB of a request body, or
maxRequestBodyBytes when you set it; a larger one is answered 413 with
invalid_request and the rest is not read.
The production fetcher rejects credentials, fragments, non-HTTPS URLs and
private/reserved targets. Every redirect is DNS-resolved and IP-pinned again;
timeouts, redirect count and body size are bounded. Its bounded cache respects
Cache-Control (including no-store/no-cache), Age, Expires, ETag and
Last-Modified, coalesces concurrent misses and reports sanitized cache events;
invalid or unavailable identity receives only a short fail-closed cache entry.
Resolution itself is rate-limited on two levels (CimdCachePolicy):
maxResolutionsPerClient (default 10) bounds ONE client_id per
resolutionWindowMs window (default 60 s) — a client whose document disables
caching burns its own budget, not the server’s — while maxResolutions
(default 120) caps the server-wide outbound rate as the flood backstop.
Positive and negative cache entries keep separate pools of maxEntries each,
so a flood of unresolvable ids cannot evict warmed clients and a burst of
successes cannot reset a failing client’s backoff.
Authorization hardening (MCP 2026-07-28)
isson every authorization response (RFC 9207, SEP-2468). Success and error redirects carryiss, and the AS metadata advertisesauthorization_response_iss_parameter_supported: true. A client that talks to several authorization servers validatesissbefore redeeming the code, which closes the mix-up attack — an attacker’s server cannot pass its response off as this issuer’s. Additive on the wire: a client that ignoresissis unaffected.application_typeon registration (SEP-837). A client may declare"native"(desktop / CLI) or"web"in its DCR body. A native client may register anhttploopback redirect (http://127.0.0.1:…, RFC 8252 §7.3); a web client is held tohttpsonly — that mismatch is the usual cause of theredirect_urirejection CLI clients hit. Omit the field and registration behaves exactly as before (loopback allowed); an unknown value is rejected rather than silently defaulted.
Dynamic Client Registration is an explicit interoperability mode, not the default. Enabling it changes public discovery and adds
/register; keep it off unless a client that cannot publish CIMD requires it.
Proxying a remote API — implementRemote
implement binds a contract to local handlers. implementRemote binds it to a
remote HTTP API instead — every handler forwards the call to a deployed server
through the contract’s typed client:
import { createHttpClient } from 'stitchkit'
import { implementRemote } from 'stitchkit/remote'
import { createStdioMcpServer } from 'stitchkit/tools/mcp'
const http = createHttpClient({
baseUrl: 'https://api.example.com',
headers: () => ({ Authorization: `Bearer ${apiKey}` }),
})
const stdio = await createStdioMcpServer({
serverInfo: { name: 'my-app', version: '1.0.0' },
auth: null,
services: contracts.map((c) => implementRemote(c, http)),
})
// During graceful shutdown:
await stdio.close()
This is how you ship a thin local MCP server for an API that already runs in the cloud: the local process owns only the transport and any filesystem-facing native tools, while every contract tool proxies to the remote API. One contract, no duplicated business logic.
implementRemote(contract, http, { transformArgs }) takes an optional
transformArgs hook that rewrites a call’s arguments before they are forwarded
— e.g. to upload a local file referenced in the args and swap in its URL.
The call’s signal reaches the existing typed HTTP client’s request options, for endpoints
with or without arguments. An already cancelled call, including cancellation while an async
argument transform runs, dispatches no request. Bind your process signal to createCli or
createCliInvoker; REQUEST_ABORTED is a safe nonretryable failure with CLI exit130.
Cooperative origins observe ctx.signal and release resources when their work ends. An
origin or provider ignoring cancellation may continue after the local wait stops.
An explicit AppError.retryable boolean also survives this remote hop. false on502 and
true on409 remain the application’s recommendation through HTTP, CLI, MCP and AI tools.
Absent metadata keeps the previous defaults and does not alter HTTP transport retries.
A remote failure that carries no stitchkit error envelope — an HTML 503 from a proxy
during a deploy, a 429 from a rate limiter, a 404 page, a refused connection — keeps
what the caller can act on and drops what it must not see. A response with a status
becomes HTTP_ERROR with that status and a fixed message; a failure with no response
becomes CONNECTION_REQUEST_FAILED (502). Neither carries details: the upstream
body, URL and credentials stay in the raw cause that onToolError and AgentToolError.cause
expose in-process. One rule decides retryable for every origin: the declared value wins,
otherwise the status decides (408, 429, 502, 503, 504 are retryable, as for any
local tool). A call the caller cancelled (REQUEST_ABORTED) declares retryable: false;
a deadline the remote client hit (REQUEST_TIMEOUT, 408) follows the status like a
408 from a local handler.
Structured output
When a contract endpoint or runtime tool declares output, its MCP tool
registers that exact schema as outputSchema and returns the validated value as
structuredContent alongside a JSON text block. Every JSON root type is kept
unchanged: object, array, string, number, boolean and null. Stitchkit does not
wrap non-object values in { result }; protocol-era adaptation belongs to the
official SDK.
A tool without an output contract advertises no outputSchema and returns no
invented structured value. Its successful MCP result has an empty content
list.
A different answer for tools — withToolView
An admin page wants the full record; a model wants a card. Every byte of a
tool result enters the model’s context and stays in its history, and personal
fields a task does not need are better never shown to it. Two endpoints on one
path (one expose: ['HTTP'], one for the tools) duplicate the description, the
schemas and the handler; branching on ctx.source teaches every handler about
transports and still advertises the full output schema.
A tool view declares the difference on the endpoint instead. Most often the difference is only the call — a model needs a lighter one, and the answer keeps its one shape:
import { defineContract, withToolView } from 'stitchkit';
export const items = defineContract({ prefix: 'items' }, {
list: withToolView(
{
method: 'GET', path: '/:slug/items', desc: 'List items', tool: { name: 'db_items' },
input: ItemsQuery, // include: z.array(...).default(['relations'])
output: ItemsResponse, // one answer on HTTP and on the tools
},
{ defaults: { include: [] } }, // what a tool call gets when it passes nothing
),
});
When the answer itself differs, the view carries its own schema too:
import { defineContract, withToolView } from 'stitchkit';
export const users = defineContract({ prefix: 'users' }, {
list: withToolView(
{
method: 'GET', path: '/', desc: 'List users', tool: { name: 'user_list' },
input: UserListQuery, // include: z.array(...).default(['profile', 'stats'])
output: UserListResponse, // HTTP, OpenAPI and the typed client — unchanged
},
{
defaults: { include: [] }, // what a tool call gets when it passes nothing
output: UserCardList, // the tool answer and its schema
project: (full, { input }) => ({ items: full.items.map(toCard) }), // optional: without it, `output` slices
},
),
});
- One handler, one parser.
defaultsjoins the tool call’s arguments before the endpoint’s owninputparses them, so the handler receives an ordinaryinput—include: []from a tool, the HTTP default from HTTP — and loads only what was asked for. An explicit argument wins over the default. - The answer is derived, then checked. The handler’s result is validated
against the full
outputfirst, as always; thenproject(a pure, synchronous function of the full result and the parsed input) or a slice by the view’soutput, then validation against the view’s schema. A projection that throws, returns a Promise or breaks its schema isINTERNAL_SERVER_ERROR— a server fault, like any output mismatch. - Where it applies. The MCP, agent and CLI mounts answer with the view;
outputSchema,structuredContent, the advertised inputdefault, the catalog stamp and the surface snapshot follow it. HTTP, OpenAPI, the typed client and the in-processcreateToolInvokerkeep the full answer — code, not a model, is calling there. - Four forms, one helper. With
defaultsalone the call changes and the answer is the fulloutput, validated by it and advertised as it. Withoutputandprojectthe answer has its own schema. Withprojectalone it reshapes values inside the full schema. Withoutputalone it is a slice, and the full result must fit that schema —withToolViewrefuses the endpoint at compile time when it does not. - Exposure. A view needs a tool transport. With
createContractFactory({ toolExposure: 'explicit' })an endpoint withoutexposeis HTTP-only, so an endpoint with a view names its tool transports inexpose. - What it refuses.
defineContractrejects a view on an endpoint with no tool transport or no fulloutput, on a raw,rawBody, multipart,responseMetaor streaming endpoint, a view that declares nodefaults,outputorproject— it would change nothing — and a default for a key the input does not have, for a path param, or with a value the input would reject.
A projection adds nothing the full result does not carry: a field the tool
answer needs belongs in the full answer, loaded by the handler. Keys a
projection returns beyond its schema are reported by onOutputStrip; a slice
removes keys on purpose and is not reported. → ADR 0196
AI agents — mountAgent
mountAgent remains the low-level path when the application owns its own model
loop and conversation state. For the optional Stitchkit-owned run/history loop,
see Agent application runtime.
mountAgent turns a service into a Vercel AI SDK ToolSet, ready for
generateText / streamText:
import { mountAgent } from 'stitchkit/tools'
import { generateText } from 'ai'
const tools = mountAgent(usersService, { context: { userId: 'agent-1' } })
const result = await generateText({
model,
tools,
prompt: 'Create a user named Max',
})
Each contract endpoint exposed on AGENT becomes a tool whose input schema is
the merged params + input. context is merged into every tool handler’s
ctx, alongside source: 'agent'.
AgentMountConfig
| Field | Purpose |
|---|---|
context |
values merged into every tool handler’s ctx |
lifecycle |
beforeHandle / afterHandle — the tool-side auth gate (see Guarding tools) |
hooks |
tool-call observability hooks — afterToolCall fires on every result |
extend |
add extra args resolved before the handler runs (see below) |
runtimeTools |
framework-managed pathless operations from defineRuntimeTool |
lifecycle works the same as on the MCP server — without it an agent tool call
bypasses the HTTP authorize gate. Pass your createAuthHook result.
Adding tool-only args — extend
extend adds tool-only arguments — fields the model fills that are resolved
into context, then stripped before the contract handler sees them. Use it when a
tool needs an argument the HTTP endpoint does not — the classic case being a
multi-tenant server reached by one API key, where the model passes tenantId
on every call. It is the same ToolExtend shape on mountMcp, createMcpHandler
and mountAgent:
interface ToolExtend {
/** Extra Zod fields added to every matching tool's input schema. */
schema: Record<string, z.ZodType>
/** Turn the extra args into context merged into the handler's ctx. */
resolve: (args: Record<string, unknown>) => Partial<Ctx> | Promise<Partial<Ctx>>
/** Limit the extension to specific methods — default: every method. */
filter?: (service: ServiceDef, method: MethodDef) => boolean
}
On the MCP server — add tenantId to each tool, validate access in resolve,
inject the resolved tenant into ctx, and filter to the tenant-scoped tools:
createMcpHandler({
serverInfo, auth,
services: [widgetsService],
extend: {
schema: { tenantId: z.string().describe('Tenant to act on') },
resolve: async ({ tenantId }) => {
const id = String(tenantId)
if (!(await tenantExists(id))) throw new Error(`unknown tenant ${id}`)
return { tenantId: id } // merged into ctx → ctx.tenantId
},
filter: (_service, method) => method.scope === 'tenant',
},
})
The model calls widgets_list({ tenantId: 't_123' }); resolve checks access and
puts tenantId on ctx; the contract handler reads ctx.tenantId (same place
as the HTTP prefix param — see
Route groups → param prefixes),
so one handler serves both surfaces. Pair extend with lifecycle (your
createAuthHook) so the tool call is still scope-gated.
Pathless runtime tools and multimodal results
Use defineRuntimeTool for an operation that has no HTTP path but still needs
the same validation, isolated per-call context, lifecycle/RBAC and hooks as a
contract tool. The handler returns one transport-neutral, schema-validated
result. Optional presentation callbacks map that result to MCP content and AI
SDK model output without coupling the handler to either SDK:
import { createMcpHandler, defineRuntimeTool, mountAgent } from 'stitchkit/tools'
import { z } from 'zod'
const renderPreview = defineRuntimeTool({
name: 'render_preview',
description: 'Render and inspect a preview',
identity: {
serviceName: 'mediaTools',
action: 'renderPreview',
scope: 'admin',
method: 'POST',
},
input: z.object({ prompt: z.string() }),
output: z.object({ assetId: z.string(), imageBase64: z.string() }),
handler: async ({ input }) => renderAndSave(input.prompt),
present: {
mcp: (output) => ({
content: [
{ type: 'image', data: output.imageBase64, mimeType: 'image/png' },
{ type: 'text', text: output.assetId },
],
}),
agent: (output) => ({
type: 'content',
value: [{
type: 'file',
data: { type: 'data', data: output.imageBase64 },
mediaType: 'image/png',
filename: `${output.assetId}.png`,
}],
}),
},
})
const handleMcp = createMcpHandler({
serverInfo: { name: 'my-app', version: '1.0.0' },
auth,
services: [service],
runtimeTools: [renderPreview],
lifecycle: { beforeHandle: authHook },
hooks: audit.toolCall,
})
const agentTools = mountAgent([service], {
runtimeTools: [renderPreview],
lifecycle: { beforeHandle: authHook },
hooks: audit.toolCall,
})
When several runtime tools share application context and identity, bind both
once with createRuntimeToolFactory. Its Zod context schema is parsed once per
call inside the same runner; the authored handler receives validated context and
parsed input, while lifecycle, hooks, output validation and presenters remain
unchanged:
const knowledgeTools = createRuntimeToolFactory({
serviceName: 'agentKnowledge',
scope: 'user',
context: z.object({ userId: z.string(), tz: z.string() }),
})
const countRecords = knowledgeTools.define({
name: 'count_records',
action: 'countRecords',
method: 'GET',
description: 'Count records',
input: z.object({ kind: z.string() }),
output: z.object({ count: z.number() }),
handler: async ({ userId, tz, input }) => countFor(userId, tz, input.kind),
})
serviceName and scope cannot be overridden by one definition. Use standalone
defineRuntimeTool when tools do not share a context schema or identity.
transports defaults to ['MCP', 'AGENT']; set an explicit subset when an
operation belongs on only one surface. Include 'CLI' explicitly to reuse the
definition through createCli({ runtimeTools }); adding CLI support never
widens existing definitions silently. The configured identity becomes the
hook/lifecycle OperationIdentity and the tool RequestEvent
(serviceName, action, httpMethod). A runtime operation has no HTTP route,
so no fake path is added.
When output is declared, Stitchkit validates the neutral handler result after
afterHandle. MCP owns structuredContent and isError: a presenter supplies
only rich content/metadata, while the framework inserts the validated
structured result and normalises failures. The Agent adapter uses the AI SDK’s
official toModelOutput callback, so execute and application UI keep the
neutral output while the model receives text/file content. Presentation
callbacks require an output schema.
The MCP registration uses an identity carrier: the SDK advertises the compiled JSON Schema but forwards the raw object into Stitchkit. Input failures therefore use the same validation, lifecycle and hook path as contract tools.
Managed wait, download, upload and view-file
The common imperative shapes have typed definition factories. They produce
ordinary runtime-tool definitions, so one value can enter runtimeTools on MCP
and Agent and receives the same lifecycle, hooks, context, cancellation,
collision checks and introspection as any defineRuntimeTool operation:
import {
defineDownloadTool,
defineUploadTool,
defineViewFileTool,
defineWaitTool,
} from 'stitchkit/tools'
import { createManagedFileBoundary } from 'stitchkit/files'
import { z } from 'zod'
const files = await createManagedFileBoundary({
root: '/srv/job-media',
createRoot: true, // creates only this final directory; /srv must already be trusted
inspectionTimeoutMs: 15_000,
inspect: ({ prefix, name, declaredMediaType, signal }) =>
inspectFile(prefix, { name, declaredMediaType, signal }),
})
const waitForJob = defineWaitTool({
name: 'wait_for_job',
description: 'Wait for a job to finish',
identity: { serviceName: 'jobs', action: 'waitForJob', scope: 'user' },
input: z.object({ id: z.string() }),
state: z.object({ id: z.string(), status: z.enum(['RUNNING', 'DONE']) }),
poll: ({ id }, context) => getJob(id, context.signal),
done: (state) => state.status === 'DONE',
backoff: [2, 3, 5],
defaultTimeout: 120,
})
const downloadResult = defineDownloadTool({
name: 'download_result',
description: 'Download a completed result',
identity: { serviceName: 'jobs', action: 'downloadResult', scope: 'user' },
input: z.object({ id: z.string() }),
resolveUrl: ({ id }) => resolveResultUrl(id),
files,
pathFromInput: ({ id }) => `results/${id}.bin`,
})
const uploadInput = defineUploadTool({
name: 'upload_input',
description: 'Upload a local input file',
identity: { serviceName: 'jobs', action: 'uploadInput', scope: 'user' },
output: z.object({ url: z.url() }),
files,
upload: (source, context) => uploadBytes(source.bytes, context.signal),
})
const viewResult = defineViewFileTool({
name: 'view_result',
description: 'Inspect generated media',
identity: { serviceName: 'jobs', action: 'viewResult', scope: 'user' },
// Local files are disabled when files is omitted. URL fetches retain SSRF
// protection; use allowPrivateHosts only for an explicitly trusted network.
files,
})
const runtimeTools = [waitForJob, downloadResult, uploadInput, viewResult]
Managed factories return neutral validated data. Expected failures go through
the standard failed ToolResult path rather than masquerading as a successful
payload with isError. Wait sleep is abort-aware, guarded downloads receive the
active signal, and upload implementations receive it on their typed context.
Managed view-file accepts one or several paths/URLs, retains successful
multimodal content beside structured per-item errors, and charges every item
to one total 20 MB batch budget. Its MCP and Agent presenters are built in;
remote fetch cancellation follows the active call signal.
paths is one path/URL string or an actual array of strings (at most 20).
Pass { paths: ['image.png'] }, not { paths: '["image.png"]' }: a JSON
array written as text fails input validation before any file or network access,
with the hint paths is a list written as text — pass an array of paths or one path. The same rule applies to array items and the raw mountViewFile input.
Ordinary file names containing brackets, such as [preview].png, remain valid.
A local file with an unsupported extension is refused before reading; its
per-item error identifies the caller’s path and the extension it saw.
The older mountWait, mountDownload, mountUpload and mountViewFile
functions remain
intentional raw MCP adapters with their text-envelope behavior. Use them under
rawTools only when opting out of managed policy is deliberate; they share the
same neutral mechanics but do not gain lifecycle or hooks. The raw view-file
adapter preserves its content-only MCP envelope while using the same bounded
batch operation.
Async-operation protocol
For a complete long-running operation, define the linked runtime-only surface once. Stitchkit owns transport names, validation and wait mechanics; the application still owns storage, execution, retries and domain states:
import {
createAsyncOperationSnapshotSchema,
defineAsyncOperation,
} from 'stitchkit/tools'
const id = z.object({ id: z.string() })
const state = z.object({
phase: z.enum(['pending', 'running', 'succeeded']),
value: z.string().optional(),
})
const snapshot = createAsyncOperationSnapshotSchema({
failure: z.object({ code: z.string() }),
progress: z.object({ current: z.number(), total: z.number() }),
})
const exportOperation = defineAsyncOperation({
mode: 'runtime-only',
name: 'export',
description: 'Export project data',
identity: { serviceName: 'exports', action: 'export', scope: 'user' },
startInput: z.object({ projectId: z.string() }),
id,
state,
snapshot,
start: ({ projectId }) => enqueueExport(projectId),
authorize: ({ id }, capability, ctx) => authorizeExport(id, capability, ctx),
inspect: ({ id }, ctx) => readExport(id, ctx.signal),
classify: (job) => ({ phase: job.phase }),
result: {
output: z.object({ value: z.string() }),
handler: (job) => ({ value: job.value ?? '' }),
},
})
const runtimeTools = exportOperation.runtimeTools
Durable application-owned execution
The executable reference
durable-async-operation-harness.ts
shows the complete boundary behind that surface. It composes
defineAsyncOperationContract, defineAsyncOperation, createApplication,
createBoundedAdmission, managed shutdown and createAgentToolFenceLifecycle
around an injected store and provider.
The store transactionally owns the idempotency key and normalized request
hash. Reusing both returns the existing operation; reusing the key with a
different hash conflicts. Every mutation is an expected-revision CAS. Before an
external call, the application persists a unique attempt/effect key and marks
the dispatch possibly-dispatched; a lost response is therefore reconciled by
that key after restart and is never treated as permission to submit the effect
again. Provider acknowledgement, progress, cancellation intent, result and
artifact references remain durable application state.
Agent tool fencing and operation idempotency are separate guarantees. The fence prevents a stale run owner from crossing an effect boundary; the transactional idempotency record makes a repeated current call converge on one operation. Neither replaces the other.
Stitchkit deliberately does not own the durable queue, operation database, lease renewal, provider retry/reconciliation protocol, asset catalog or domain states. The process-local application resource stops admission and drains accepted work; unresolved durable records survive shutdown for the next recovery pass. The packed consumer lane executes this composition from the published package on Bun and Node.
Every follow-up repeats authorize; an opaque id is never authority. Aborting
wait only stops waiting and never calls optional domain cancel.
For a new HTTP surface, derive the canonical capability contract once. The
factory defines schemas and routes only; the application still supplies normal
handlers to implement and owns the job:
import { defineAsyncOperationContract } from 'stitchkit/tools'
import { z } from 'zod'
const operationId = z.object({ id: z.string() })
const operationSnapshot = z.object({
phase: z.enum(['pending', 'running', 'succeeded', 'failed', 'cancelled']),
})
const operations = defineAsyncOperationContract({
prefix: 'exports',
scope: 'user',
description: 'Export project data',
startInput: z.object({ projectId: z.string() }),
id: operationId,
snapshot: operationSnapshot,
cancel: true,
result: z.object({ downloadUrl: z.url() }),
})
// Pass operations.contract and ordinary handlers to implement(...).
// operations.capabilities/schemas/adapters are the canonical protocol metadata.
If an existing dedicated contract has a different wire shape — for example
start returns a snapshot while follow-ups accept separate envelopes — bind it
without casts or duplicate endpoints. Inline callbacks are contextually typed;
every returned id/input is parsed by the destination schema:
import { defineContract } from 'stitchkit/contract'
import { bindContractAsyncOperation } from 'stitchkit/tools'
const startOutput = z.object({ operation: operationId, acceptedAt: z.string() })
const statusInput = z.object({ operationId: z.string() })
const waitInput = z.object({ lookup: operationId })
const existing = defineContract({ prefix: 'exports' }, {
start: {
method: 'POST', path: '/', desc: 'Start export',
input: z.object({ projectId: z.string() }), output: startOutput,
},
status: {
method: 'POST', path: '/status', desc: 'Read export status',
input: statusInput, output: operationSnapshot,
},
wait: {
method: 'POST', path: '/wait', desc: 'Wait for export',
input: waitInput, output: operationSnapshot,
},
})
const operationHandlers = {
start: () => ({ operation: { id: 'example' }, acceptedAt: new Date().toISOString() }),
status: (): z.output<typeof operationSnapshot> => ({ phase: 'pending' }),
wait: (): z.output<typeof operationSnapshot> => ({ phase: 'succeeded' }),
}
const bound = bindContractAsyncOperation({
mode: 'contract-backed',
binding: 'adapted',
contract: existing,
id: operationId,
capabilities: { start: 'start', status: 'status', wait: 'wait' },
adapters: {
idFromStart: (output) => output.operation,
inputFor: {
status: (id) => ({ operationId: id.id }),
wait: (id) => ({ lookup: id }),
},
},
handlers: operationHandlers,
})
Direct binding remains the short form when start already returns the id and every follow-up reuses that exact schema instance. Its ID schema must be wire-stable: input and output types match and parsing has no transform, coercion, default or overwrite. This also excludes same-type transforms because the direct adapters would parse an already parsed ID again. Use adapted binding and explicitly project the parsed ID back into each follow-up input. In both modes the binder creates no router; it returns the existing contract, handlers and protocol metadata for application composition.
Explicit raw SDK registration
rawTools is deliberately named as an escape hatch. A tool registered there
does not receive stitchkit schema policy, lifecycle, per-call context or
hooks. The built-in mountViewFile, mountWait, mountDownload and
mountUpload helpers remain raw for callers that choose that boundary; the
media helper fetches with SSRF and path-traversal defenses:
import { createMcpHandler, mountViewFile } from 'stitchkit/tools'
const handleMcp = createMcpHandler({
serverInfo: { name: 'my-app', version: '1.0.0' },
auth,
services: [service],
rawTools: (server) => mountViewFile(server, { files }),
})
Use raw registration only when opting out is intentional. For a protected
view_file, use defineViewFileTool in runtimeTools; it supplies the
canonical schemas, shared batch budget, structured partial failures and both
multimodal presenters. resolveMedia remains the single-item core for custom
operations whose contract intentionally differs.
Introspecting the complete tool surface
Deferred tool search, name snapshots and boot diagnostics accept the same object-shaped surface as the mounts. Contract operations are followed by runtime definitions, matching mount order; exposure filters and collisions are resolved by Stitchkit rather than by consumer code:
import {
buildToolManifest,
listToolNames,
summarizeTransports,
} from 'stitchkit/tools'
const surface = { services, runtimeTools: [renderPreview] }
const manifest = buildToolManifest({
...surface,
transport: 'AGENT',
flattenUnionInput: true,
})
const names = listToolNames(surface)
const summary = summarizeTransports(surface)
buildToolManifest returns the exact immutable presentation schema shown to
the selected MCP or Agent transport. It does not execute validation effects and
fails first on duplicate contract/runtime names. There is no runtime-only
manifest helper or public mount adapter: the framework owns merging and schema
projection.
Applications using Stitchkit’s durable runtime can compose this manifest with
bounded per-run activation through createDeferredAgentToolSurface from
stitchkit/agent-runtime. It keeps selected calls as direct mountAgent tools
rather than routing them through a generic executor. Search receipts live in
ordinary durable tool-result history, so recovery restores activation and a
queued successor cannot inherit it. See
Bounded deferred tool catalogs
and ADR 0129. Raw mountAgent, mountMcp and buildToolManifest behavior is
unchanged.
listToolNames remains diagnostic and reports kind: 'contract' | 'runtime'
for every identity. summarizeTransports returns contractServices,
runtimeTools, aggregate totals, and a sources breakdown. Runtime tools
contribute only to MCP/Agent according to their transports; they never inflate
HTTP or CLI counts.
Logging tool calls — createToolLogger
Every tool mount fires an afterToolCall hook. createToolLogger is a ready
preset for it — one line logs each call (ok / failed, duration, which endpoint,
keyed by the endpoint’s stable serviceName / key identity):
import { createToolLogger } from 'stitchkit/tools'
mountMcp(server, services, { hooks: createToolLogger() })
// [tool] ok list_widgets (widgets.list) 12ms
// [tool] warn get_widget (widgets.get) NOT_FOUND 4ms
By default the line goes to console.error — stderr, never stdout. That is
deliberate: in a stdio-MCP server stdout is the JSON-RPC protocol channel, and a
log line written there corrupts the stream. If you pass your own log, keep it
off stdout for any stdio deployment.
Pass log to redirect the line, or onRecord to feed a metrics sink the
structured ToolCallRecord. That record carries traceId whenever an
observability context is active, so a tool call made inside an HTTP request
joins that request’s log line on one key — see
Observability. For a boot-time picture of what is exposed
where, summarizeTransports({ services, runtimeTools }) returns the mixed
per-transport counts for you to log.
One handler, three callers
A contract handler runs the same for an HTTP request, an MCP tool call and an
agent tool call. ctx.source ('http' · 'mcp' · 'agent') tells it which —
the rest of the context (params, input, anything context injects) is
identical. Write the handler once; it serves every surface.