Skip to content

Commit ebfc5e9

Browse files
committed
mcp: handle bad or missing params
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 #195
1 parent a9a503f commit ebfc5e9

File tree

8 files changed

+169
-50
lines changed

8 files changed

+169
-50
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 & 9 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,29 @@ 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() {
157+
if info.flags&notification != 0 {
158+
if req.ID.IsValid() {
159+
return methodInfo{}, fmt.Errorf("%w: unexpected id for %q", jsonrpc2.ErrInvalidRequest, req.Method)
160+
}
161+
} else if !req.ID.IsValid() {
158162
return methodInfo{}, fmt.Errorf("%w: missing ID, %q", jsonrpc2.ErrInvalidRequest, req.Method)
159163
}
160-
if !info.isRequest && req.ID.IsValid() {
161-
return methodInfo{}, fmt.Errorf("%w: unexpected id for %q", jsonrpc2.ErrInvalidRequest, req.Method)
164+
// missingParamsOK is checked here to catch the common case where "params" is
165+
// missing entirely.
166+
//
167+
// However, it's checked again after unmarshalling to catch the rare but
168+
// possible case where "params" is JSON null (see https://go.dev/issue/33835).
169+
if info.flags&missingParamsOK == 0 && len(req.Params) == 0 {
170+
return methodInfo{}, fmt.Errorf("%w: missing required \"params\"", jsonrpc2.ErrInvalidRequest)
162171
}
163172
return info, nil
164173
}
165174

166175
// methodInfo is information about sending and receiving a method.
167176
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
177+
// flags is a collection of flags controlling how the JSONRPC method is
178+
// handled. See individual flag values for documentation.
179+
flags methodFlags
171180
// Unmarshal params from the wire into a Params struct.
172181
// Used on the receive side.
173182
unmarshalParams func(json.RawMessage) (Params, error)
@@ -193,20 +202,37 @@ type paramsPtr[T any] interface {
193202
Params
194203
}
195204

205+
type methodFlags int
206+
207+
const (
208+
notification methodFlags = 1 << iota // method is a notification, not request
209+
missingParamsOK // params may be missing or null
210+
)
211+
196212
// newMethodInfo creates a methodInfo from a typedMethodHandler.
197213
//
198214
// If isRequest is set, the method is treated as a request rather than a
199215
// notification.
200-
func newMethodInfo[S Session, P paramsPtr[T], R Result, T any](d typedMethodHandler[S, P, R], isRequest bool) methodInfo {
216+
func newMethodInfo[S Session, P paramsPtr[T], R Result, T any](d typedMethodHandler[S, P, R], flags methodFlags) methodInfo {
201217
return methodInfo{
202-
isRequest: isRequest,
218+
flags: flags,
203219
unmarshalParams: func(m json.RawMessage) (Params, error) {
204220
var p P
205221
if m != nil {
206222
if err := json.Unmarshal(m, &p); err != nil {
207223
return nil, fmt.Errorf("unmarshaling %q into a %T: %w", m, p, err)
208224
}
209225
}
226+
// We must check missingParamsOK here, in addition to checkRequest, to
227+
// catch the edge cases where "params" is set to JSON null.
228+
// See also https://go.dev/issue/33835.
229+
//
230+
// We need to ensure that p is non-null to guard against crashes, as our
231+
// internal code or externally provided handlers may assume that params
232+
// is non-null.
233+
if flags&missingParamsOK == 0 && p == nil {
234+
return nil, fmt.Errorf("%w: missing required \"params\"", jsonrpc2.ErrInvalidRequest)
235+
}
210236
return orZero[Params](p), nil
211237
},
212238
handleMethod: MethodHandler[S](func(ctx context.Context, session S, _ string, params Params) (Result, error) {

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)