ADR 0019 — Generic native MCP tools
Context
Most tools come from a contract (mountMcp). Some jobs cannot be expressed as a
request/response contract — they are imperative: block-poll an async job until it
is done, download a result URL to disk, upload a local file. stitchkit already
ships one such generic native tool, mountViewFile (materialise a media URL into
MCP image/audio blocks), via the nativeTools(server) escape hatch.
Without more of these, every app re-hand-rolls them on the raw McpServer:
- it imports
@modelcontextprotocol/sdkdirectly to typeserver.registerTool, - it re-implements the same backoff/timeout poll loop the CLI
--waitalready has (pollUntilDone), drifting from it.
A consuming app commonly hits exactly this — a hand-written wait / download
/ upload native tool with its own poll loop and a direct SDK import.
Decision
Ship the imperative tools as generic native tools, mechanism-only — the app
injects the domain. Like mountViewFile, stitchkit owns the MCP plumbing
(registerTool, the SDK types, the poll/fetch/fs mechanics); the consumer
supplies the domain predicate.
mountWait(server, config)— pollsconfig.poll(args)with backoff untilconfig.done(state)or the timeout. The loop is the sharedpollUntil.mountDownload(server, config)—config.resolveUrl(args)→ fetch → write to disk with a content-type / URL-derived extension.mountUpload(server, config)— hands a localpathtoconfig.upload.pollUntil— one backoff/timeout loop, the single engine behind both the CLI--wait(pollUntilDone) andmountWait. No second copy.type McpServeris re-exported fromstitchkit/tools— a consumer writing a native-tool registrar ((server) => …) types it without importing the SDK.
These stay domain-free (ADR 0002): stitchkit knows nothing about
“generations” or “statuses” — the consumer’s poll / done / resolveUrl /
upload carry all the domain.
Consequences
- An app composes native tools by configuration, not by hand-rolling on the raw
SDK — no direct
@modelcontextprotocol/sdkimport in the consumer (the pilot’spackages/mcpdropped to zero). - One poll loop (
pollUntil) serves the CLI and MCP — the duplicate the pilot carried is gone; CLI--waitand MCPwaitcannot drift. - The
nativeTools(server)raw hatch still exists for genuinely bespoke tools; these three just cover the common imperative shapes so most apps never need it. - The MCP SDK coupling lives where it belongs — in the framework, behind a generic surface — not scattered across consumers.