← stitchkitDocumentation

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-level expose to set once (→ ADR 0036). A snapshot of listToolNames fails 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, so createAuthHook resolves identity through resolveFromContext, not resolve. Set it (read the identity your auth / context injected) — 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 static services array is checked when createMcpHandler is constructed, so a bad schema fails the deploy, not the first request. Better than a tool that silently vanishes from the surface.
  • 'warn' — log through logger and 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)

  • iss on every authorization response (RFC 9207, SEP-2468). Success and error redirects carry iss, and the AS metadata advertises authorization_response_iss_parameter_supported: true. A client that talks to several authorization servers validates iss before 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 ignores iss is unaffected.
  • application_type on registration (SEP-837). A client may declare "native" (desktop / CLI) or "web" in its DCR body. A native client may register an http loopback redirect (http://127.0.0.1:…, RFC 8252 §7.3); a web client is held to https only — that mismatch is the usual cause of the redirect_uri rejection 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. defaults joins the tool call’s arguments before the endpoint’s own input parses them, so the handler receives an ordinary input — 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 output first, as always; then project (a pure, synchronous function of the full result and the parsed input) or a slice by the view’s output, then validation against the view’s schema. A projection that throws, returns a Promise or breaks its schema is INTERNAL_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 input default, the catalog stamp and the surface snapshot follow it. HTTP, OpenAPI, the typed client and the in-process createToolInvoker keep the full answer — code, not a model, is calling there.
  • Four forms, one helper. With defaults alone the call changes and the answer is the full output, validated by it and advertised as it. With output and project the answer has its own schema. With project alone it reshapes values inside the full schema. With output alone it is a slice, and the full result must fit that schema — withToolView refuses the endpoint at compile time when it does not.
  • Exposure. A view needs a tool transport. With createContractFactory({ toolExposure: 'explicit' }) an endpoint without expose is HTTP-only, so an endpoint with a view names its tool transports in expose.
  • What it refuses. defineContract rejects a view on an endpoint with no tool transport or no full output, on a raw, rawBody, multipart, responseMeta or streaming endpoint, a view that declares no defaults, output or project — 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.