Skip to content
Open
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ LSP diagnostics go stale after `buf generate`; trust `go build` / `go test` over
- **proto message copy-locks.** Generated message types embed `protoimpl.MessageState` which contains a `sync.Mutex`. Don't struct-copy them (`c := *t`); use `proto.Clone`.
- **`paths=source_relative`.** Our `buf.gen.yaml` uses `paths=source_relative`, meaning the output directory mirrors the proto source tree. Do not change without updating every `go_package` option and every import path.
- **protojson vs json.** MCP tool content is protojson. Use `protojson.Unmarshal` in tests that decode into generated proto types, plain `json.Unmarshal` breaks on Timestamp, Duration, enum-as-name, and int64-as-string.
- **Elicitation is multi-round-trip (SEP-2322).** Generated elicitation-gated handlers run **twice** per confirmed call: first invocation returns an `InputRequests` result carrying a `RequestState` bound to the call (`protomcp.ElicitationState(toolName, rawArgs)`), the retry must echo that state and the answer in `req.Params.InputResponses["confirm"]`. A harness that invokes a generated handler directly needs non-nil `req.Params` with both the map entry and the matching `RequestState`, or it will get the input-required result, not the tool result.
- **Subscription registration is asynchronous on protocol ≥ 2026-07-28.** The client's `subscriptions/listen` call dispatches without awaiting a response, so `Subscribe`/`Connect` return before the server has recorded the subscription. Tests that connect and immediately mutate can miss notifications; poll or wait for a first event instead of assuming registration completed.

## Where design decisions live

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ Any deviation is a hard error citing the `service.method`. No fallbacks.

### 7. Schema mismatches are dev-time failures

`AddTool` is called with both input and output schemas. The SDK validates responses against the output schema before delivering them, a proto server returning a response that fails our generated schema is a codegen bug we want the test suite to catch, not something to silence.
`AddTool` is called with both input and output schemas. The SDK validates responses against the output schema before delivering them, a proto server returning a response that fails our generated schema is a codegen bug we want the test suite to catch, not something to silence. The one exception is by SDK design: input-required results (multi-round-trip `InputRequests`, as emitted by the generated elicitation gate) skip output-schema validation entirely — they are protocol intermediates, not tool results.

## Style

Expand Down
42 changes: 30 additions & 12 deletions README.md

Large diffs are not rendered by default.

14 changes: 12 additions & 2 deletions examples/subscriptions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,24 @@ internally and fans each `ResourceUpdated` call out to only the
sessions that asked for that URI.

**The subscribe/unsubscribe handlers only act as a gate.** When a
`resources/subscribe` request arrives, the SDK calls your handler
first (`return err` to reject, `return nil` to allow); on allow, it
subscription request arrives, the SDK calls your handler first
(`return err` to reject, `return nil` to allow); on allow, it
**unconditionally** records the session in its internal
subscriptions map. `ResourceUpdated` always reads from that same map,
so it fans out to subscribed sessions regardless of whether your
handlers are no-ops or doing real upstream work. The handler type
decides **which URIs are accepted**, not whether fan-out happens.

**How subscriptions arrive depends on the protocol revision.** Legacy
sessions (negotiated through `initialize`, up to 2025-11-25) send
`resources/subscribe` / `resources/unsubscribe` requests. Sessions on
revision 2026-07-28 or later hold a long-lived `subscriptions/listen`
stream instead, and `resources/subscribe` is rejected there — but the
SDK routes each listed URI through the same internal subscribe path,
so your `SubscribeHandler`/`UnsubscribeHandler` fire per URI on both
paths and `ResourceUpdated` delivers over whichever channel the
session holds. The go-sdk client picks the mechanism transparently.

## Pick the pattern that matches your event source

### Pattern A: push from the write path (simpler, more common)
Expand Down
183 changes: 183 additions & 0 deletions examples/tasks/e2e_elicitation_legacy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// Legacy-protocol coverage for the elicitation gate. Every other
// elicitation test in this package rides in-memory transports, which
// negotiate protocol 2026-07-28 via server/discover and therefore
// exercise only the go-sdk's client-side multi-round-trip middleware.
// Pre-2026 clients instead hit the SDK's server-side compatibility shim:
// it intercepts the InputRequests result, performs a real session.Elicit
// round-trip, and re-invokes the handler with the answer. That shim is
// the entire backward-compatibility story for generated elicitation
// gates, so it gets its own suite here.
//
// Streamable HTTP without Stateless pins sessions to 2025-11-25 (the
// transport refuses to advertise 2026-07-28), which is exactly the
// legacy path. connectLegacyHTTP asserts the negotiated version so this
// suite fails loudly if the transport ever starts speaking the new
// protocol and the shim silently stops being exercised.
package tasks_test

