Skip to content

Commit b34ba21

Browse files
authored
mcp: handle bad or missing params (modelcontextprotocol#210)
Audit all cases where params must not be null, and enforce this using methodInfo via a new methodFlags bitfield that parameterizes method requirements. Write extensive conformance tests catching all the (server-side) crashes that were possible. We should go further and validate schema against the spec, but that is more indirect, more complicated, and potentially slower. For now we adopt this more explicit approach. Still TODO in a subsequent CL: verify the client side of this with client conformance tests. Additionally, improve some error messages that were redundant or leaked internal implementation details. Fixes modelcontextprotocol#195
1 parent a9a503f commit b34ba21

File tree

9 files changed

+170
-52
lines changed

9 files changed

+170
-52
lines changed

internal/jsonrpc2/wire.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,30 +13,30 @@ import (
1313

1414
var (
1515
// ErrParse is used when invalid JSON was received by the server.
16-
ErrParse = NewError(-32700, "JSON RPC parse error")
16+
ErrParse = NewError(-32700, "parse error")
1717
// ErrInvalidRequest is used when the JSON sent is not a valid Request object.
18-
ErrInvalidRequest = NewError(-32600, "JSON RPC invalid request")
18+
ErrInvalidRequest = NewError(-32600, "invalid request")
1919
// ErrMethodNotFound should be returned by the handler when the method does
2020
// not exist / is not available.
21-
ErrMethodNotFound = NewError(-32601, "JSON RPC method not found")
21+
ErrMethodNotFound = NewError(-32601, "method not found")
2222
// ErrInvalidParams should be returned by the handler when method
2323
// parameter(s) were invalid.
24-
ErrInvalidParams = NewError(-32602, "JSON RPC invalid params")
24+
ErrInvalidParams = NewError(-32602, "invalid params")
2525
// ErrInternal indicates a failure to process a call correctly
26-
ErrInternal = NewError(-32603, "JSON RPC internal error")
26+
ErrInternal = NewError(-32603, "internal error")
2727

2828
// The following errors are not part of the json specification, but
2929
// compliant extensions specific to this implementation.
3030

3131
// ErrServerOverloaded is returned when a message was refused due to a
3232
// server being temporarily unable to accept any new messages.
33-
ErrServerOverloaded = NewError(-32000, "JSON RPC overloaded")
33+
ErrServerOverloaded = NewError(-32000, "overloaded")
3434
// ErrUnknown should be used for all non coded errors.
35-
ErrUnknown = NewError(-32001, "JSON RPC unknown error")
35+
ErrUnknown = NewError(-32001, "unknown error")
3636
// ErrServerClosing is returned for calls that arrive while the server is closing.
37-
ErrServerClosing = NewError(-32004, "JSON RPC server is closing")
37+
ErrServerClosing = NewError(-32004, "server is closing")
3838
// ErrClientClosing is a dummy error returned for calls initiated while the client is closing.
39-
ErrClientClosing = NewError(-32003, "JSON RPC client is closing")
39+
ErrClientClosing = NewError(-32003, "client is closing")
4040
)
4141

4242
const wireVersion = "2.0"

mcp/client.go

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -286,17 +286,21 @@ func (c *Client) AddReceivingMiddleware(middleware ...Middleware[*ClientSession]
286286
}
287287

288288
// clientMethodInfos maps from the RPC method name to serverMethodInfos.
289+
//
290+
// The 'allowMissingParams' values are extracted from the protocol schema.
291+
// TODO(rfindley): actually load and validate the protocol schema, rather than
292+
// curating these method flags.
289293
var clientMethodInfos = map[string]methodInfo{
290-
methodComplete: newMethodInfo(sessionMethod((*ClientSession).Complete), true),
291-
methodPing: newMethodInfo(sessionMethod((*ClientSession).ping), true),
292-
methodListRoots: newMethodInfo(clientMethod((*Client).listRoots), true),
293-
methodCreateMessage: newMethodInfo(clientMethod((*Client).createMessage), true),
294-
notificationToolListChanged: newMethodInfo(clientMethod((*Client).callToolChangedHandler), false),
295-
notificationPromptListChanged: newMethodInfo(clientMethod((*Client).callPromptChangedHandler), false),
296-
notificationResourceListChanged: newMethodInfo(clientMethod((*Client).callResourceChangedHandler), false),
297-
notificationResourceUpdated: newMethodInfo(clientMethod((*Client).callResourceUpdatedHandler), false),
298-
notificationLoggingMessage: newMethodInfo(clientMethod((*Client).callLoggingHandler), false),
299-
notificationProgress: newMethodInfo(sessionMethod((*ClientSession).callProgressNotificationHandler), false),
294+
methodComplete: newMethodInfo(sessionMethod((*ClientSession).Complete), 0),
295+
methodPing: newMethodInfo(sessionMethod((*ClientSession).ping), missingParamsOK),
296+
methodListRoots: newMethodInfo(clientMethod((*Client).listRoots), missingParamsOK),
297+
methodCreateMessage: newMethodInfo(clientMethod((*Client).createMessage), 0),
298+
notificationToolListChanged: newMethodInfo(clientMethod((*Client).callToolChangedHandler), notification|missingParamsOK),
299+
notificationPromptListChanged: newMethodInfo(clientMethod((*Client).callPromptChangedHandler), notification|missingParamsOK),
300+
notificationResourceListChanged: newMethodInfo(clientMethod((*Client).callResourceChangedHandler), notification|missingParamsOK),
301+
notificationResourceUpdated: newMethodInfo(clientMethod((*Client).callResourceUpdatedHandler), notification|missingParamsOK),
302+
notificationLoggingMessage: newMethodInfo(clientMethod((*Client).callLoggingHandler), notification),
303+
notificationProgress: newMethodInfo(sessionMethod((*ClientSession).callProgressNotificationHandler), notification),
300304
}
301305

302306
func (cs *ClientSession) sendingMethodInfos() map[string]methodInfo {

mcp/server.go

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -683,23 +683,27 @@ func (s *Server) AddReceivingMiddleware(middleware ...Middleware[*ServerSession]
683683
}
684684

685685
// serverMethodInfos maps from the RPC method name to serverMethodInfos.
686+
//
687+
// The 'allowMissingParams' values are extracted from the protocol schema.
688+
// TODO(rfindley): actually load and validate the protocol schema, rather than
689+
// curating these method flags.
686690
var serverMethodInfos = map[string]methodInfo{
687-
methodComplete: newMethodInfo(serverMethod((*Server).complete), true),
688-
methodInitialize: newMethodInfo(sessionMethod((*ServerSession).initialize), true),
689-
methodPing: newMethodInfo(sessionMethod((*ServerSession).ping), true),
690-
methodListPrompts: newMethodInfo(serverMethod((*Server).listPrompts), true),
691-
methodGetPrompt: newMethodInfo(serverMethod((*Server).getPrompt), true),
692-
methodListTools: newMethodInfo(serverMethod((*Server).listTools), true),
693-
methodCallTool: newMethodInfo(serverMethod((*Server).callTool), true),
694-
methodListResources: newMethodInfo(serverMethod((*Server).listResources), true),
695-
methodListResourceTemplates: newMethodInfo(serverMethod((*Server).listResourceTemplates), true),
696-
methodReadResource: newMethodInfo(serverMethod((*Server).readResource), true),
697-
methodSetLevel: newMethodInfo(sessionMethod((*ServerSession).setLevel), true),
698-
methodSubscribe: newMethodInfo(serverMethod((*Server).subscribe), true),
699-
methodUnsubscribe: newMethodInfo(serverMethod((*Server).unsubscribe), true),
700-
notificationInitialized: newMethodInfo(serverMethod((*Server).callInitializedHandler), false),
701-
notificationRootsListChanged: newMethodInfo(serverMethod((*Server).callRootsListChangedHandler), false),
702-
notificationProgress: newMethodInfo(sessionMethod((*ServerSession).callProgressNotificationHandler), false),
691+
methodComplete: newMethodInfo(serverMethod((*Server).complete), 0),
692+
methodInitialize: newMethodInfo(sessionMethod((*ServerSession).initialize), 0),
693+
methodPing: newMethodInfo(sessionMethod((*ServerSession).ping), missingParamsOK),
694+
methodListPrompts: newMethodInfo(serverMethod((*Server).listPrompts), missingParamsOK),
695+
methodGetPrompt: newMethodInfo(serverMethod((*Server).getPrompt), 0),
696+
methodListTools: newMethodInfo(serverMethod((*Server).listTools), missingParamsOK),
697+
methodCallTool: newMethodInfo(serverMethod((*Server).callTool), 0),
698+
methodListResources: newMethodInfo(serverMethod((*Server).listResources), missingParamsOK),
699+
methodListResourceTemplates: newMethodInfo(serverMethod((*Server).listResourceTemplates), missingParamsOK),
700+
methodReadResource: newMethodInfo(serverMethod((*Server).readResource), 0),
701+
methodSetLevel: newMethodInfo(sessionMethod((*ServerSession).setLevel), 0),
702+
methodSubscribe: newMethodInfo(serverMethod((*Server).subscribe), 0),
703+
methodUnsubscribe: newMethodInfo(serverMethod((*Server).unsubscribe), 0),
704+
notificationInitialized: newMethodInfo(serverMethod((*Server).callInitializedHandler), notification|missingParamsOK),
705+
notificationRootsListChanged: newMethodInfo(serverMethod((*Server).callRootsListChangedHandler), notification|missingParamsOK),
706+
notificationProgress: newMethodInfo(sessionMethod((*ServerSession).callProgressNotificationHandler), notification),
703707
}
704708

705709
func (ss *ServerSession) sendingMethodInfos() map[string]methodInfo { return clientMethodInfos }
@@ -744,6 +748,9 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any,
744748
}
745749

746750
func (ss *ServerSession) initialize(ctx context.Context, params *InitializeParams) (*InitializeResult, error) {
751+
if params == nil {
752+
return nil, fmt.Errorf("%w: \"params\" must be be provided", jsonrpc2.ErrInvalidParams)
753+
}
747754
ss.mu.Lock()
748755
ss.initializeParams = params
749756
ss.mu.Unlock()

mcp/shared.go

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ func handleReceive[S Session](ctx context.Context, session S, req *jsonrpc.Reque
129129
}
130130
params, err := info.unmarshalParams(req.Params)
131131
if err != nil {
132-
return nil, fmt.Errorf("handleRequest %q: %w", req.Method, err)
132+
return nil, fmt.Errorf("handling '%s': %w", req.Method, err)
133133
}
134134

135135
mh := session.receivingMethodHandler().(MethodHandler[S])
@@ -154,20 +154,28 @@ func checkRequest(req *jsonrpc.Request, infos map[string]methodInfo) (methodInfo
154154
if !ok {
155155
return methodInfo{}, fmt.Errorf("%w: %q unsupported", jsonrpc2.ErrNotHandled, req.Method)
156156
}
157-
if info.isRequest && !req.ID.IsValid() {
158-
return methodInfo{}, fmt.Errorf("%w: missing ID, %q", jsonrpc2.ErrInvalidRequest, req.Method)
159-
}
160-
if !info.isRequest && req.ID.IsValid() {
157+
if info.flags&notification != 0 && req.ID.IsValid() {
161158
return methodInfo{}, fmt.Errorf("%w: unexpected id for %q", jsonrpc2.ErrInvalidRequest, req.Method)
162159
}
160+
if info.flags&notification == 0 && !req.ID.IsValid() {
161+
return methodInfo{}, fmt.Errorf("%w: missing id for %q", jsonrpc2.ErrInvalidRequest, req.Method)
162+
}
163+
// missingParamsOK is checked here to catch the common case where "params" is
164+
// missing entirely.
165+
//
166+
// However, it's checked again after unmarshalling to catch the rare but
167+
// possible case where "params" is JSON null (see https://go.dev/issue/33835).
168+
if info.flags&missingParamsOK == 0 && len(req.Params) == 0 {
169+
return methodInfo{}, fmt.Errorf("%w: missing required \"params\"", jsonrpc2.ErrInvalidRequest)
170+
}
163171
return info, nil
164172
}
165173

166174
// methodInfo is information about sending and receiving a method.
167175
type methodInfo struct {
168-
// isRequest reports whether the method is a JSON-RPC request.
169-
// Otherwise, the method is treated as a notification.
170-
isRequest bool
176+
// flags is a collection of flags controlling how the JSONRPC method is
177+
// handled. See individual flag values for documentation.
178+
flags methodFlags
171179
// Unmarshal params from the wire into a Params struct.
172180
// Used on the receive side.
173181
unmarshalParams func(json.RawMessage) (Params, error)
@@ -193,20 +201,37 @@ type paramsPtr[T any] interface {
193201
Params
194202
}
195203

204+
type methodFlags int
205+
206+
const (
207+
notification methodFlags = 1 << iota // method is a notification, not request
208+
missingParamsOK // params may be missing or null
209+
)
210+
196211
// newMethodInfo creates a methodInfo from a typedMethodHandler.
197212
//
198213
// If isRequest is set, the method is treated as a request rather than a
199214
// notification.
200-
func newMethodInfo[S Session, P paramsPtr[T], R Result, T any](d typedMethodHandler[S, P, R], isRequest bool) methodInfo {
215+
func newMethodInfo[S Session, P paramsPtr[T], R Result, T any](d typedMethodHandler[S, P, R], flags methodFlags) methodInfo {
201216
return methodInfo{
202-
isRequest: isRequest,
217+
flags: flags,
203218
unmarshalParams: func(m json.RawMessage) (Params, error) {
204219
var p P
205220
if m != nil {
206221
if err := json.Unmarshal(m, &p); err != nil {
207222
return nil, fmt.Errorf("unmarshaling %q into a %T: %w", m, p, err)
208223
}
209224
}
225+
// We must check missingParamsOK here, in addition to checkRequest, to
226+
// catch the edge cases where "params" is set to JSON null.
227+
// See also https://go.dev/issue/33835.
228+
//
229+
// We need to ensure that p is non-null to guard against crashes, as our
230+
// internal code or externally provided handlers may assume that params
231+
// is non-null.
232+
if flags&missingParamsOK == 0 && p == nil {
233+
return nil, fmt.Errorf("%w: missing required \"params\"", jsonrpc2.ErrInvalidRequest)
234+
}
210235
return orZero[Params](p), nil
211236
},
212237
handleMethod: MethodHandler[S](func(ctx context.Context, session S, _ string, params Params) (Result, error) {

mcp/sse_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ func TestSSEServer(t *testing.T) {
8888
responseContains string
8989
}{
9090
{"not a method", `{"jsonrpc":"2.0", "method":"notamethod"}`, "not handled"},
91-
{"missing ID", `{"jsonrpc":"2.0", "method":"ping"}`, "missing ID"},
91+
{"missing ID", `{"jsonrpc":"2.0", "method":"ping"}`, "missing id"},
9292
}
9393
for _, r := range badRequests {
9494
t.Run(r.name, func(t *testing.T) {

mcp/testdata/conformance/server/bad_requests.txtar

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ bad requests.
44
Fixed bugs:
55
- No id in 'initialize' should not panic (#197).
66
- No id in 'ping' should not panic (#194).
7-
- Notifications with IDs should not be treated like requests.
8-
9-
TODO:
107
- No params in 'initialize' should not panic (#195).
8+
- Notifications with IDs should not be treated like requests. (#196)
9+
- No params in 'logging/setLevel' should not panic.
10+
- No params in 'completion/complete' should not panic.
11+
- JSON null params should also not panic in these cases.
1112

1213
-- prompts --
1314
code_review
@@ -22,6 +23,11 @@ code_review
2223
"clientInfo": { "name": "ExampleClient", "version": "1.0.0" }
2324
}
2425
}
26+
{
27+
"jsonrpc": "2.0",
28+
"id": 1,
29+
"method": "initialize"
30+
}
2531
{
2632
"jsonrpc": "2.0",
2733
"id": 2,
@@ -32,10 +38,21 @@ code_review
3238
"clientInfo": { "name": "ExampleClient", "version": "1.0.0" }
3339
}
3440
}
35-
{"jsonrpc":"2.0", "id": 3, "method":"notifications/initialized"}
41+
{"jsonrpc":"2.0", "id": 3, "method": "notifications/initialized"}
3642
{"jsonrpc":"2.0", "method":"ping"}
43+
{"jsonrpc":"2.0", "id": 4, "method": "logging/setLevel"}
44+
{"jsonrpc":"2.0", "id": 5, "method": "completion/complete"}
45+
{"jsonrpc":"2.0", "id": 4, "method": "logging/setLevel", "params": null}
3746

3847
-- server --
48+
{
49+
"jsonrpc": "2.0",
50+
"id": 1,
51+
"error": {
52+
"code": -32600,
53+
"message": "invalid request: missing required \"params\""
54+
}
55+
}
3956
{
4057
"jsonrpc": "2.0",
4158
"id": 2,
@@ -59,6 +76,30 @@ code_review
5976
"id": 3,
6077
"error": {
6178
"code": -32600,
62-
"message": "JSON RPC invalid request: unexpected id for \"notifications/initialized\""
79+
"message": "invalid request: unexpected id for \"notifications/initialized\""
80+
}
81+
}
82+
{
83+
"jsonrpc": "2.0",
84+
"id": 4,
85+
"error": {
86+
"code": -32600,
87+
"message": "invalid request: missing required \"params\""
88+
}
89+
}
90+
{
91+
"jsonrpc": "2.0",
92+
"id": 5,
93+
"error": {
94+
"code": -32600,
95+
"message": "invalid request: missing required \"params\""
96+
}
97+
}
98+
{
99+
"jsonrpc": "2.0",
100+
"id": 4,
101+
"error": {
102+
"code": -32600,
103+
"message": "handling 'logging/setLevel': invalid request: missing required \"params\""
63104
}
64105
}

mcp/testdata/conformance/server/prompts.txtar

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
Check behavior of a server with just prompts.
22

33
Fixed bugs:
4-
- empty tools lists should not be returned as 'null'
4+
- Empty tools lists should not be returned as 'null'.
5+
- No params in 'prompts/get' should not panic.
56

67
-- prompts --
78
code_review
@@ -19,6 +20,7 @@ code_review
1920
}
2021
{ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }
2122
{ "jsonrpc": "2.0", "id": 4, "method": "prompts/list" }
23+
{ "jsonrpc": "2.0", "id": 5, "method": "prompts/get" }
2224
-- server --
2325
{
2426
"jsonrpc": "2.0",
@@ -63,3 +65,11 @@ code_review
6365
]
6466
}
6567
}
68+
{
69+
"jsonrpc": "2.0",
70+
"id": 5,
71+
"error": {
72+
"code": -32600,
73+
"message": "invalid request: missing required \"params\""
74+
}
75+
}

mcp/testdata/conformance/server/resources.txtar

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ Check behavior of a server with just resources.
22

33
Fixed bugs:
44
- A resource result holds a slice of contents, not just one.
5+
- No params in 'resource/read' should not panic.
6+
- No params in 'resources/subscribe' should not panic.
7+
- No params in 'resources/unsubscribe' should not panic.
58

69
-- resources --
710
info
@@ -39,6 +42,8 @@ info.txt
3942
"roots": []
4043
}
4144
}
45+
{ "jsonrpc": "2.0", "id": 4, "method": "resources/read" }
46+
{ "jsonrpc": "2.0", "id": 5, "method": "resources/subscribe" }
4247
-- server --
4348
{
4449
"jsonrpc": "2.0",
@@ -107,3 +112,19 @@ info.txt
107112
]
108113
}
109114
}
115+
{
116+
"jsonrpc": "2.0",
117+
"id": 4,
118+
"error": {
119+
"code": -32600,
120+
"message": "invalid request: missing required \"params\""
121+
}
122+
}
123+
{
124+
"jsonrpc": "2.0",
125+
"id": 5,
126+
"error": {
127+
"code": -32600,
128+
"message": "invalid request: missing required \"params\""
129+
}
130+
}

mcp/testdata/conformance/server/tools.txtar

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Fixed bugs:
44
- "tools/list" can have missing params
55
- "_meta" should not be nil
66
- empty resource or prompts should not be returned as 'null'
7+
- the server should not crash when params are passed to tools/call
78

89
-- tools --
910
greet
@@ -22,6 +23,7 @@ greet
2223
{ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }
2324
{ "jsonrpc": "2.0", "id": 3, "method": "resources/list" }
2425
{ "jsonrpc": "2.0", "id": 4, "method": "prompts/list" }
26+
{ "jsonrpc": "2.0", "id": 5, "method": "tools/call" }
2527
-- server --
2628
{
2729
"jsonrpc": "2.0",
@@ -81,3 +83,11 @@ greet
8183
"prompts": []
8284
}
8385
}
86+
{
87+
"jsonrpc": "2.0",
88+
"id": 5,
89+
"error": {
90+
"code": -32600,
91+
"message": "invalid request: missing required \"params\""
92+
}
93+
}

0 commit comments

Comments
 (0)