Repository navigation
Description
What is the problem this feature would solve?
McpServer has no public API for serving the methods and notifications of an MCP extension. The extensions option only advertises an entry at initialize; nothing routes the extension's methods. A server that needs an extension that Effect does not ship has to replace a protocol adapter or the transport.
Why it comes up. Each extension that Effect serves or plans to serve is wired through internal code. Skills (#10016, #10082) has its own internal module and is restricted to the 2026-07-28 adapter (#10342). Tasks (#6834) is proposed as a built-in module that edits the 2026-07-28 adapter (#8631). Apps (#6353, #10399) asks for a third built-in extension. A comment on #10016 mentions "ugly hacks to get custom methods without modifying Effect itself".
#10016 (comment)
#6834 proposes "a small internal MCP extension boundary" and lists "Supporting arbitrary third-party extensions in this issue" as a non-goal. Skills has since landed as its own internal module, and Apps asks for another extension. A public route adds two things an internal boundary does not: an extension can be served while its spec is still changing, without waiting for an Effect release, and built-in extensions such as Skills could be served through the same route instead of each editing an adapter. Exporting the boundary #6834 proposes, marked experimental, would satisfy this request.
Example of the pattern. The MCP Events design sketch, an exploratory repository rather than a specification, has a stdio server answer events/list and a long-lived events/stream request that pushes notifications/events/event until the client cancels. It illustrates the long-lived notification pattern only: its top-level events capability and its echo of the request id are outside this request.
https://github.com/modelcontextprotocol/experimental-ext-triggers-events/blob/d5316be214be2e76e51462069c1d67e00a5a7619/docs/design-sketch-proposal.md
Ran on effect 4.1.2 and 4.1.3 with Bun 1.4.3 (macOS arm64); both versions give the output below. Source read at Effect-TS/effect 4.1.3 (7865dbb).
Current behavior. A stdio server that declares an extension through the public options (a synthetic example; imports from effect, effect/ai and the Bun platform package omitted):
const Ping = Tool.make("ping", { description: "Returns pong", success: Schema.String })
const toolkit = Toolkit.make(Ping)
const ServerLive = McpServer.layerStdio({
name: "probe",
version: "0.0.0",
protocols: [McpProtocol.v2025_11_25],
extensions: { "example.org/events": {} },
registrations: McpServer.toolkit(toolkit).pipe(
Layer.provide(toolkit.toLayer({ ping: () => Effect.succeed("pong") }))
)
}).pipe(Layer.provide(BunStdio.layer))
BunRuntime.runMain(Layer.launch(ServerLive))Requests written to stdin, one per line:
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"probe-client","version":"0.0.0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"events/list","params":{}}Responses on stdout: the entry is advertised, and its method is not routed.
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"extensions":{"example.org/events":{}},"logging":{},"tools":{"listChanged":true},"completions":{}},"serverInfo":{"name":"probe","version":"0.0.0"}}}
{"jsonrpc":"2.0","id":2,"error":{"code":-32601,"message":"Method not found: events/list"}}
The layerStdio options accept protocol adapters and extension entries; no option registers request handlers or notifications for an extension.
effect/packages/effect/src/ai/McpServer.ts
Lines 2649 to 2660 in 7865dbb
| >(options: { | |
| readonly name: string | |
| readonly version: string | |
| readonly instructions?: string | undefined | |
| readonly description?: string | undefined | |
| readonly websiteUrl?: string | undefined | |
| readonly icons?: ReadonlyArray<McpSchema.Icon> | undefined | |
| readonly protocols: Protocols | |
| readonly extensions?: ServerExtensions | undefined | |
| readonly cacheHints?: CacheHints | undefined | |
| readonly registrations?: Layer.Layer<RA, RE, RR> | undefined | |
| }): Layer.Layer< |
The built-in Skills extension declares its entry and serves its methods through internal modules, and the package exports map blocks every ai/internal path.
effect/packages/effect/src/ai/internal/mcpSkills.ts
Lines 31 to 42 in 7865dbb
| /** | |
| * Adds the extension declaration while any skill is registered. Declaring it | |
| * commits the server to `skills/list`, `skills/get` and, through | |
| * `directoryRead`, `resources/directory/read`. | |
| * | |
| * @internal | |
| */ | |
| export const declareExtension = <A>( | |
| extensions: Readonly<Record<string, A>> | undefined, | |
| servesSkills: boolean | |
| ): Readonly<Record<string, A | { readonly directoryRead: true }>> | undefined => | |
| servesSkills ? { ...extensions, [EXTENSION_ID]: { directoryRead: true } } : extensions |
What is the feature you are proposing to solve the problem?
This requests a public McpServer API, experimental if preferred, that serves one MCP extension from three opt-in parts.
- Schema-typed request methods with handlers, routed alongside the built-in methods of the selected protocol adapters. A method that a selected adapter already serves fails layer construction instead of being shadowed, for example with
IllegalArgumentError, as a duplicate protocol version does today. A loud failure on upgrade, when a later adapter adds a method of the same name, is preferable to silent shadowing. - The extension's capability entry under
capabilities.extensions, advertised whenever its methods are registered, as the Skills extension does today. Theextensionsoption stays as it is for extensions that add no methods; an id given to both fails layer construction, so that one capability has one source. - Typed notifications that a handler sends to the client whose request it is serving, while that request runs, so that a long-lived request can push events. Adapter handlers already receive an internal
sendNotificationfor this. On a transport that cannot deliver them, such aslayerHttpRequestScoped, which installs handlers withoutsendNotification, registering an extension with notifications can fail or the limit can be documented.
One possible shape, with illustrative names, in which two extensions written separately share one protocol version:
const Events = McpServer.extension({
id: "example.org/events",
capability: {},
requests: RpcGroup.make(EventsList, EventsStream),
notifications: RpcGroup.make(EventOccurred)
})
const EventsLive = Events.toLayer({
"events/list": () => Effect.succeed({ events: [] }),
// runs until the client cancels; each notify goes to the requesting client
"events/stream": (request) =>
Stream.runForEach(watch(request), (event) => Events.notify("notifications/events/event", event))
})
McpServer.layerStdio({
name: "probe",
version: "0.0.0",
// AuditLive: a second extension, written separately
registrations: Layer.mergeAll(McpServer.toolkit(toolkit), EventsLive, AuditLive)
})Typed, exported extension points on ProtocolAdapter would also satisfy the request, but would publish the adapter internals listed under the alternatives, so the extension value above is the smaller surface.
Not requested.
- Built-in support for any particular extension, including Events or Apps.
- Capabilities outside
capabilities.extensions, such as the Events sketch's top-levelevents. - Notifications to a client outside one of its requests, such as a list-changed notification, and a hook on client disconnect.
- Changes to the built-in methods, the existing protocol adapters, their defaults or their response shapes.
- Changes to
McpClientorMcpTest.
Open questions.
- Whether an extension should name the protocol versions it is served on, as the Skills extension is served on 2026-07-28 only.
- Whether handlers need their JSON-RPC request id, which the Events sketch echoes in
_meta["io.modelcontextprotocol/subscriptionId"].McpRequestContextcarries none, and effect/ai: support the MCP Apps extension (io.modelcontextprotocol/ui) #10399 proposes adding one.
Acceptance check. A stdio test in the style of McpStdioHarness, which, unlike McpTest, sends arbitrary methods. Today an unregistered method gets -32601, as events/list does above, so step 2 fails. After the change, with an example.org/echo extension registered through the new API, every step passes.
initializeadvertisescapabilities.extensions["example.org/echo"].example/echowith{ "text": "hi" }returns{ "text": "hi" }.- Before that response, the client receives
notifications/example/echoedwith{ "text": "hi" }. - Registering an extension method named
tools/listfails layer construction.
What alternatives have you considered?
- The
extensionsoption. Advertises the entry; the methods stay unrouted, as shown above. - A wrapped protocol adapter passed through
protocols. In a prototype on the 2025-11-25 and 2026-07-28 adapters, spreading the stock adapter and replacingclientRpcs,serverNotificationRpcsandinstallHandlersrouted the methods and delivered a custom notification. The costs:installHandlerstakescore,lifecycleandtargettypedany; the erased client group exposes onlyrequestsandprefix, so merging a group needs a cast; the handler target and itssendNotificationcontext are internal shapes; and the registry accepts one adapter per protocol version, so extensions written separately cannot each wrap the stock adapter, and one wrapper has to combine them all.
effect/packages/effect/src/ai/McpProtocol.ts
Lines 169 to 173 in 7865dbb
readonly installHandlers: ( core: any, lifecycle: any, target: any ) => Effect.Effect<void, never, HandlerRequirements>
effect/packages/effect/src/ai/internal/mcpProtocolRegistry.ts
Lines 58 to 65 in 7865dbb
for (const protocol of snapshot) { if (byVersion.has(protocol.protocolVersion)) { return yield* new Cause.IllegalArgumentError( `Duplicate MCP protocol version: ${protocol.protocolVersion}` ) } byVersion.set(protocol.protocolVersion, protocol) } - A custom
RpcServer.ProtocolunderMcpServer.layer. Uses public APIs only: the protocol intercepts extension frames, answers them and writes notifications itself. The extension methods then run outside the server's lifecycle, request context, middleware and tracing, and the custom protocol replaceslayerStdioandlayerHttp. - A separate
RpcServerbuilt fromMcpSchemagroups. Serves custom methods, but duplicates initialization, protocol selection and the built-in tool, resource and prompt methods thatMcpServeralready provides. resources/subscribewithnotifications/resources/updated. A supported push channel, but its params are a resource URI and_meta, so the extension's own notification methods and payload schemas cannot be used.
Related.
- Support dynamic MCP registrations #10442, dynamic registrations, which covers built-in registration kinds only.
AI disclosure: Written by AI, checked by independent AI reviewers, and filed without the account holder's review.
- Provider: Anthropic
- Model: Opus 5.5
Activity