import (
"context"
"encoding/json"
"fmt"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"

tasksv1 "github.com/akuity/protomcp/pkg/api/gen/examples/tasks/v1"
"github.com/akuity/protomcp/pkg/protomcp"

"github.com/modelcontextprotocol/go-sdk/mcp"
)

// legacyProtocolCutoff is the first protocol revision on which the
// server-side elicitation shim no longer applies.
const legacyProtocolCutoff = "2026-07-28"

// connectLegacyHTTP serves srv over Streamable HTTP (non-stateless) and
// connects an MCP client to it, returning a session guaranteed to speak
// a pre-2026-07-28 protocol revision.
func connectLegacyHTTP(ctx context.Context, t *testing.T, srv *protomcp.Server, opts *mcp.ClientOptions) *mcp.ClientSession {
t.Helper()
ts := httptest.NewServer(srv)
t.Cleanup(ts.Close)
// Disable the client-side multi-round-trip middleware: it fires on
// any inputRequests result regardless of protocol version, so left
// enabled it would fulfill the elicitation itself and mask a
// server-side shim regression — exactly what this suite exists to
// catch. With it off, only the shim can complete the round-trip.
if opts == nil {
opts = &mcp.ClientOptions{}
}
opts.MultiRoundTrip = &mcp.MultiRoundTripOptions{Disabled: true}
client := mcp.NewClient(&mcp.Implementation{Name: "legacy-test-client", Version: "0.0.1"}, opts)
cs, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: ts.URL}, nil)
if err != nil {
t.Fatalf("client connect: %v", err)
}
t.Cleanup(func() { _ = cs.Close() })
if v := cs.InitializeResult().ProtocolVersion; v >= legacyProtocolCutoff {
t.Fatalf("negotiated protocol %q >= %q: this suite must exercise the legacy server-side elicitation shim", v, legacyProtocolCutoff)
}
return cs
}

// TestDeleteTask_LegacyShimAccept drives confirm-then-delete over the
// legacy path: the server-side shim turns the handler's InputRequests
// result into a session.Elicit round-trip, the client accepts, and the
// backend observes the Delete.
func TestDeleteTask_LegacyShimAccept(t *testing.T) {
ctx := context.Background()
grpcClient := startGRPC(t)
srv := protomcp.New("tasks", "0.1.0")
tasksv1.RegisterTasksMCPTools(srv, grpcClient)

var seenMessage atomic.Value // string
var elicitCalls atomic.Int32
cs := connectLegacyHTTP(ctx, t, srv, &mcp.ClientOptions{
ElicitationHandler: func(_ context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
elicitCalls.Add(1)
if req != nil && req.Params != nil {
seenMessage.Store(req.Params.Message)
}
return &mcp.ElicitResult{Action: "accept"}, nil
},
})

var created tasksv1.Task
callTool(ctx, t, cs, "Tasks_CreateTask",
`{"task":{"title":"delete-me-legacy","done":false}}`, &created)
if created.Id == "" {
t.Fatalf("Create: empty id")
}

var del tasksv1.DeleteTaskResponse
callTool(ctx, t, cs, "Tasks_DeleteTask",
fmt.Sprintf(`{"id":%q}`, created.Id), &del)
if !del.Existed {
t.Errorf("Delete: Existed = false, want true (task was present before the delete)")
}
if got := elicitCalls.Load(); got != 1 {
t.Errorf("elicitation handler called %d times, want 1", got)
}
msg, _ := seenMessage.Load().(string)
if !strings.Contains(msg, created.Id) {
t.Errorf("elicitation message %q does not contain task id %q", msg, created.Id)
}
}

