Skip to content

mcp: bidirectional custom methods, CustomMethod type, and extension registry#1031

Open
sambhav wants to merge 1 commit into
modelcontextprotocol:mainfrom
sambhav:custom-method-bidirectional-extension
Open

mcp: bidirectional custom methods, CustomMethod type, and extension registry#1031
sambhav wants to merge 1 commit into
modelcontextprotocol:mainfrom
sambhav:custom-method-bidirectional-extension

Conversation

@sambhav

@sambhav sambhav commented Jun 27, 2026

Copy link
Copy Markdown
Member

This PR builds on the custom JSON-RPC method support added in #956, addressing the review comments I left there. It adds three related capabilities.

What's new

1. Bidirectional custom methods

#956 added client→server only. This PR adds the server→client direction:

// Server registers that it can send a method to clients:
mcp.AddServerSendingCustomMethod[*PingParams, *PingResult](server, "acme/ping")

// Client registers a handler to receive it:
mcp.AddClientReceivingCustomMethod(client, "acme/ping",
    func(ctx context.Context, cs *mcp.ClientSession, params *PingParams) (*PingResult, error) {
        return &PingResult{OK: true}, nil
    })

// Server calls the client:
result, err := mcp.ServerCallCustomMethod[*PingParams, *PingResult](ctx, ss, "acme/ping", &PingParams{})

Under the hood: Server gains a sendMethods map (parallel to its existing receiveMethods); Client gains a receiveMethods map (parallel to its existing sendMethods). The per-session dispatch functions (sendingMethodInfos/receivingMethodInfos) now read from these maps under the server/client mutex rather than returning the static global maps.

2. CustomMethod[P, R, T] — ergonomic type-safe wrapper

Extension authors typically define a package-level var:

var Method = mcp.NewCustomMethod[*TranslateParams, *TranslateResult]("latin/translate")

This captures the name and types once. Every registration and call site then uses the var rather than repeating generic type arguments or the method-name string:

Method.RegisterServerReceiving(server, handler) // server receives
Method.RegisterClientSending(client)            // client sends
result, err := Method.Call(ctx, cs, &TranslateParams{Text: "hello"})

Method.RegisterServerSending(server)            // server sends
Method.RegisterClientReceiving(client, handler) // client receives
result, err := Method.ServerCall(ctx, ss, &TranslateParams{Text: "hello"})

The lower-level Add*/Call* free functions remain exported for callers who don't want the wrapper.

3. Extension registry

A library can now package a custom method as an extension — importers get everything wired automatically:

// Extension author (in some library package):
var Method = mcp.NewCustomMethod[*Params, *Result]("acme/method")

func init() {
    mcp.RegisterExtension(mcp.Extension{
        Server: func(s *mcp.Server) error { return Method.RegisterServerReceiving(s, handler) },
        Client: func(c *mcp.Client) error { return Method.RegisterClientSending(c) },
    })
}
// Extension consumer (just import the package):
import _ "example.com/acmeext"

server := mcp.NewServer(...)  // handler wired automatically
client := mcp.NewClient(...)  // sending registered automatically

Two registration scopes:

  • Global (RegisterExtension): process-wide, typically called from init. Applied first.
  • Per-instance (ServerOptions.Extensions / ClientOptions.Extensions): applied after global, so per-instance wins on collision. Useful for tests or programs that instantiate multiple servers with different behaviors.

Example restructure

The examples/server/custom-method example is rewritten to demonstrate the pattern:

  • latinext/ — the extension-author package: types, Method var, init() registration, Translate() helper
  • main.go — the consumer: just imports latinext; NewServer/NewClient need no manual setup
$ go run ./examples/server/custom-method/...
Hello                               → salve
Seize the day                       → carpe diem
Peace                               → pax
Truth                               → veritas
I came I saw I conquered            → veni vidi vici

Design notes

  • CustomMethod is a phantom-type struct — P, R, T appear only in the type parameters, not as stored fields. This is purely a compile-time ergonomics device.
  • Extension.Server / Extension.Client are independently nullable — an extension can be server-only or client-only.
  • If applying an extension returns an error (e.g. shadowing a standard method name), NewServer/NewClient panic, matching the pattern established by AddReceivingMiddleware and similar setup calls.
  • runExtensions is a generic helper shared between the global-registry path and the per-instance path to avoid duplicating the nil-check + call + panic loop.

cc @guglielmo-san — tagging you as the author of #956 and SDK maintainer.

Closes the review comments from #956.

…egistry

Builds on the custom JSON-RPC method support in modelcontextprotocol#956 in three ways:

1. **Bidirectionality** — the server can now send requests to the client,
   and the client can register handlers for them, mirroring the existing
   client→server direction:
   - AddServerSendingCustomMethod / ServerCallCustomMethod
   - AddClientReceivingCustomMethod
   - CustomMethod.RegisterServerSending / .ServerCall
   - CustomMethod.RegisterClientReceiving

2. **CustomMethod[P, R, T]** — a phantom-type wrapper that captures the
   method name and parameter/result types once at package level, so call
   sites never repeat generic arguments or method-name strings:

       var Method = mcp.NewCustomMethod[*Params, *Result]("acme/method")
       result, err := Method.Call(ctx, cs, &Params{...})

3. **Extension registry** — a mechanism for libraries to auto-wire custom
   methods into every Server/Client without requiring callers to do any
   manual setup:
   - RegisterExtension (global, typically called from init)
   - ServerOptions.Extensions / ClientOptions.Extensions (per-instance)

   Global extensions are applied first; per-instance extensions after
   (last writer wins on name collision).

The example is restructured to demonstrate the pattern: a latinext
sub-package is the "extension author" (defines types, registers via
init(), exports a Translate() helper); main.go is the "consumer" (just
imports latinext, no generics or method-name strings visible).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@piyushbag piyushbag left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looked through the extension registry and bidirectional custom-method wiring. The CustomMethod wrapper and latinext example make the #956 follow-up easy to follow.

Local check:

go test ./mcp/... -count=1

All green on my machine.

One non-blocking suggestion: TestCustomMethods only exercises client→server today. A small round-trip test for AddServerSendingCustomMethod + AddClientReceivingCustomMethod (or ServerCallCustomMethod) would lock in the new path.

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.

2 participants