← stitchkitDocumentation

MCP & AI agents

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. No tool is hand-written; both come from the contract.

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

desc is the tool description the model reads — write it for the model, not just for a human. A multipart endpoint is never a tool. The tool name defaults to a verb-aware name from the method + prefix (listlist_widgets, getget_widget); set toolName for an explicit one. 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) resolves every tool name your services expose (the exact resolver the mounts use), with its (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)).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.

MCP — createMcpHandler

createMcpHandler builds a complete Streamable-HTTP MCP server as a single Request → Response handler. It owns the whole MCP lifecycle — the SSE event store, per-session transports, the server instances — so your app never imports @modelcontextprotocol/sdk itself.

import { createMcpHandler } from 'stitchkit/tools'

const handleMcp = createMcpHandler({
  serverInfo: { name: 'my-app', version: '1.0.0' },
  auth: (req) => resolveApiKey(req),   // → an identity, or null for 401
  services: [usersService, catalogService],
})

Mount the returned handler on a raw route — typically /mcp:

createServer({
  services,
  rawRoutes: [{ method: 'ALL', path: '/mcp', handler: (req) => handleMcp(req) }],
})

McpHandlerConfig

Field Purpose
serverInfo MCP server identity — { name, version }
auth (req) => identity | nullnull rejects with 401
services the services to expose — an array, or (auth) => ServiceDef[]
context (auth) => {…} — values merged into every tool handler’s ctx
lifecycle beforeHandle / afterHandle — the tool-side auth gate (see below)
hooks tool-call observability hooks — afterToolCall fires on every result
onIncompatibleSchema 'throw' (default) · 'skip' · 'warn' — see below
logger a StitchLogger for the 'warn' policy
nativeTools register non-contract tools directly on the McpServer
instructions a short host-facing usage hint, surfaced to MCP tool-search

services and context receive the resolved identity, so a tenant can be shown only its own tools and every handler can read ctx.tenantId.

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) — the tool-side twin of createServer’s hooks. Pass the same createAuthHook result you give the HTTP server and tool calls are scope-checked by the identical rules:

createMcpHandler({ serverInfo, auth, services, lifecycle: { beforeHandle: authHook } })

Without it, a tool call bypasses the HTTP beforeHandle — 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 their last argument — 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: (toolName, args, result, ms, ctx, endpoint) => {
    audit({ service: endpoint.serviceName, action: endpoint.key, ok: result.ok, ms })
  },
}

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.

Incompatible schemas — onIncompatibleSchema

A contract schema that JSON Schema cannot represent (a z.date(), a z.map()) cannot become a tool. onIncompatibleSchema 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.

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 variant’s fields become optional with a Required if <disc> = … hint.

It is deep: unions are flattened at every depth — top level, object fields, array items, and through optional / nullable / default / intersection wrappers — so no oneOf survives anywhere (e.g. a content.parts[] that is an array of a discriminated union). It is advertised-only and lossy: the original schemas stay the validation schemas in executeToolMethod, so requests are still validated against the real union. Schemas a transform cannot safely rebuild (refined / piped / lazy / plain non-discriminated unions) are left as-is.

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'

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 { createStdioMcpServer } from 'stitchkit/tools'

await createStdioMcpServer({
  serverInfo: { name: 'my-app', version: '1.0.0' },
  auth: resolveIdentity(),          // resolved once at startup, not per request
  services: [usersService],
})

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.

Both transports build the server through the shared buildMcpServer — same contract pipeline, same services / context / hooks / nativeTools / 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), Dynamic Client Registration (RFC 7591) 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, 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 handleMcp = 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 — DCR, /authorize (PKCE), /token.
const oauthRoutes = mountOAuthProvider({
  issuer,
  resource,
  signingSecret: env.OAUTH_SECRET,
  clients, codes, refreshTokens,  // your stores (DB or in-memory)
  authorizeUser,                  // your login + consent → { userId } | Response
})

createServer({
  services,
  rawRoutes: [
    { method: 'ALL', path: '/mcp', handler: (req) => handleMcp(req) },
    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 } to issue a code, 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.

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 deprecated in the 2026-07-28 spec in favour of Client ID Metadata Documents (CIMD), with a ≥12-month window. DCR keeps working and stays supported here; CIMD support is tracked separately.

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 { createStdioMcpServer, implementRemote } from 'stitchkit/tools'

const http = createHttpClient({
  baseUrl: 'https://api.example.com',
  headers: () => ({ Authorization: `Bearer ${apiKey}` }),
})

await createStdioMcpServer({
  serverInfo: { name: 'my-app', version: '1.0.0' },
  auth: null,
  services: contracts.map((c) => implementRemote(c, http)),
})

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.

Structured output

When a contract endpoint declares an object output, its MCP tool registers that as the tool outputSchema and the result carries structuredContent alongside the text block — the structured payload an MCP App UI consumes.

AI agents — mountAgent

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)

lifecycle works the same as on the MCP server — without it an agent tool call bypasses the HTTP beforeHandle auth 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.

Native multimodal tools

Contract tools return JSON. For a tool that returns an image or other multimodal content, register a native tool. mountViewFile is the built-in one — it lets a model fetch and view a file (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],
  nativeTools: (server) => mountViewFile(server, { baseDir: '/srv/uploads' }),
})

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

Pass log to redirect the line, or onRecord to feed a metrics sink the structured ToolCallRecord. For a boot-time picture of what is exposed where, summarizeTransports(services) returns per-transport operation counts (HTTP / MCP / AGENT / CLI) 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.