// TestDeleteTask_LegacyShimDecline asserts the decline path through the
// shim: an IsError tool result (not a protocol error) and an untouched
// backend.
func TestDeleteTask_LegacyShimDecline(t *testing.T) {
ctx := context.Background()
grpcClient := startGRPC(t)
srv := protomcp.New("tasks", "0.1.0")
tasksv1.RegisterTasksMCPTools(srv, grpcClient)

cs := connectLegacyHTTP(ctx, t, srv, &mcp.ClientOptions{
ElicitationHandler: func(_ context.Context, _ *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
return &mcp.ElicitResult{Action: "decline"}, nil
},
})

var created tasksv1.Task
callTool(ctx, t, cs, "Tasks_CreateTask",
`{"task":{"title":"keep-me-legacy","done":false}}`, &created)

out, err := cs.CallTool(ctx, &mcp.CallToolParams{
Name: "Tasks_DeleteTask",
Arguments: json.RawMessage(fmt.Sprintf(`{"id":%q}`, created.Id)),
})
if err != nil {
t.Fatalf("CallTool: transport error: %v", err)
}
if !out.IsError {
t.Fatalf("Delete: want IsError, got success: %+v", out)
}

// Backend untouched.
var got tasksv1.Task
callTool(ctx, t, cs, "Tasks_GetTask",
fmt.Sprintf(`{"id":%q}`, created.Id), &got)
if got.Id != created.Id {
t.Errorf("Get after declined delete: got %q, want %q", got.Id, created.Id)
}
}

// TestDeleteTask_LegacyShimNoHandler pins the upgrade-note behavior: a
// client with no ElicitationHandler now gets a hard protocol error from
// the elicitation round-trip — under go-sdk v1.5.0 this surfaced as a
// graceful IsError tool result instead. The backend must stay untouched
// either way.
func TestDeleteTask_LegacyShimNoHandler(t *testing.T) {
ctx := context.Background()
grpcClient := startGRPC(t)
srv := protomcp.New("tasks", "0.1.0")
tasksv1.RegisterTasksMCPTools(srv, grpcClient)

cs := connectLegacyHTTP(ctx, t, srv, nil)

var created tasksv1.Task
callTool(ctx, t, cs, "Tasks_CreateTask",
`{"task":{"title":"keep-me-nohandler","done":false}}`, &created)

out, err := cs.CallTool(ctx, &mcp.CallToolParams{
Name: "Tasks_DeleteTask",
Arguments: json.RawMessage(fmt.Sprintf(`{"id":%q}`, created.Id)),
})
if err == nil {
t.Fatalf("CallTool: want hard error without an ElicitationHandler, got result %+v", out)
}
if !strings.Contains(err.Error(), "elicitation") {
t.Errorf("CallTool error %q does not mention elicitation", err)
}

// Backend untouched.
var got tasksv1.Task
callTool(ctx, t, cs, "Tasks_GetTask",
fmt.Sprintf(`{"id":%q}`, created.Id), &got)
if got.Id != created.Id {
t.Errorf("Get after failed delete: got %q, want %q", got.Id, created.Id)
}
}
99 changes: 98 additions & 1 deletion examples/tasks/e2e_elicitation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
// DeleteTask carries both the destructive tool hint and a confirmation
// elicitation in tasks.proto; the generated handler must:
//
// - fire session.Elicit before the upstream gRPC call
// - publish the confirmation as a multi-round-trip InputRequest before
// the upstream gRPC call (the SDK fulfills it through the client's
// ElicitationHandler and re-invokes the handler with the answer)
// - render {{id}} into the prompt so the user sees *which* task
// - run the gRPC call only when action=="accept"
// - return an IsError CallToolResult with a clear message when the
Expand Down Expand Up @@ -146,6 +148,101 @@ func TestDeleteTask_ElicitationDecline(t *testing.T) {
}
}

