Skip to content

McpServer: public API to serve extension methods and notifications #10530

Open

Description

@aidenzepp

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.

>(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.

/**
* 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.

  1. 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.
  2. The extension's capability entry under capabilities.extensions, advertised whenever its methods are registered, as the Skills extension does today. The extensions option 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.
  3. 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 sendNotification for this. On a transport that cannot deliver them, such as layerHttpRequestScoped, which installs handlers without sendNotification, 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-level events.
  • 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 McpClient or McpTest.

Open questions.

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.

  1. initialize advertises capabilities.extensions["example.org/echo"].
  2. example/echo with { "text": "hi" } returns { "text": "hi" }.
  3. Before that response, the client receives notifications/example/echoed with { "text": "hi" }.
  4. Registering an extension method named tools/list fails layer construction.

What alternatives have you considered?

  • The extensions option. 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 replacing clientRpcs, serverNotificationRpcs and installHandlers routed the methods and delivered a custom notification. The costs: installHandlers takes core, lifecycle and target typed any; the erased client group exposes only requests and prefix, so merging a group needs a cast; the handler target and its sendNotification context 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.
    readonly installHandlers: (
    core: any,
    lifecycle: any,
    target: any
    ) => Effect.Effect<void, never, HandlerRequirements>

    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.Protocol under McpServer.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 replaces layerStdio and layerHttp.
  • A separate RpcServer built from McpSchema groups. Serves custom methods, but duplicates initialization, protocol selection and the built-in tool, resource and prompt methods that McpServer already provides.
  • resources/subscribe with notifications/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.


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

  1. self-assigned this
    on Nov 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions