Design: @gemstack/mcp — graduate the standalone MCP server framework
Tracking: epic #1 (the last unchecked family box). This is the server-authoring axis of the MCP taxonomy, the peer of @gemstack/ai-mcp (the agent bridge). It is agent-agnostic, NOT ai-* — nothing here depends on @gemstack/ai-sdk, and ai-sdk must never depend on it. See Architecture.md "MCP taxonomy (two axes, do not conflate)".
This is a design proposal, no code. Gated on family alignment before anything lands.
What graduates
@rudderjs/mcp (mature, v6.2.1) is a full framework for authoring MCP servers: McpServer / McpTool / McpResource / McpPrompt / McpResponse / Mcp, the metadata + MCP-spec-annotation decorators, OAuth2 middleware, a streamable-HTTP transport, an observer registry, and a McpTestClient. The framework itself is already mostly agent-agnostic and standalone-testable. The work is to lift the framework-agnostic core out from under the Rudder framework wiring.
The carve-out differs from the ai-mcp one in one important way
ai-mcp was a hard-move-no-shim because it was days old with zero installed base. @rudderjs/mcp is the opposite: published, mature, real consumers. Architecture.md reserved exactly this case:
Keep the deprecated-shim-for-one-minor play in reserve for the first carve-out after 1.0, where a real installed base exists.
So the shape is the @rudderjs/ai -> @gemstack/ai-sdk shim pattern, not the hard-move:
@gemstack/mcp = the standalone, framework-agnostic core, fresh 0.1.0.
@rudderjs/mcp stays a (now-thin) package: it re-exports the core from @gemstack/mcp and keeps only the Rudder binding (the McpProvider, the @Handle -> Rudder-container wiring, the CLI commands, the doctor check). Existing @rudderjs/mcp importers keep working unchanged.
Difference from the @rudderjs/ai shim: that one was a pure re-export (zero Rudder-specific surface left). @rudderjs/mcp legitimately owns Rudder-specific surface (provider, DI decorator semantics, CLI), so it remains a real thin binding package on top of @gemstack/mcp, not an empty re-export.
Coupling map (from a full source audit of packages/mcp)
@rudderjs/* dep |
Kind |
Where |
Decouple difficulty |
@rudderjs/json-schema |
hard dep |
zod-to-json-schema.ts (one convertSchema() call) |
Easy. Replace with Zod 4 native z.toJSONSchema() (exactly what ai-sdk Phase 2 did), keep the date-format + open-object fallbacks. Zero behavior change. |
@rudderjs/router |
optional peer |
provider.ts, runtime/http-transport.ts |
Already done. Consumed only through a duck-typed { all(path, handler, mw?) } shape via resolveOptionalPeer, with a graceful fallback. No Rudder-specific API. Keep the pattern; also expose a framework-neutral handler (see decisions). |
@rudderjs/console |
optional peer |
commands/make-*.ts (type-only MakeSpec), doctor.ts (registerDoctorCheck) |
Moderate-easy, but it stays in the binding. Scaffolders + doctor are CLI wiring, not framework. They live in @rudderjs/mcp, not in the agnostic core. |
@rudderjs/core |
hard dep |
provider.ts (ServiceProvider, CLI rudder.command()), runtime/handle-deps.ts (reads __rudderjs_app__/__rudderjs_instance__ off globalThis), auth/oauth2.ts (MiddlewareHandler type, lazy passport) |
The real work. Split into two: (a) ServiceProvider/CLI = framework wiring -> stays in the binding; (b) the DI that @Handle() performs = needs a neutral seam (below). |
Tests import no undeclared @rudderjs/* packages (unlike ai-sdk, whose suite pulled cache/storage/queue) — packages/mcp already runs its full suite on zod + Node built-ins alone. The boost/ dir is Claude-skills docs, ships as-is.
The one genuine design problem: the @Handle() DI seam
Everything else is mechanical. The crux is @Handle(Logger, Cache, ...) on a tool/resource/prompt method: runtime/handle-deps.ts resolves those tokens by reading Rudder's container off globalThis. A framework-agnostic core cannot assume Rudder's container exists.
Mirror the ai-sdk Phase-2 resolution (neutral optional adapter seam; core deps collapse to zod only):
- Core defines a minimal resolver contract, e.g.
McpResolver = { resolve(token): unknown }, and a setMcpResolver(resolver) (or a per-Mcp/per-server option). With no resolver set, @Handle either no-ops or throws a clear "no resolver registered" error — the core never reaches for globalThis.__rudderjs_*.
- The Rudder binding registers a resolver that delegates to Rudder's container, so
@Handle(Logger) keeps working byte-for-byte in a Rudder app. Non-Rudder users either skip @Handle (plain closures/constructor injection) or register their own resolver (one adapter function over Awilix/tsyringe/InversifyJS/etc).
This keeps the decorator surface identical for existing users while making the core container-agnostic.
Decisions to confirm (A/B, like #7/#8/#9)
- Shim shape. Confirm
@rudderjs/mcp stays a thin binding package (re-export core + Rudder provider/DI/CLI/doctor) rather than a pure re-export. (Recommended: yes — it owns real Rudder surface.)
- DI seam. Neutral
setMcpResolver + binding-registers-Rudder-container (recommended), vs. drop DI from core entirely and re-add @Handle only in the binding. Trade-off: the former keeps @Handle usable by non-Rudder users with their own container; the latter keeps the core smaller but makes @Handle a Rudder-only feature.
- HTTP transport surface. Keep the duck-typed router peer and add a framework-neutral entry (a plain
(req, res) Node handler / createMcpHttpHandler()) so non-Rudder users can mount the server on Express/Hono/Vike/raw http without a Rudder router. (Recommended: add the neutral handler; it is already almost that internally.)
- OAuth2 typing. Re-type
oauth2McpMiddleware against a generic Connect-style (req, res, next) signature instead of importing core's MiddlewareHandler; keep passport as an optional peer of the binding, not the core. (Recommended.)
json-schema removal. Inline Zod 4 native conversion in the core, drop the @rudderjs/json-schema dep (same move as ai-sdk). (Recommended.)
- Relationship to
ai-mcp. Keep them independent: ai-mcp's mcpServerFromAgent continues to use the raw MCP SDK, not @gemstack/mcp, so neither package depends on the other. The tiny "both can produce a server" overlap is from different inputs and is expected, not duplication. (Recommended: no dependency edge between them.)
- Versioning.
@gemstack/mcp starts fresh at 0.1.0 (reset, like ai-sdk), not continuing 6.x. @rudderjs/mcp gets a minor/patch when it is repointed onto the core.
Proposed phasing
- Phase 1 — scaffold + agnostic core. Copy
packages/mcp/src into gemstack/packages/mcp, rename @rudderjs/mcp -> @gemstack/mcp, strip the framework wiring (provider.ts, commands/*, doctor.ts) out of the core, drop @rudderjs/json-schema (Zod 4 native), introduce the McpResolver seam, re-type OAuth2. Core deps target: @modelcontextprotocol/sdk + zod + reflect-metadata. Publish 0.1.0.
- Phase 2 — Rudder binding. Repoint
@rudderjs/mcp onto @gemstack/mcp: re-export the core, keep McpProvider, register the Rudder-container resolver, keep the CLI commands + doctor + scaffolders. Verify auto-discovery, @Handle DI, and the inspector still work in playground. Changeset for @rudderjs/mcp.
- Phase 3 (optional, later). A framework-neutral quickstart / docs showing
@gemstack/mcp on raw http + Hono, proving the agnostic claim outside Rudder.
Non-goals
- No merge with
@gemstack/ai-mcp — opposite taxonomy axis.
- No new transports beyond what
@rudderjs/mcp ships (streamable HTTP + stdio via the SDK).
- No durable/clustered server runtime — out of scope.
- The
make-* scaffolders + Rudder doctor stay Rudder-specific (binding only), not in the agnostic core.
Acceptance (for the eventual build, not this issue)
@gemstack/mcp builds + typechecks + runs its full test suite standalone with deps = @modelcontextprotocol/sdk, zod, reflect-metadata (no @rudderjs/*).
- A server authored with only
@gemstack/mcp (tools/resources/prompts/OAuth) serves over a framework-neutral HTTP handler with no Rudder present.
@rudderjs/mcp, repointed onto the core, keeps McpProvider auto-discovery, @Handle DI, the CLI commands, and the doctor check working unchanged in playground.
- One-directional dep check:
@gemstack/mcp imports nothing from @gemstack/ai-*; ai-* imports nothing from mcp.
Design:
@gemstack/mcp— graduate the standalone MCP server frameworkTracking: epic #1 (the last unchecked family box). This is the server-authoring axis of the MCP taxonomy, the peer of
@gemstack/ai-mcp(the agent bridge). It is agent-agnostic, NOTai-*— nothing here depends on@gemstack/ai-sdk, andai-sdkmust never depend on it. SeeArchitecture.md"MCP taxonomy (two axes, do not conflate)".This is a design proposal, no code. Gated on family alignment before anything lands.
What graduates
@rudderjs/mcp(mature, v6.2.1) is a full framework for authoring MCP servers:McpServer/McpTool/McpResource/McpPrompt/McpResponse/Mcp, the metadata + MCP-spec-annotation decorators, OAuth2 middleware, a streamable-HTTP transport, an observer registry, and aMcpTestClient. The framework itself is already mostly agent-agnostic and standalone-testable. The work is to lift the framework-agnostic core out from under the Rudder framework wiring.The carve-out differs from the
ai-mcpone in one important wayai-mcpwas a hard-move-no-shim because it was days old with zero installed base.@rudderjs/mcpis the opposite: published, mature, real consumers.Architecture.mdreserved exactly this case:So the shape is the
@rudderjs/ai->@gemstack/ai-sdkshim pattern, not the hard-move:@gemstack/mcp= the standalone, framework-agnostic core, fresh0.1.0.@rudderjs/mcpstays a (now-thin) package: it re-exports the core from@gemstack/mcpand keeps only the Rudder binding (theMcpProvider, the@Handle-> Rudder-container wiring, the CLI commands, the doctor check). Existing@rudderjs/mcpimporters keep working unchanged.Difference from the
@rudderjs/aishim: that one was a pure re-export (zero Rudder-specific surface left).@rudderjs/mcplegitimately owns Rudder-specific surface (provider, DI decorator semantics, CLI), so it remains a real thin binding package on top of@gemstack/mcp, not an empty re-export.Coupling map (from a full source audit of
packages/mcp)@rudderjs/*dep@rudderjs/json-schemazod-to-json-schema.ts(oneconvertSchema()call)z.toJSONSchema()(exactly whatai-sdkPhase 2 did), keep the date-format + open-object fallbacks. Zero behavior change.@rudderjs/routerprovider.ts,runtime/http-transport.ts{ all(path, handler, mw?) }shape viaresolveOptionalPeer, with a graceful fallback. No Rudder-specific API. Keep the pattern; also expose a framework-neutral handler (see decisions).@rudderjs/consolecommands/make-*.ts(type-onlyMakeSpec),doctor.ts(registerDoctorCheck)@rudderjs/mcp, not in the agnostic core.@rudderjs/coreprovider.ts(ServiceProvider, CLIrudder.command()),runtime/handle-deps.ts(reads__rudderjs_app__/__rudderjs_instance__offglobalThis),auth/oauth2.ts(MiddlewareHandlertype, lazy passport)ServiceProvider/CLI = framework wiring -> stays in the binding; (b) the DI that@Handle()performs = needs a neutral seam (below).Tests import no undeclared
@rudderjs/*packages (unlikeai-sdk, whose suite pulled cache/storage/queue) —packages/mcpalready runs its full suite onzod+ Node built-ins alone. Theboost/dir is Claude-skills docs, ships as-is.The one genuine design problem: the
@Handle()DI seamEverything else is mechanical. The crux is
@Handle(Logger, Cache, ...)on a tool/resource/prompt method:runtime/handle-deps.tsresolves those tokens by reading Rudder's container offglobalThis. A framework-agnostic core cannot assume Rudder's container exists.Mirror the
ai-sdkPhase-2 resolution (neutral optional adapter seam; core deps collapse tozodonly):McpResolver = { resolve(token): unknown }, and asetMcpResolver(resolver)(or a per-Mcp/per-server option). With no resolver set,@Handleeither no-ops or throws a clear "no resolver registered" error — the core never reaches forglobalThis.__rudderjs_*.@Handle(Logger)keeps working byte-for-byte in a Rudder app. Non-Rudder users either skip@Handle(plain closures/constructor injection) or register their own resolver (one adapter function over Awilix/tsyringe/InversifyJS/etc).This keeps the decorator surface identical for existing users while making the core container-agnostic.
Decisions to confirm (A/B, like #7/#8/#9)
@rudderjs/mcpstays a thin binding package (re-export core + Rudder provider/DI/CLI/doctor) rather than a pure re-export. (Recommended: yes — it owns real Rudder surface.)setMcpResolver+ binding-registers-Rudder-container (recommended), vs. drop DI from core entirely and re-add@Handleonly in the binding. Trade-off: the former keeps@Handleusable by non-Rudder users with their own container; the latter keeps the core smaller but makes@Handlea Rudder-only feature.(req, res)Node handler /createMcpHttpHandler()) so non-Rudder users can mount the server on Express/Hono/Vike/raw http without a Rudder router. (Recommended: add the neutral handler; it is already almost that internally.)oauth2McpMiddlewareagainst a generic Connect-style(req, res, next)signature instead of importing core'sMiddlewareHandler; keep passport as an optional peer of the binding, not the core. (Recommended.)json-schemaremoval. Inline Zod 4 native conversion in the core, drop the@rudderjs/json-schemadep (same move asai-sdk). (Recommended.)ai-mcp. Keep them independent:ai-mcp'smcpServerFromAgentcontinues to use the raw MCP SDK, not@gemstack/mcp, so neither package depends on the other. The tiny "both can produce a server" overlap is from different inputs and is expected, not duplication. (Recommended: no dependency edge between them.)@gemstack/mcpstarts fresh at0.1.0(reset, likeai-sdk), not continuing6.x.@rudderjs/mcpgets a minor/patch when it is repointed onto the core.Proposed phasing
packages/mcp/srcintogemstack/packages/mcp, rename@rudderjs/mcp->@gemstack/mcp, strip the framework wiring (provider.ts,commands/*,doctor.ts) out of the core, drop@rudderjs/json-schema(Zod 4 native), introduce theMcpResolverseam, re-type OAuth2. Core deps target:@modelcontextprotocol/sdk+zod+reflect-metadata. Publish0.1.0.@rudderjs/mcponto@gemstack/mcp: re-export the core, keepMcpProvider, register the Rudder-container resolver, keep the CLI commands + doctor + scaffolders. Verify auto-discovery,@HandleDI, and the inspector still work inplayground. Changeset for@rudderjs/mcp.@gemstack/mcpon raw http + Hono, proving the agnostic claim outside Rudder.Non-goals
@gemstack/ai-mcp— opposite taxonomy axis.@rudderjs/mcpships (streamable HTTP + stdio via the SDK).make-*scaffolders + Rudder doctor stay Rudder-specific (binding only), not in the agnostic core.Acceptance (for the eventual build, not this issue)
@gemstack/mcpbuilds + typechecks + runs its full test suite standalone with deps =@modelcontextprotocol/sdk,zod,reflect-metadata(no@rudderjs/*).@gemstack/mcp(tools/resources/prompts/OAuth) serves over a framework-neutral HTTP handler with no Rudder present.@rudderjs/mcp, repointed onto the core, keepsMcpProviderauto-discovery,@HandleDI, the CLI commands, and the doctor check working unchanged inplayground.@gemstack/mcpimports nothing from@gemstack/ai-*;ai-*imports nothing frommcp.