// TestDeleteTask_ElicitationReusedParamsRePrompts is the regression test
// for answer/request binding. The SDK's client middleware mutates the
// caller's CallToolParams in place on a fulfilled elicitation
// (setMultiRoundTripRetryParams), so a client that reuses one params
// struct across calls carries a stale inputResponses["confirm"] into the
// next call. Without RequestState binding that stale answer silently
// confirmed a delete of a *different* task; with it, every distinct call
// re-prompts.
func TestDeleteTask_ElicitationReusedParamsRePrompts(t *testing.T) {
ctx := context.Background()
grpcClient := startGRPC(t)
srv := protomcp.New("tasks", "0.1.0")
tasksv1.RegisterTasksMCPTools(srv, grpcClient)

var elicitCalls atomic.Int32
cs := connectWith(ctx, t, srv, &mcp.ClientOptions{
ElicitationHandler: func(_ context.Context, _ *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
elicitCalls.Add(1)
return &mcp.ElicitResult{Action: "accept"}, nil
},
})

var first, second tasksv1.Task
callTool(ctx, t, cs, "Tasks_CreateTask", `{"task":{"title":"one","done":false}}`, &first)
callTool(ctx, t, cs, "Tasks_CreateTask", `{"task":{"title":"two","done":false}}`, &second)

// One params struct, reused across both deletes: after the first
// call the SDK has stuffed InputResponses + RequestState into it.
params := &mcp.CallToolParams{
Name: "Tasks_DeleteTask",
Arguments: json.RawMessage(fmt.Sprintf(`{"id":%q}`, first.Id)),
}
if out, err := cs.CallTool(ctx, params); err != nil || out.IsError {
t.Fatalf("first Delete: err=%v out=%+v", err, out)
}
if got := elicitCalls.Load(); got != 1 {
t.Fatalf("after first delete: elicitation handler called %d times, want 1", got)
}

params.Arguments = json.RawMessage(fmt.Sprintf(`{"id":%q}`, second.Id))
if out, err := cs.CallTool(ctx, params); err != nil || out.IsError {
t.Fatalf("second Delete: err=%v out=%+v", err, out)
}
if got := elicitCalls.Load(); got != 2 {
t.Errorf("after second delete: elicitation handler called %d times, want 2 (stale answer must re-prompt, not confirm)", got)
}
}

// TestDeleteTask_ElicitationPrePopulatedAnswerRePrompts asserts that an
// inputResponses entry supplied on the very first call, without the
// matching RequestState, does not skip the confirmation: the gate
// re-prompts, and a declining user still blocks the delete.
func TestDeleteTask_ElicitationPrePopulatedAnswerRePrompts(t *testing.T) {
ctx := context.Background()
grpcClient := startGRPC(t)
srv := protomcp.New("tasks", "0.1.0")
tasksv1.RegisterTasksMCPTools(srv, grpcClient)

var elicitCalls atomic.Int32
cs := connectWith(ctx, t, srv, &mcp.ClientOptions{
ElicitationHandler: func(_ context.Context, _ *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
elicitCalls.Add(1)
return &mcp.ElicitResult{Action: "decline"}, nil
},
})

var created tasksv1.Task
callTool(ctx, t, cs, "Tasks_CreateTask", `{"task":{"title":"keep-me","done":false}}`, &created)

out, err := cs.CallTool(ctx, &mcp.CallToolParams{
Name: "Tasks_DeleteTask",
Arguments: json.RawMessage(fmt.Sprintf(`{"id":%q}`, created.Id)),
InputResponses: mcp.InputResponseMap{
"confirm": &mcp.ElicitResult{Action: "accept"},
},
})
if err != nil {
t.Fatalf("CallTool: transport error: %v", err)
}
if !out.IsError {
t.Fatalf("Delete: want IsError (user declined the re-prompt), got success: %+v", out)
}
if got := elicitCalls.Load(); got != 1 {
t.Errorf("elicitation handler called %d times, want 1 (pre-populated answer must trigger a real prompt)", got)
}

// Task survived.
var got tasksv1.Task
callTool(ctx, t, cs, "Tasks_GetTask",
fmt.Sprintf(`{"id":%q}`, created.Id), &got)
if got.Id != created.Id {
t.Errorf("Get after blocked delete: got %q, want %q", got.Id, created.Id)
}
}

// TestDeleteTask_ElicitationCancel asserts that action=cancel behaves the
// same as decline, it is a non-accept action, so the tool must
// short-circuit with IsError and leave the backend untouched.
Expand Down
6 changes: 4 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ go 1.26.2
require (
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1
github.com/cbroglie/mustache v1.4.0
github.com/google/jsonschema-go v0.4.2
github.com/modelcontextprotocol/go-sdk v1.5.0
github.com/google/jsonschema-go v0.4.3
github.com/modelcontextprotocol/go-sdk v1.7.0
github.com/yosida95/uritemplate/v3 v3.0.2
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478
google.golang.org/grpc v1.80.0
Expand All @@ -18,8 +18,10 @@ require (
github.com/segmentio/encoding v0.5.4 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1 // indirect
)
Expand Down
12 changes: 8 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU=
github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA=
github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
Expand All @@ -42,10 +42,14 @@ golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
Expand Down
Loading
Loading