Skip to content

feat(core): make Protocol concrete and exported (alternative to #1846)#1891

Draft
felixweinberger wants to merge 16 commits intomainfrom
fweinberger/protocol-concrete
Draft

feat(core): make Protocol concrete and exported (alternative to #1846)#1891
felixweinberger wants to merge 16 commits intomainfrom
fweinberger/protocol-concrete

Conversation

@felixweinberger
Copy link
Copy Markdown
Contributor

@felixweinberger felixweinberger commented Apr 14, 2026

Alternative to #1846/#1868. Protocol becomes concrete and exported; setRequestHandler/setNotificationHandler/request/notification gain a 3-arg (method: string, paramsSchema, handler) overload alongside the spec-typed form. Optional Protocol<S extends ProtocolSpec> generic for declaring a typed method vocabulary.

Approach

  • 5 assert*Capability abstracts + buildContext → no-op virtuals (Client/Server override to enforce)
  • One setRequestHandler with three overloads: typed-via-S (autocomplete from ProtocolSpec), spec-typed (RequestMethod), and string fallback. SpecRequests<S> conditional resolves to never for default S, so the typed overload only fires for concrete specs.
  • setCustom*/sendCustom* from feat(core): add custom request/notification handler API to Protocol #1846 deleted — folded into the overloads.

What this enables

  • class App extends Protocol<AppSpec> mixing ui/*, tools/call, notifications/message on one object — see examples/server/src/customMethodExtAppsExample.ts
  • server.setRequestHandler('acme/foo', schema, h) on a stock Server — no separate API

ext-apps comparison (validated against local tarball build)

composition (ext-apps#612, on #1846+#1868) this PR
Files changed 105 20
v1 app-bridge.test.ts tests passing unmodified 73/88 81/88
Wire-method renames 2 0
v1↔v2 interop shims needed not needed
ProtocolWithEvents replaced kept

Motivation and Context

Exploring whether exporting concrete Protocol is a simpler answer than #1846's separate *Custom* API + #1868's ExtensionHandle for consumers that speak an MCP-dialect over a non-MCP channel.

How Has This Been Tested?

core 503 / client 350 / server 55 tests; typecheck:all + check:all clean; all 3 examples run. ext-apps re-port at the comparison numbers above (tsc clean, 276/2/0).

Breaking Changes

None vs #1846 base. Restores Protocol as public (was public in v1).

Types of changes

  • New feature (non-breaking change which adds functionality)

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Known follow-up: 3-arg setNotificationHandler adds an async parseSchema hop that can break sync-dispatch expectations over InMemoryTransport. Fix is to take the sync path when the schema's ~standard.validate returns a non-Promise.

Adds setCustomRequestHandler, setCustomNotificationHandler, sendCustomRequest,
sendCustomNotification (plus remove* variants) to the Protocol class. These
allow registering handlers for vendor-specific methods outside the standard
RequestMethod/NotificationMethod unions, with user-provided Zod schemas for
param/result validation.

Custom handlers share the existing _requestHandlers map and dispatch path,
so they receive full context (cancellation, task support, send/notify) for
free. Capability checks are skipped for custom methods.

Also exports InMemoryTransport from core/public so examples and tests can
use createLinkedPair() without depending on the internal core barrel, and
adds examples/server/src/customMethodExample.ts demonstrating the API.
- Guard setCustom*/removeCustom* against standard MCP method names
  (throws directing users to setRequestHandler/setNotificationHandler)
- Add isRequestMethod/isNotificationMethod runtime predicates
- Add comprehensive unit tests (15 cases) for all 6 custom-method APIs
- Add ext-apps style example demonstrating mcp-ui/* methods and
  DOM-style event listeners built on setCustomNotificationHandler
- Add @modelcontextprotocol/client path mapping to examples/server
  tsconfig so the example resolves source instead of dist
…typed-params overloads; migration docs

- sendCustomNotification now delegates to notification() so debouncing and
  task-queued delivery apply to custom methods
- sendCustomRequest/sendCustomNotification gain a {params, result}/{params}
  schema-bundle overload that validates outbound params before sending
- clarify JSDoc: capability checks are a no-op for custom methods regardless
  of enforceStrictCapabilities
- add migration.md / migration-SKILL.md sections for custom protocol methods
setRequestHandler is overridden in Client/Server, so {@linkcode Protocol.setRequestHandler}
resolves to the undocumented base. Use unqualified {@linkcode setRequestHandler} instead.
assertCapabilityForMethod/assertNotificationCapability are protected — use plain backticks.
The {params, result} schema bundle in sendCustomRequest/sendCustomNotification
is a type guard, not a transformer — the caller-provided value is sent as-is,
matching request()/v1 behavior. Transforms/defaults on the params schema are
not applied outbound (parsed.data is intentionally unused on the send path).
Adds JSDoc and a test asserting params are sent verbatim.
… example notification to request stream; add changeset

- setCustomRequestHandler/setCustomNotificationHandler now strip _meta from
  params before validating against the user schema, so .strict() schemas do
  not reject SDK-injected fields like progressToken. _meta remains available
  via ctx.mcpReq._meta. Adds regression test.
- examples/server/src/customMethodExample.ts: pass relatedRequestId so the
  acme/statusUpdate notification routes to the request response stream as
  the comment claims (was going to the standalone SSE stream).
- Add .changeset/custom-method-handlers.md (minor bump for client+server).
…st Zod)

AnySchema is now StandardSchemaV1 (which Zod schemas implement), and parseSchema
routes through validateStandardSchema. This lets specTypeSchema() output and other
non-Zod Standard Schemas be passed directly to setCustomRequestHandler /
setCustomNotificationHandler / sendCustomRequest / sendCustomNotification.

parseSchema is now async; all 16 callers were already in async contexts except
_setupListChangedHandler, which is now async too (called from connect()).
validateStandardSchema constraint relaxed from StandardSchemaWithJSON to
StandardSchemaV1. isSchemaBundle was already StandardSchema-aware.
…eric

Replaces the setCustom*/sendCustom* API with overloads on setRequestHandler /
setNotificationHandler / request / notification:

- Two-arg spec-method form (existing): handler receives the full request object,
  validated by the SDK. Used by Client/Server overrides.
- Three-arg form (new): (method: string, paramsSchema, handler-receives-params).
  Accepts any method string; params validated against caller-supplied schema.

Protocol is no longer abstract:
- 5 assert*Capability methods become no-op virtuals; Client/Server override.
- buildContext gets a default implementation returning the BaseContext as-is.
- Protocol<S extends ProtocolSpec, ContextT> — S declares the request/notification
  vocabulary (autocomplete reserved for follow-up; runtime path complete).
- McpSpec derived from RequestTypeMap/ResultTypeMap/NotificationTypeMap;
  Client/Server extend Protocol<McpSpec, ...>.

This lets MCP-dialect protocols (ext-apps) subclass Protocol directly with no
role enforcement, no wire renames, no setCustom* split — restoring the v1 model
with a cleaner generic shape.

Exports Protocol, ProtocolSpec, McpSpec and InMemoryTransport from core/public.

Tests: customMethods.test.ts rewritten (13 tests covering concrete instantiation,
3-arg overloads, ProtocolSpec, non-Zod StandardSchemaV1). protocol.test.ts and
protocolTransportHandling.test.ts adapted to the new generic position.

Examples: customMethodExample.ts (server + client) rewritten to use the 3-arg
overloads on stock Client/Server. customMethodExtAppsExample.ts rewritten as
class App extends Protocol<AppSpec> mixing ui/* and tools/call on one object.

Docs: migration.md and migration-SKILL.md custom-methods sections updated.
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 14, 2026

🦋 Changeset detected

Latest commit: db49955

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/client Minor
@modelcontextprotocol/server Minor
@modelcontextprotocol/express Major
@modelcontextprotocol/fastify Major
@modelcontextprotocol/hono Major
@modelcontextprotocol/node Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 14, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@1891

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@1891

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@1891

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@1891

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@1891

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@1891

commit: db49955

- SpecRequests<S>/SpecNotifications<S> conditional types: resolve to never for
  the default ProtocolSpec, enabling the spec-typed overload only when a
  concrete S is supplied
- Add the typed overload (first) to setRequestHandler/setNotificationHandler/
  request/notification; remove eslint-disable on the now-used S generic
- Replace 'as never' casts in Client/Server.setRequestHandler with proper
  handler-type casts (or none where assignable)
- buildContext: JSDoc warning that overriding ContextT requires overriding it
- Drop _specCheck hacks (Protocol<AppSpec> constraint now enforces it)
- Export SpecRequests/SpecNotifications from core/public
- Type-level tests: SpecRequests resolves correctly for concrete vs default S;
  request/setRequestHandler infer params/result from S; string fallback works
- Revert cfWorker.ts lint-reorder (pre-existing on base; not this PR's concern)
- Rename/rewrite changeset to describe the actual API
…Server use default S

Fix type/runtime disagreement: when calling 3-arg setRequestHandler with a
method in S, overload 1 typed handler params from S[K]['params'] but runtime
parses with the passed paramsSchema. Now constrains paramsSchema to
StandardSchemaV1<S[K]['params']> and types handler from SchemaOutput<P>.

Client/Server now extend Protocol<ProtocolSpec, ...> (default S) instead of
Protocol<McpSpec, ...>, so the typed-S overload doesn't fire on them — spec
methods use the 2-arg form, custom methods use the 3-arg string fallback.
McpSpec stays exported for users who want to compose it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant