@johnhenry/mcp-query
v0.0.0
Published
A reactive, cached, embeddable MCP client for non-agentic apps. TanStack Query keys + RTK Query tags + LSP-client lifecycle, on top of the official MCP SDK.
Downloads
178
Readme
mcp-query
A reactive, cached, embeddable MCP client for ordinary (non-agentic) applications.
MCP is almost always consumed by LLM agents. But an MCP server is just a typed,
introspectable capability surface — tools, resources, prompts — and there's no
reason a normal app can't use it as a universal data/capability layer. mcp-query
gives such apps the developer experience that Apollo/React-Query gave GraphQL/REST
apps: hooks, a cache, reactivity, optimistic updates, devtools — on top of the
official v2 MCP SDK (@modelcontextprotocol/client).
MCP 2026-07-28 ready, v1 by default. Unconfigured connections speak the
classic 2025-era protocol byte-for-byte (no probe, no behavioral changes).
Opt into newer revisions per client or per connection with the additive
versions list:
new MCPClient({ servers, versions: ["2026-07-28", "2025-11-25"] });
// → probe via server/discover; speak 2026-07-28 where offered, fall back
// losslessly to v1 otherwise. A modern-only list (["2026-07-28"]) pins.On a modern connection you get the full revision — multi-round-trip
elicitation/sampling through the same human-in-the-loop broker, change
notifications over subscriptions/listen, SEP-2549 ttlMs/cacheScope
cache hints, per-request logLevel — while legacy connections keep the
classic behavior (including session resumption). connection(name).era
tells you what was actually negotiated; versionNegotiation remains the
low-level escape hatch. Deprecated
surfaces (sampling/roots/logging per SEP-2577, sessions per SEP-2567, the old
tasks RPCs per SEP-2663) are annotated @deprecated and remain functional on
legacy connections. Tasks are reimplemented on the io.modelcontextprotocol/tasks
extension wire shapes (legacy-era-gated until the SDK ships its extension
runtime).
Status: published, working reference implementation.
mainis pre-2026-07-28: this package is at0.0.1, thelatesttag on npm, and speaks MCP 2025-11-25 (v1) only, on top of the official@modelcontextprotocol/sdk@^1.29.0.tsc --noEmitis clean and the full vitest suite passes (npm testfor the current count), including end-to-end coverage of the cache, multiplexing, protocol-driven invalidation, dynamic registration, reconnect, the human-in-the-loop broker, and Inspector-style tooling (message log, manual sampling, auth recorder, CLI) — all driven against a real SDK server over an in-memory transport, with the codegen CLI verified against the live@modelcontextprotocol/server-everything. A preview build with the 2026-07-28 migration (v2 SDK, dual-era support,versionsnegotiation) is in progress on PR #17, held until the spec finalizes on the 28th — try it via thercdist-tag:npm install @johnhenry/mcp-query@rc. See Supported protocol versions below.
Install
npm install @johnhenry/mcp-queryThis installs latest (0.0.1), which speaks MCP 2025-11-25 (v1) only. To preview
the in-progress 2026-07-28 migration ahead of its release, install the rc tag instead:
npm install @johnhenry/mcp-query@rc.
Supported protocol versions
| npm tag | Version | Protocol | SDK |
|---|---|---|---|
| latest (default, this branch) | 0.0.1 | MCP 2025-11-25 only | @modelcontextprotocol/sdk@^1.29.0 (v1) |
| rc | 0.1.0-rc.1 | MCP 2025-11-25 + opt-in 2026-07-28 | v2 SDK, dual-era |
The rc preview (built from PR #17,
not yet merged to main) adds opt-in support for the finalized 2026-07-28 revision via
an additive versions list:
new MCPClient({ servers, versions: ["2026-07-28", "2025-11-25"] })This probes each server with server/discover, speaks 2026-07-28 where the server
offers it, and falls back losslessly to 2025-11-25 (v1) otherwise; a modern-only list
pins (no fallback). Unconfigured connections — and everything installed from latest
today — keep speaking 2025-11-25 byte-for-byte, no probe.
Develop
npm install
npm run typecheck # tsc --noEmit (covers src + examples)
npm test # vitest run
npm run build # emit dist/ (ESM + .d.ts) — what `npm publish` ships
npm run example:node # runnable: drives @modelcontextprotocol/server-everything
npm run codegen -- --command npx --args "-y @modelcontextprotocol/server-everything" --out src/mcp.gen.tsExamples
A graded series from one-liner to full app lives in examples/ (see
examples/README.md) — 01→06 are runnable with no network
(npm run example:01 … example:06):
- 01 connect/list/call · 02 caching + invalidation · 03 live subscriptions · 04 multi-server routing · 05 human-in-the-loop · 06 running alongside a separate MCP client on shared state.
examples/node-everything.ts— guided tour against the realserver-everything(npm run example:node).examples/07-hybrid-agent-ui.tsx/react-app.tsx— illustrative React (agent + live UI + broker; every React surface).
What the tests cover
| File | Exercises |
|---|---|
| test/cache.test.ts | staleTime, subscriber ref-counting, tag + protocol invalidation, optimistic rollback, gc |
| test/router.test.ts | tool/resource routing, namespacing, ambiguity errors |
| test/connection.test.ts | connect/negotiate, cursor pagination, resources/updated, list_changed, reconnect with a changed capability set |
| test/session-resume.test.ts | session capture/persist, resume-skips-initialize, validated fallback on forgotten sessions, resume across reconnect |
| test/client.test.ts | multi-server routing, URI-tagged caching, subscribe ref-count, declared invalidation, isError rollback |
| test/codegen.test.ts | JSON Schema → TS, generated output compiles under --strict, paginated generateFromClient |
| test/react.dom.test.tsx | useResource loading→data, useTool invoke, useTools reactivity on list_changed (happy-dom) |
The in-memory mock server (src/testing/mockServer.ts, exported as mcp-query/testing)
is reusable for testing your own integrations.
Design & background
- docs/api.md — the full API reference: every feature with an example.
- docs/backend.md — using mcp-query server-side: multi-tenant context, interceptors, authorization + audit, resilience, metrics/health, the gateway re-server, per-principal sessions, and the multi-node L2 cache.
The conceptual analysis behind every choice lives in docs/:
- docs/design.md — the Apollo reframe, the GraphQL↔MCP mapping, what's
similar/different/new/harder/impossible, the MCP server conventions a client must respect, and
how
MCPClientrelates to the SDK'sClient(wraps, not replaces). - docs/prior-art.md — does this already exist? Lessons from TanStack Query, RTK Query, urql, Relay, gRPC, tRPC, Connect, and LSP.
- docs/sampling-and-non-agentic.md — why "non-agentic"
≠ "no LLM," and how to plug Chrome's built-in AI into the
samplinghandler. - docs/human-in-the-loop.md — the InteractionBroker: one approval queue for sampling + elicitation, with prompt-edit, response-redaction, trust policy, and an audit log.
- docs/inspector.md — Inspector-style debugging on mcp-query: raw
JSON-RPC message log, manual (human-as-model) sampling, OAuth-debug recorder, and the
mcp-query-inspectCLI + per-request timeouts. - docs/webmcp.md — experimental WebMCP bridge: expose backend tools
to an in-browser agent (
bridgeToWebMCP), and consume page tools (webMcpToolServer).
The thesis
The right prior art for an MCP client is not Apollo — Apollo's defining feature (normalized entity caching) is impossible on MCP's opaque, identity-free results. The right models are:
| Borrowed from | What we took |
|---|---|
| TanStack Query | key→document cache, staleTime/gcTime, background refetch, cache-and-network |
| RTK Query | tag-based invalidation (providesTags/invalidatesTags) |
| urql | document cache by default, normalization strictly opt-in |
| Language Server Protocol | per-server lifecycle state machine, dynamic registration (list_changed ≙ client/registerCapability), N-server multiplexing, reconnection with capability re-negotiation, cancellation/progress |
| Connect-Query | typed RPC feeding a key-cache; codegen from schema (JSON Schema here) |
The MCP bonus: a chunk of the invalidation you'd hand-declare in RTK Query is
emitted by the protocol itself (notifications/resources/updated,
notifications/.../list_changed), so well-behaved servers invalidate your cache for free.
Architecture (two layers)
┌──────────────────────────── React bindings ───────────────────────────┐
│ useResource (useQuery) useTool (useMutation) useTools/usePrompt … │
└───────────────▲───────────────────────▲──────────────────▲─────────────┘
│ useSyncExternalStore │ │
Layer 1: CACHE ──────────────────────┴─────────────────────────┴──────────────────┴───────────
MCPCache: key→entry, tag index, invalidateTags, onResourceUpdated/onListChanged,
ref-counted subscribers (→ drives resources/subscribe), gc, optimistic patch
▲ ▲
writes / invalidation fires onResourceUpdated / onListChanged
│ │
Layer 2: CONNECTIONS ─────────────────┴─────────────────────────┴──────────────────────────────
MCPClient multiplexer + router + host handlers (sampling/elicitation/roots)
└─ ServerConnection (×N) LSP-style state machine · dynamic registration · reconnect+reconcile
└─ @modelcontextprotocol/sdk Client ── stdio / Streamable HTTP / SSEThe seam that matters: the connection layer drives the cache layer.
notifications/resources/updated→cache.onResourceUpdated→ invalidates that exact URI tag.notifications/<kind>/list_changed→ re-list →cache.onListChanged→useTools()re-renders.- cache subscriber count (>0) → connection issues
resources/subscribe; (==0) → unsubscribe + gc. - reconnect → re-
initialize(capabilities may differ) → reconcile → re-list → resubscribe observed entries. - with a
sessionStore(opt-in), reload/reconnect resumes the server-side Streamable HTTP session instead: the persistedMcp-Session-Idskipsinitialize, apingvalidates it, and a forgotten session falls back to fresh init (docs/api.md).
Usage
import { MCPClient } from "@johnhenry/mcp-query";
import { MCPProvider, useResource, useTool, useTools } from "@johnhenry/mcp-query/react";
import { DevtoolsHub, MCPDevtools } from "@johnhenry/mcp-query/devtools";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const hub = new DevtoolsHub();
const client = new MCPClient({
servers: {
fs: { transport: () => new StdioClientTransport({ command: "mcp-server-filesystem", args: ["/work"] }) },
github: { transport: () => new StreamableHTTPClientTransport(new URL("https://mcp.example.com/github")) },
},
schemeMap: { file: "fs", github: "github" },
handlers: {
// Registering a handler is what advertises the capability to the server.
elicitation: async (req) => showModal(req.message, req.requestedSchema), // → UI dialog
roots: () => [{ uri: "file:///work" }],
// no `sampling` → not advertised → server never asks for an LLM. (non-agentic)
},
devtools: hub,
});
await client.connect();
function App() {
return (
<MCPProvider client={client}>
<Issues />
<MCPDevtools hub={hub} />
</MCPProvider>
);
}
function Issues() {
// useQuery analog: read a resource, live-subscribe, auto-tagged by URI.
const { data, isLoading } = useResource("github://repos/acme/app/issues", {
fetchPolicy: "cache-and-network",
subscribe: true,
});
// useMutation analog. Args validate against the tool's inputSchema (bind a form to it).
// `invalidates` is the fallback for servers that DON'T emit resources/updated.
const [createIssue, { isPending, isDestructive, inputSchema }] = useTool("github.create_issue", {
invalidates: ["res:github:github://repos/acme/app/issues"],
optimistic: (a) => [{
key: { kind: "resource", server: "github", uri: "github://repos/acme/app/issues" },
recipe: (prev: any) => ({ ...prev, contents: [...(prev?.contents ?? []), { title: a.title }] }),
}],
});
const { tools } = useTools({ server: "github" }); // re-renders on tools/list_changed
// ...render `inputSchema` as a form; gate a confirm dialog on `isDestructive`...
}What's deliberately not here (and why)
- Normalized caching. No global object identity in MCP results → impossible to do
automatically. The opt-in entity layer is
providesTags+entityTag()only. - Static end-to-end types (tRPC-style). MCP servers are polyglot/decoupled, so types
come from codegen against
tools/listJSON Schemas +createTypedHooks(), not TS inference. Everything else discussed during design — codegen-typed hooks, sampling (incl. Chrome built-in AI), polling, persistence, Suspense, dynamic topology, completion, ping — is now implemented. See docs/api.md for every feature with an example.
Feature coverage
Reads/queries (useResource, useToolResult, queryTool) · mutations (useTool with
optimistic + invalidation + progress + cancel) · capability lists + templates + prompts ·
useServerState · in-flight dedup · structural sharing · polling · Suspense · persistence ·
entity tags · structured output + annotation helpers · human-in-the-loop broker (sampling +
elicitation, trust policy, audit) · Chrome built-in AI sampling · codegen + typed hooks ·
ping · completion · dynamic add/remove server · read retry · devtools · raw JSON-RPC message
log · manual (human-as-model) sampling · OAuth-debug recorder · mcp-query-inspect CLI +
per-request timeouts. Full vitest suite green — run npm test for the current count.
File map
| Path | Role |
|---|---|
| core/cache.ts | the centerpiece — keys, tags, invalidation, subscribers, gc, optimistic |
| core/connection.ts | LSP-style lifecycle, dynamic registration, reconnect + reconcile |
| core/client.ts | multiplexer + imperative read/call/list API |
| core/router.ts | resolve tool-name / resource-URI → server (namespacing, ambiguity) |
| core/handlers.ts | sampling/elicitation/roots; registration ⇒ capability advertisement |
| core/keys.ts, core/tags.ts | cache-key shapes + tag conventions |
| react/* | useResource, useTool, useTools/useResourceList/usePrompt |
| devtools/* | event protocol + three-pane panel |
