mcp: label resultType on results that bypass the dispatcher (#1226)
Fixes #1225. See the [issue comment](https://github.com/modelcontextprotocol/go-sdk/issues/1225#issuecomment-5500818546). ## The bug `2026-07-28` requires `resultType` on every result. `CallToolResult`, `GetPromptResult`, and `ReadResourceResult` keep `resultType` in an unexported field. Each of these results can be `complete` or `input_required`, so it cannot embed `completeResultWithType` the way the seven types in #1060 do. Only `handleMultiRoundTripResult` sets that field. It runs inside `Server.callTool`, `getPrompt`, and `readResource`. Some middleware returns a result directly instead of calling `next` — for example, an auth, rate-limit, or entitlement gate. That middleware never reaches `handleMultiRoundTripResult`. The response then ships without the field, and strict clients reject it. ## The fix The choice between `complete` and `input_required` now happens inside `setMultiRoundTripResultType`. Both call sites that need this choice call that function. `setCompleteResultType` now labels results it did not label before, so it is renamed `annotateResultType`. The new function picks a branch with a type switch. Reading matters here. `setResultType` is unexported, so setting `InputRequests` is the only way for middleware to ask for input. If the code always labels results `complete`, the response ships as `{"resultType":"complete","inputRequests":{...}}`. A client that trusts the discriminator then drops the elicitation request. Recomputing the label every time is safe. A result that did reach the dispatcher already carries the same label from that test. This also covers one edge case: a handler that returns `InputRequests` again after the shim's single reinvocation. Before this fix, that case shipped with no `resultType`. Embedding `completeResultWithType` instead is not an option: it labels everything `complete` and overwrites `input_required`, breaking multi-round-trip flows. **Skip nil results.** Middleware can return a typed nil value, which satisfies the `Result` interface. `handle` now checks for that case before it calls the labeling function. Before this change, that same case also panicked, one frame later in `annotateServerInfo` and then `GetMeta`. ## Checks - `TestServerSessionHandle_SetsResultTypeWhenMiddlewareShortCircuits` fails on all three methods before the first change. Its fourth case fails if labeling overwrites a populated `InputRequests`. - `TestServerSessionHandle_ResultTypeGate` records which requests carry `resultType`, including one known gap. See "Separate from this PR" below. - `TestServerSessionHandle_NilResultFromMiddleware` panics without the nil check. - The complete-path cases added to `TestServerSessionHandle_SetsResultTypeOnNewProtocol` also pass before this change. They close a gap in #1060, which added only three cases, and only for `input_required`. - The exported API is unchanged. `go doc -all ./mcp`, with comments stripped, is identical before and after this change. - With the reproduction from the issue, over the streamable transport: the dispatched `tools/call` and the short-circuited `tools/call` both now return `resultType: "complete"`. I could not run `./scripts/conformance.sh`. CONTRIBUTING.md names that script, but the repo only has `client-conformance.sh` and `server-conformance.sh`. Both scripts need GNU `timeout`, and macOS does not include it. ## Also here Ten comments named an exported `ResultType` field and `ResultTypeComplete` / `ResultTypeInputRequired` constants. None of those exist. Two of the comments were `[CallToolResult.ResultType]` doc links, and those links cannot resolve. They now point at `[CallToolResult.NeedsInput]` instead, because an unexported field is also not a valid link target. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Guglielmo Colombo <guglielmoc@google.com>
This commit is contained in:
parent
5bc078a795
commit
e40f35d137
5 changed files with 322 additions and 83 deletions
19
mcp/mrtr.go
19
mcp/mrtr.go
|
|
@ -36,29 +36,20 @@ type multiRoundTripResponse interface {
|
|||
hasContent() bool
|
||||
}
|
||||
|
||||
func handleMultiRoundTripResult(ss *ServerSession, logger *slog.Logger, res multiRoundTripResponse) error {
|
||||
// validateMultiRoundTripResult rejects a result that carries both content and
|
||||
// input requests. [annotateResultType] labels the result later, in
|
||||
// [ServerSession.handle], so this function does not set resultType.
|
||||
func validateMultiRoundTripResult(logger *slog.Logger, res multiRoundTripResponse) error {
|
||||
if res == nil {
|
||||
return nil
|
||||
}
|
||||
hasInputRequests := res.inputRequests() != nil
|
||||
|
||||
if hasInputRequests && res.hasContent() {
|
||||
if res.inputRequests() != nil && res.hasContent() {
|
||||
logger.Warn("handler returned both content and inputRequests")
|
||||
return &jsonrpc.Error{
|
||||
Code: jsonrpc.CodeInternalError,
|
||||
Message: "server bug: result has both content and inputRequests",
|
||||
}
|
||||
}
|
||||
|
||||
if clientSupportsMultiRoundTrip(ss) {
|
||||
// For older clients the resultType is left unset. Input requests will be handled
|
||||
// by serverMultiRoundTripMiddleware client calls and handler reinvocation.
|
||||
if hasInputRequests {
|
||||
res.setResultType(resultTypeInputRequired)
|
||||
} else {
|
||||
res.setResultType(resultTypeComplete)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ type resultType string
|
|||
|
||||
const (
|
||||
// resultTypeComplete indicates the result is final.
|
||||
// This is the default when ResultType is empty.
|
||||
// This is the default when resultType is empty.
|
||||
resultTypeComplete resultType = "complete"
|
||||
|
||||
// resultTypeInputRequired indicates the server needs additional client
|
||||
|
|
@ -40,9 +40,20 @@ type completeResultResponse interface {
|
|||
isCompleteResult()
|
||||
}
|
||||
|
||||
func setCompleteResultType(res Result) {
|
||||
if r, ok := res.(completeResultResponse); ok {
|
||||
// annotateResultType sets the resultType that protocol 2026-07-28 requires on
|
||||
// every result.
|
||||
func annotateResultType(res Result) {
|
||||
switch r := res.(type) {
|
||||
case completeResultResponse:
|
||||
r.setResultType(resultTypeComplete)
|
||||
case multiRoundTripResponse:
|
||||
// These results are complete or input_required, so label them by
|
||||
// whether the handler asked for more client input.
|
||||
if r.inputRequests() != nil {
|
||||
r.setResultType(resultTypeInputRequired)
|
||||
} else {
|
||||
r.setResultType(resultTypeComplete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -226,9 +237,8 @@ type CallToolParams struct {
|
|||
// marshaled to JSON.
|
||||
Arguments any `json:"arguments,omitempty"`
|
||||
|
||||
// InputResponses maps input request IDs to responses, provided when
|
||||
// retrying a call after receiving a result with ResultType
|
||||
// ResultTypeInputRequired.
|
||||
// InputResponses maps input request IDs to responses. Set it when the
|
||||
// client retries a call that asked for input. See [CallToolResult.NeedsInput].
|
||||
InputResponses InputResponseMap `json:"inputResponses,omitempty"`
|
||||
// RequestState is the opaque state from the previous input-required result.
|
||||
// The client must echo this back when retrying.
|
||||
|
|
@ -249,9 +259,8 @@ type CallToolParamsRaw struct {
|
|||
// Arguments (see [AddTool]).
|
||||
Arguments json.RawMessage `json:"arguments,omitempty"`
|
||||
|
||||
// InputResponses maps input request IDs to responses, provided when
|
||||
// retrying a call after receiving a result with ResultType
|
||||
// ResultTypeInputRequired.
|
||||
// InputResponses maps input request IDs to responses. Set it when the
|
||||
// client retries a call that asked for input. See [CallToolResult.NeedsInput].
|
||||
InputResponses InputResponseMap `json:"inputResponses,omitempty"`
|
||||
// RequestState is the opaque state from the previous input-required result.
|
||||
// The client must echo this back when retrying.
|
||||
|
|
@ -305,9 +314,9 @@ type CallToolResult struct {
|
|||
IsError bool `json:"isError,omitempty"`
|
||||
|
||||
// InputRequests is a map of server-assigned IDs to input requests.
|
||||
// Populated only when ResultType is ResultTypeInputRequired.
|
||||
// It is set only when the result needs more client input.
|
||||
// The client must fulfill these and echo the IDs back in InputResponses
|
||||
// when retrying the call.
|
||||
// when it retries the call.
|
||||
InputRequests InputRequestMap `json:"inputRequests,omitempty"`
|
||||
|
||||
// RequestState is an opaque string the client must echo back when
|
||||
|
|
@ -317,10 +326,10 @@ type CallToolResult struct {
|
|||
// Unauthenticated servers must encrypt, sign and verify this value.
|
||||
RequestState string `json:"requestState,omitempty"`
|
||||
|
||||
// ResultType indicates whether this result is complete or requires further
|
||||
// client input. Empty or ResultTypeComplete means the call succeeded
|
||||
// normally. ResultTypeInputRequired means the client should fulfill the
|
||||
// InputRequests and retry the call.
|
||||
// resultType records whether the call finished or needs more client input.
|
||||
// Empty or resultTypeComplete means it finished. resultTypeInputRequired
|
||||
// means the client must fulfill InputRequests and retry.
|
||||
// See [CallToolResult.NeedsInput].
|
||||
resultType resultType
|
||||
// The error passed to setError, if any.
|
||||
// It is not marshaled, and therefore it is only visible on the server.
|
||||
|
|
@ -348,7 +357,8 @@ func (r *CallToolResult) GetError() error {
|
|||
return r.err
|
||||
}
|
||||
|
||||
func (*CallToolResult) isResult() {}
|
||||
func (*CallToolResult) isResult() {}
|
||||
func (x *CallToolResult) isNil() bool { return x == nil }
|
||||
|
||||
func (r *CallToolResult) setResultType(rt resultType) { r.resultType = rt }
|
||||
func (r *CallToolResult) requestState() string { return r.RequestState }
|
||||
|
|
@ -363,7 +373,7 @@ func (r *CallToolResult) hasContent() bool {
|
|||
}
|
||||
|
||||
// NeedsInput reports whether this result requires further client input.
|
||||
// This is true when the server returned ResultType "input_required".
|
||||
// This is true when the server returned a resultType of "input_required".
|
||||
// When NeedsInput returns true, check InputRequests for the set of
|
||||
// requests the server needs fulfilled before retrying the call.
|
||||
// An empty InputRequests with NeedsInput true indicates load-shedding.
|
||||
|
|
@ -655,7 +665,8 @@ type CompleteResult struct {
|
|||
Completion CompletionResultDetails `json:"completion"`
|
||||
}
|
||||
|
||||
func (*CompleteResult) isResult() {}
|
||||
func (*CompleteResult) isResult() {}
|
||||
func (x *CompleteResult) isNil() bool { return x == nil }
|
||||
|
||||
// CreateMessageParams holds parameters for a sampling/createMessage request.
|
||||
//
|
||||
|
|
@ -839,6 +850,7 @@ type CreateMessageResult struct {
|
|||
}
|
||||
|
||||
func (*CreateMessageResult) isResult() {}
|
||||
func (x *CreateMessageResult) isNil() bool { return x == nil }
|
||||
func (*CreateMessageResult) isInputResponse() {}
|
||||
func (r *CreateMessageResult) UnmarshalJSON(data []byte) error {
|
||||
type result CreateMessageResult // avoid recursion
|
||||
|
|
@ -890,6 +902,7 @@ var createMessageWithToolsResultAllow = map[string]bool{
|
|||
}
|
||||
|
||||
func (*CreateMessageWithToolsResult) isResult() {}
|
||||
func (x *CreateMessageWithToolsResult) isNil() bool { return x == nil }
|
||||
func (*CreateMessageWithToolsResult) isInputResponse() {}
|
||||
|
||||
// MarshalJSON marshals the result. When Content has a single element, it is
|
||||
|
|
@ -950,9 +963,8 @@ type GetPromptParams struct {
|
|||
// The name of the prompt or prompt template.
|
||||
Name string `json:"name"`
|
||||
|
||||
// InputResponses maps input request IDs to responses, provided when
|
||||
// retrying a call after receiving a result with ResultType
|
||||
// ResultTypeInputRequired.
|
||||
// InputResponses maps input request IDs to responses. Set it when the
|
||||
// client retries a call that asked for input. See [CallToolResult.NeedsInput].
|
||||
InputResponses InputResponseMap `json:"inputResponses,omitempty"`
|
||||
// RequestState is the opaque state from the previous input-required result.
|
||||
RequestState string `json:"requestState,omitempty"`
|
||||
|
|
@ -972,19 +984,20 @@ type GetPromptResult struct {
|
|||
Description string `json:"description,omitempty"`
|
||||
Messages []*PromptMessage `json:"messages"`
|
||||
|
||||
// InputRequests is populated when ResultType is ResultTypeInputRequired.
|
||||
// InputRequests is set when the result needs more client input.
|
||||
// See [CallToolResult.InputRequests].
|
||||
InputRequests InputRequestMap `json:"inputRequests,omitempty"`
|
||||
// RequestState is the opaque state for multi-round-trip retries.
|
||||
// See [CallToolResult.RequestState].
|
||||
RequestState string `json:"requestState,omitempty"`
|
||||
|
||||
// ResultType indicates whether this result is complete or requires further
|
||||
// client input. See [CallToolResult.ResultType] for details.
|
||||
// resultType records whether the call finished or needs more client input.
|
||||
// See [CallToolResult.NeedsInput].
|
||||
resultType resultType
|
||||
}
|
||||
|
||||
func (*GetPromptResult) isResult() {}
|
||||
func (*GetPromptResult) isResult() {}
|
||||
func (x *GetPromptResult) isNil() bool { return x == nil }
|
||||
|
||||
func (r *GetPromptResult) setResultType(rt resultType) { r.resultType = rt }
|
||||
func (r *GetPromptResult) requestState() string { return r.RequestState }
|
||||
|
|
@ -1094,7 +1107,8 @@ type InitializeResult struct {
|
|||
ServerInfo *Implementation `json:"serverInfo"`
|
||||
}
|
||||
|
||||
func (*InitializeResult) isResult() {}
|
||||
func (*InitializeResult) isResult() {}
|
||||
func (x *InitializeResult) isNil() bool { return x == nil }
|
||||
|
||||
type InitializedParams struct {
|
||||
// Meta is reserved by the protocol to allow clients and servers to attach
|
||||
|
|
@ -1137,7 +1151,8 @@ type DiscoverResult struct {
|
|||
Instructions string `json:"instructions,omitempty"`
|
||||
}
|
||||
|
||||
func (*DiscoverResult) isResult() {}
|
||||
func (*DiscoverResult) isResult() {}
|
||||
func (x *DiscoverResult) isNil() bool { return x == nil }
|
||||
|
||||
func (x *ListPromptsParams) isParams() {}
|
||||
func (x *ListPromptsParams) isNil() bool { return x == nil }
|
||||
|
|
@ -1207,6 +1222,7 @@ type ListPromptsResult struct {
|
|||
}
|
||||
|
||||
func (x *ListPromptsResult) isResult() {}
|
||||
func (x *ListPromptsResult) isNil() bool { return x == nil }
|
||||
func (x *ListPromptsResult) nextCursorPtr() *string { return &x.NextCursor }
|
||||
|
||||
type ListResourceTemplatesParams struct {
|
||||
|
|
@ -1238,6 +1254,7 @@ type ListResourceTemplatesResult struct {
|
|||
}
|
||||
|
||||
func (x *ListResourceTemplatesResult) isResult() {}
|
||||
func (x *ListResourceTemplatesResult) isNil() bool { return x == nil }
|
||||
func (x *ListResourceTemplatesResult) nextCursorPtr() *string { return &x.NextCursor }
|
||||
|
||||
type ListResourcesParams struct {
|
||||
|
|
@ -1269,6 +1286,7 @@ type ListResourcesResult struct {
|
|||
}
|
||||
|
||||
func (x *ListResourcesResult) isResult() {}
|
||||
func (x *ListResourcesResult) isNil() bool { return x == nil }
|
||||
func (x *ListResourcesResult) nextCursorPtr() *string { return &x.NextCursor }
|
||||
|
||||
// ListRootsParams holds parameters for a roots/list request.
|
||||
|
|
@ -1305,6 +1323,7 @@ type ListRootsResult struct {
|
|||
}
|
||||
|
||||
func (*ListRootsResult) isResult() {}
|
||||
func (x *ListRootsResult) isNil() bool { return x == nil }
|
||||
func (*ListRootsResult) isInputResponse() {}
|
||||
|
||||
type ListToolsParams struct {
|
||||
|
|
@ -1336,6 +1355,7 @@ type ListToolsResult struct {
|
|||
}
|
||||
|
||||
func (x *ListToolsResult) isResult() {}
|
||||
func (x *ListToolsResult) isNil() bool { return x == nil }
|
||||
func (x *ListToolsResult) nextCursorPtr() *string { return &x.NextCursor }
|
||||
|
||||
// The severity of a log message.
|
||||
|
|
@ -1575,9 +1595,8 @@ type ReadResourceParams struct {
|
|||
// the server how to interpret it.
|
||||
URI string `json:"uri"`
|
||||
|
||||
// InputResponses maps input request IDs to responses, provided when
|
||||
// retrying a call after receiving a result with ResultType
|
||||
// ResultTypeInputRequired.
|
||||
// InputResponses maps input request IDs to responses. Set it when the
|
||||
// client retries a call that asked for input. See [CallToolResult.NeedsInput].
|
||||
InputResponses InputResponseMap `json:"inputResponses,omitempty"`
|
||||
// RequestState is the opaque state from the previous input-required result.
|
||||
RequestState string `json:"requestState,omitempty"`
|
||||
|
|
@ -1596,19 +1615,20 @@ type ReadResourceResult struct {
|
|||
Cacheable
|
||||
Contents []*ResourceContents `json:"contents"`
|
||||
|
||||
// InputRequests is populated when ResultType is ResultTypeInputRequired.
|
||||
// InputRequests is set when the result needs more client input.
|
||||
// See [CallToolResult.InputRequests].
|
||||
InputRequests InputRequestMap `json:"inputRequests,omitempty"`
|
||||
// RequestState is the opaque state for multi-round-trip retries.
|
||||
// See [CallToolResult.RequestState].
|
||||
RequestState string `json:"requestState,omitempty"`
|
||||
|
||||
// ResultType indicates whether this result is complete or requires further
|
||||
// client input. See [CallToolResult.ResultType] for details.
|
||||
// resultType records whether the call finished or needs more client input.
|
||||
// See [CallToolResult.NeedsInput].
|
||||
resultType resultType
|
||||
}
|
||||
|
||||
func (*ReadResourceResult) isResult() {}
|
||||
func (*ReadResourceResult) isResult() {}
|
||||
func (x *ReadResourceResult) isNil() bool { return x == nil }
|
||||
|
||||
func (r *ReadResourceResult) setResultType(rt resultType) { r.resultType = rt }
|
||||
func (r *ReadResourceResult) requestState() string { return r.RequestState }
|
||||
|
|
@ -2114,7 +2134,8 @@ type SubscriptionsListenResult struct {
|
|||
Meta `json:"_meta"`
|
||||
}
|
||||
|
||||
func (*SubscriptionsListenResult) isResult() {}
|
||||
func (*SubscriptionsListenResult) isResult() {}
|
||||
func (x *SubscriptionsListenResult) isNil() bool { return x == nil }
|
||||
|
||||
// TODO(jba): add CompleteRequest and related types.
|
||||
|
||||
|
|
@ -2191,6 +2212,7 @@ type ElicitResult struct {
|
|||
}
|
||||
|
||||
func (*ElicitResult) isResult() {}
|
||||
func (x *ElicitResult) isNil() bool { return x == nil }
|
||||
func (*ElicitResult) isInputResponse() {}
|
||||
|
||||
// ElicitationCompleteParams is sent from the server to the client, informing it that an out-of-band elicitation interaction has completed.
|
||||
|
|
|
|||
|
|
@ -910,7 +910,7 @@ func (s *Server) getPrompt(ctx context.Context, req *GetPromptRequest) (*GetProm
|
|||
}
|
||||
res, err := prompt.handler(ctx, req)
|
||||
if err == nil && res != nil {
|
||||
if err := handleMultiRoundTripResult(req.Session, s.opts.Logger, res); err != nil {
|
||||
if err := validateMultiRoundTripResult(s.opts.Logger, res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
|
@ -1012,10 +1012,10 @@ func (s *Server) callTool(ctx context.Context, req *CallToolRequest) (*CallToolR
|
|||
}
|
||||
res, err := st.handler(ctx, req)
|
||||
if err == nil && res != nil {
|
||||
if err := handleMultiRoundTripResult(req.Session, s.opts.Logger, res); err != nil {
|
||||
if err := validateMultiRoundTripResult(s.opts.Logger, res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.Content == nil && res.resultType != resultTypeInputRequired {
|
||||
if res.Content == nil && res.InputRequests == nil {
|
||||
res2 := *res
|
||||
res2.Content = []Content{} // avoid "null"
|
||||
res = &res2
|
||||
|
|
@ -1080,11 +1080,11 @@ func (s *Server) readResource(ctx context.Context, req *ReadResourceRequest) (*R
|
|||
if res == nil {
|
||||
return nil, fmt.Errorf("reading resource %s: read handler returned nil information", uri)
|
||||
}
|
||||
if err := handleMultiRoundTripResult(req.Session, s.opts.Logger, res); err != nil {
|
||||
if err := validateMultiRoundTripResult(s.opts.Logger, res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.resolveCacheable(ctx, req, &res.Cacheable)
|
||||
if res.resultType == resultTypeInputRequired {
|
||||
if res.InputRequests != nil {
|
||||
return res, nil
|
||||
}
|
||||
if res.Contents == nil {
|
||||
|
|
@ -2039,8 +2039,10 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any,
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if validatedMeta.usesNewProtocol {
|
||||
setCompleteResultType(res)
|
||||
// A middleware can return a typed nil value that satisfies the Result
|
||||
// interface. Annotating that value panics.
|
||||
if validatedMeta.usesNewProtocol && res != nil && !res.isNil() {
|
||||
annotateResultType(res)
|
||||
annotateServerInfo(res, ss.server.impl)
|
||||
}
|
||||
return res, nil
|
||||
|
|
|
|||
|
|
@ -1399,6 +1399,22 @@ func TestServerSessionHandle_RejectsInitializeOnNewProtocol(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// newProtocolParams merges fields into the _meta that opts a request into the
|
||||
// sessionless protocol (SEP-2575).
|
||||
func newProtocolParams(fields map[string]any) map[string]any {
|
||||
params := map[string]any{
|
||||
"_meta": map[string]any{
|
||||
MetaKeyProtocolVersion: protocolVersion20260728,
|
||||
MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"},
|
||||
MetaKeyClientCapabilities: map[string]any{},
|
||||
},
|
||||
}
|
||||
for k, v := range fields {
|
||||
params[k] = v
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func TestServerSessionHandle_SetsResultTypeOnNewProtocol(t *testing.T) {
|
||||
server := NewServer(testImpl, &ServerOptions{
|
||||
CompletionHandler: func(context.Context, *CompleteRequest) (*CompleteResult, error) {
|
||||
|
|
@ -1424,19 +1440,19 @@ func TestServerSessionHandle_SetsResultTypeOnNewProtocol(t *testing.T) {
|
|||
InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Continue?"}},
|
||||
}, nil
|
||||
})
|
||||
newProtocolParams := func(fields map[string]any) map[string]any {
|
||||
params := map[string]any{
|
||||
"_meta": map[string]any{
|
||||
MetaKeyProtocolVersion: protocolVersion20260728,
|
||||
MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"},
|
||||
MetaKeyClientCapabilities: map[string]any{},
|
||||
},
|
||||
}
|
||||
for k, v := range fields {
|
||||
params[k] = v
|
||||
}
|
||||
return params
|
||||
}
|
||||
AddTool(server, &Tool{Name: "toolComplete"}, func(context.Context, *CallToolRequest, struct{}) (*CallToolResult, any, error) {
|
||||
return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil
|
||||
})
|
||||
server.AddPrompt(&Prompt{Name: "promptComplete"}, func(context.Context, *GetPromptRequest) (*GetPromptResult, error) {
|
||||
return &GetPromptResult{
|
||||
Messages: []*PromptMessage{{Role: "assistant", Content: &TextContent{Text: "ok"}}},
|
||||
}, nil
|
||||
})
|
||||
server.AddResource(&Resource{URI: "test://resource-complete", Name: "resourceComplete"}, func(context.Context, *ReadResourceRequest) (*ReadResourceResult, error) {
|
||||
return &ReadResourceResult{
|
||||
Contents: []*ResourceContents{{URI: "test://resource-complete", Text: "ok"}},
|
||||
}, nil
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -1507,40 +1523,243 @@ func TestServerSessionHandle_SetsResultTypeOnNewProtocol(t *testing.T) {
|
|||
params: newProtocolParams(map[string]any{"uri": "test://resource"}),
|
||||
want: resultTypeInputRequired,
|
||||
},
|
||||
{
|
||||
name: "tool complete",
|
||||
method: methodCallTool,
|
||||
params: newProtocolParams(map[string]any{"name": "toolComplete", "arguments": map[string]any{}}),
|
||||
want: resultTypeComplete,
|
||||
},
|
||||
{
|
||||
name: "prompt complete",
|
||||
method: methodGetPrompt,
|
||||
params: newProtocolParams(map[string]any{"name": "promptComplete"}),
|
||||
want: resultTypeComplete,
|
||||
},
|
||||
{
|
||||
name: "resource complete",
|
||||
method: methodReadResource,
|
||||
params: newProtocolParams(map[string]any{"uri": "test://resource-complete"}),
|
||||
want: resultTypeComplete,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := resultTypeOf(t, server, nil, tc.method, tc.params); got != string(tc.want) {
|
||||
t.Errorf("resultType = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerSessionHandle_SetsResultTypeWhenMiddlewareShortCircuits is a
|
||||
// regression test for #1225. User middleware wraps outside
|
||||
// serverMultiRoundTripMiddleware, so a middleware that returns a result
|
||||
// without calling next bypasses both that shim and the dispatcher.
|
||||
func TestServerSessionHandle_SetsResultTypeWhenMiddlewareShortCircuits(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
params map[string]any
|
||||
result Result
|
||||
want resultType
|
||||
}{
|
||||
{
|
||||
name: "tools/call",
|
||||
method: methodCallTool,
|
||||
params: newProtocolParams(map[string]any{"name": "tool", "arguments": map[string]any{}}),
|
||||
result: &CallToolResult{Content: []Content{&TextContent{Text: "denied"}}},
|
||||
want: resultTypeComplete,
|
||||
},
|
||||
{
|
||||
name: "prompts/get",
|
||||
method: methodGetPrompt,
|
||||
params: newProtocolParams(map[string]any{"name": "prompt"}),
|
||||
result: &GetPromptResult{
|
||||
Messages: []*PromptMessage{{Role: "assistant", Content: &TextContent{Text: "denied"}}},
|
||||
},
|
||||
want: resultTypeComplete,
|
||||
},
|
||||
{
|
||||
name: "resources/read",
|
||||
method: methodReadResource,
|
||||
params: newProtocolParams(map[string]any{"uri": "test://resource"}),
|
||||
result: &ReadResourceResult{
|
||||
Contents: []*ResourceContents{{URI: "test://resource", Text: "denied"}},
|
||||
},
|
||||
want: resultTypeComplete,
|
||||
},
|
||||
{
|
||||
// setResultType is unexported, so populating InputRequests is
|
||||
// the only way a middleware can ask for input.
|
||||
name: "tools/call input required",
|
||||
method: methodCallTool,
|
||||
params: newProtocolParams(map[string]any{"name": "tool", "arguments": map[string]any{}}),
|
||||
result: &CallToolResult{
|
||||
InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Continue?"}},
|
||||
},
|
||||
want: resultTypeInputRequired,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Nothing is registered: if the middleware fell through to next,
|
||||
// dispatch would fail rather than return a result.
|
||||
server := NewServer(testImpl, nil)
|
||||
server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
|
||||
return func(ctx context.Context, method string, req Request) (Result, error) {
|
||||
if method == tc.method {
|
||||
return tc.result, nil
|
||||
}
|
||||
return next(ctx, method, req)
|
||||
}
|
||||
})
|
||||
if got := resultTypeOf(t, server, nil, tc.method, tc.params); got != string(tc.want) {
|
||||
t.Errorf("resultType = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerSessionHandle_ResultTypeGate pins when a result carries resultType.
|
||||
// annotateResultType is the only setter, and handle gates it on the request's
|
||||
// _meta rather than on the version the session negotiated. So the last case
|
||||
// below gets no resultType even though the session speaks 2026-07-28. Only a
|
||||
// client that mixes protocol versions reaches that, so it is recorded rather
|
||||
// than fixed.
|
||||
func TestServerSessionHandle_ResultTypeGate(t *testing.T) {
|
||||
version := func(v string) func(*ServerSessionState) {
|
||||
return func(s *ServerSessionState) {
|
||||
s.InitializeParams = &InitializeParams{ProtocolVersion: v}
|
||||
}
|
||||
}
|
||||
callParams := map[string]any{"name": "tool", "arguments": map[string]any{}}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
state func(*ServerSessionState)
|
||||
params map[string]any
|
||||
want resultType // "" means the field must be absent
|
||||
}{
|
||||
{
|
||||
name: "legacy session, no _meta",
|
||||
state: version(protocolVersion20251125),
|
||||
params: callParams,
|
||||
},
|
||||
{
|
||||
name: "legacy session, new-protocol _meta",
|
||||
state: version(protocolVersion20251125),
|
||||
params: newProtocolParams(callParams),
|
||||
want: resultTypeComplete,
|
||||
},
|
||||
{
|
||||
name: "new-protocol session, no _meta",
|
||||
state: version(protocolVersion20260728),
|
||||
params: callParams,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := NewServer(testImpl, nil)
|
||||
// Short-circuit so this measures handle's gate, not the
|
||||
// dispatcher's.
|
||||
server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
|
||||
return func(context.Context, string, Request) (Result, error) {
|
||||
return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil
|
||||
}
|
||||
})
|
||||
if got := resultTypeOf(t, server, tc.state, methodCallTool, tc.params); got != string(tc.want) {
|
||||
t.Errorf("resultType = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerSessionHandle_NilResultFromMiddleware checks that a middleware
|
||||
// returning no result does not panic. A typed nil satisfies Result, so the
|
||||
// annotation helpers would otherwise dereference it.
|
||||
func TestServerSessionHandle_NilResultFromMiddleware(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
result Result
|
||||
}{
|
||||
{"nil interface", nil},
|
||||
{"typed nil", (*CallToolResult)(nil)},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := NewServer(testImpl, nil)
|
||||
server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
|
||||
return func(context.Context, string, Request) (Result, error) {
|
||||
return tc.result, nil
|
||||
}
|
||||
})
|
||||
ss := &ServerSession{server: server}
|
||||
id, err := jsonrpc.MakeID("test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := ss.handle(context.Background(), &jsonrpc.Request{
|
||||
res, err := ss.handle(context.Background(), &jsonrpc.Request{
|
||||
ID: id,
|
||||
Method: tc.method,
|
||||
Params: mustMarshal(tc.params),
|
||||
Method: methodCallTool,
|
||||
Params: mustMarshal(newProtocolParams(map[string]any{"name": "tool", "arguments": map[string]any{}})),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
t.Fatalf("handle: %v", err)
|
||||
}
|
||||
data, err := json.Marshal(result)
|
||||
data, err := json.Marshal(res)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got struct {
|
||||
ResultType string `json:"resultType"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ResultType != string(tc.want) {
|
||||
t.Fatalf("resultType = %q, want %q; response = %s", got.ResultType, tc.want, data)
|
||||
if got := string(data); got != "null" {
|
||||
t.Errorf("result = %s, want null", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// resultTypeOf handles one request on a transportless session and reports the
|
||||
// resultType of the marshaled result, or "" if the field is absent.
|
||||
func resultTypeOf(t *testing.T, server *Server, state func(*ServerSessionState), method string, params map[string]any) string {
|
||||
t.Helper()
|
||||
ss := &ServerSession{server: server}
|
||||
if state != nil {
|
||||
ss.updateState(state)
|
||||
}
|
||||
id, err := jsonrpc.MakeID("test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := ss.handle(context.Background(), &jsonrpc.Request{
|
||||
ID: id,
|
||||
Method: method,
|
||||
Params: mustMarshal(params),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &fields); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, ok := fields["resultType"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
var got string
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("unmarshaling resultType from %s: %v", data, err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// TestServerSessionHandle_RejectsRemovedMethodsOnNewProtocol verifies that
|
||||
// the methods removed by SEP-2575 (`initialize`, `notifications/initialized`,
|
||||
// `ping`) all return Method not found when the request opts into the new
|
||||
|
|
|
|||
|
|
@ -809,6 +809,9 @@ type Result interface {
|
|||
// isResult discourages implementation of Result outside of this package.
|
||||
isResult()
|
||||
|
||||
// isNil returns true if the underlying value is nil.
|
||||
isNil() bool
|
||||
|
||||
// GetMeta returns metadata from a value.
|
||||
GetMeta() map[string]any
|
||||
// SetMeta sets the metadata on a value.
|
||||
|
|
@ -827,13 +830,15 @@ type ResultBase struct {
|
|||
Meta `json:"_meta,omitempty"`
|
||||
}
|
||||
|
||||
func (*ResultBase) isResult() {}
|
||||
func (*ResultBase) isResult() {}
|
||||
func (x *ResultBase) isNil() bool { return x == nil }
|
||||
|
||||
// emptyResult is returned by methods that have no result, like ping.
|
||||
// Those methods cannot return nil, because jsonrpc2 cannot handle nils.
|
||||
type emptyResult struct{}
|
||||
|
||||
func (*emptyResult) isResult() {}
|
||||
func (x *emptyResult) isNil() bool { return x == nil }
|
||||
func (*emptyResult) GetMeta() map[string]any { panic("should never be called") }
|
||||
func (*emptyResult) SetMeta(map[string]any) { panic("should never be called") }
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue