Skip to content

Commit d4f63f8

Browse files
authored
go-sdk review: schema bypass canary test, ServerInfo logging, doc comments (#3531)
Addresses high-priority items from the go-sdk module review (#748): SDK upgrade safety canary, backend diagnostics, and documentation for maintainers. ### Schema bypass canary test - `TestSchemaBypassCanary` registers tools with draft-07 features (`$ref`, `definitions`, `patternProperties`) via `Server.AddTool` and asserts no panic. Breaks loudly if a future SDK version tightens validation on the instance method path that `registerToolWithoutValidation` relies on. ### Surface backend server info during registration - New `Connection.ServerInfo()` extracts name/version from `session.InitializeResult()`. - `registerToolsFromBackend` logs backend identity on connect, or notes its absence — useful for debugging version mismatches. ```go if name, version := conn.ServerInfo(); name != "" { logger.LogInfoWithServer(serverID, "backend", "Backend server info: name=%s, version=%s", name, version) } ``` ### Documentation for future maintainers - **`marshalToResponse` ID placeholder**: explains why `ID: 1` is safe (SDK resolves correlation internally; this ID is never used for matching). - **Local `CallToolParams` type**: explains why `mcp/types.go` maintains its own type with `map[string]interface{}` Arguments instead of reusing the SDK's `json.RawMessage`-based params. > [!WARNING] > > <details> > <summary>Firewall rules blocked me from connecting to one or more addresses (expand for details)</summary> > > #### I tried to connect to the following addresses, but was blocked by firewall rules: > > - `example.com` > - Triggering command: `/tmp/go-build2372329102/b510/launcher.test /tmp/go-build2372329102/b510/launcher.test -test.testlogfile=/tmp/go-build2372329102/b510/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true g_.a ache/go/1.25.8/x-ifaceassert u/13/cc1 -I ernal/logger -I u/13/cc1 ache�� SaYeuEbQ8 64/src/net 64/pkg/tool/linu-nilfunc -p crypto/internal/-o -lang=go1.25 64/pkg/tool/linu-trimpath` (dns block) > - Triggering command: `/tmp/go-build3813880827/b514/launcher.test /tmp/go-build3813880827/b514/launcher.test -test.testlogfile=/tmp/go-build3813880827/b514/testlog.txt -test.paniconexit0 -test.timeout=10m0s -ato�� -bool -buildtags x_amd64/vet -errorsas -ifaceassert -nilfunc x_amd64/vet -ato�� -bool -buildtags x_amd64/vet -errorsas -ifaceassert -nilfunc x_amd64/vet` (dns block) > - `invalid-host-that-does-not-exist-12345.com` > - Triggering command: `/tmp/go-build2372329102/b492/config.test /tmp/go-build2372329102/b492/config.test -test.testlogfile=/tmp/go-build2372329102/b492/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true ternal/ieee754/i-p _cgo_.o x_amd64/compile` (dns block) > - Triggering command: `/tmp/go-build3813880827/b496/config.test /tmp/go-build3813880827/b496/config.test -test.testlogfile=/tmp/go-build3813880827/b496/testlog.txt -test.paniconexit0 -test.timeout=10m0s -o /tmp/go-build2372329102/b390/_pkg_.a -trimpath .test -p google.golang.or/tmp/go-build3275255162/b301/vet.cfg -lang=go1.24 .test 3723�� /tmp/go-build2372329102/b422/_pkg_.a -trimpath de/node/bin/git -p google.golang.or/tmp/go-build3275255162/b430/vet.cfg -lang=go1.24 /opt/hostedtoolcache/go/1.25.8/x64/pkg/tool/linu/tmp/go-build2372329102/b537/_testmain.go` (dns block) > - `nonexistent.local` > - Triggering command: `/tmp/go-build2372329102/b510/launcher.test /tmp/go-build2372329102/b510/launcher.test -test.testlogfile=/tmp/go-build2372329102/b510/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true g_.a ache/go/1.25.8/x-ifaceassert u/13/cc1 -I ernal/logger -I u/13/cc1 ache�� SaYeuEbQ8 64/src/net 64/pkg/tool/linu-nilfunc -p crypto/internal/-o -lang=go1.25 64/pkg/tool/linu-trimpath` (dns block) > - Triggering command: `/tmp/go-build3813880827/b514/launcher.test /tmp/go-build3813880827/b514/launcher.test -test.testlogfile=/tmp/go-build3813880827/b514/testlog.txt -test.paniconexit0 -test.timeout=10m0s -ato�� -bool -buildtags x_amd64/vet -errorsas -ifaceassert -nilfunc x_amd64/vet -ato�� -bool -buildtags x_amd64/vet -errorsas -ifaceassert -nilfunc x_amd64/vet` (dns block) > - `slow.example.com` > - Triggering command: `/tmp/go-build2372329102/b510/launcher.test /tmp/go-build2372329102/b510/launcher.test -test.testlogfile=/tmp/go-build2372329102/b510/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true g_.a ache/go/1.25.8/x-ifaceassert u/13/cc1 -I ernal/logger -I u/13/cc1 ache�� SaYeuEbQ8 64/src/net 64/pkg/tool/linu-nilfunc -p crypto/internal/-o -lang=go1.25 64/pkg/tool/linu-trimpath` (dns block) > - Triggering command: `/tmp/go-build3813880827/b514/launcher.test /tmp/go-build3813880827/b514/launcher.test -test.testlogfile=/tmp/go-build3813880827/b514/testlog.txt -test.paniconexit0 -test.timeout=10m0s -ato�� -bool -buildtags x_amd64/vet -errorsas -ifaceassert -nilfunc x_amd64/vet -ato�� -bool -buildtags x_amd64/vet -errorsas -ifaceassert -nilfunc x_amd64/vet` (dns block) > - `this-host-does-not-exist-12345.com` > - Triggering command: `/tmp/go-build2372329102/b519/mcp.test /tmp/go-build2372329102/b519/mcp.test -test.testlogfile=/tmp/go-build2372329102/b519/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true s/known/wrapperspb/wrappers.pb.go .cfg x_amd64/compile --gdwarf-5 --64 -o x_amd64/compile -o g_.a 2329102/b165/ x_amd64/vet -p` (dns block) > - Triggering command: `/tmp/go-build3813880827/b523/mcp.test /tmp/go-build3813880827/b523/mcp.test -test.testlogfile=/tmp/go-build3813880827/b523/testlog.txt -test.paniconexit0 -test.timeout=10m0s -ato�� 2329102/b519/mcp-errorsas -buildtags x_amd64/vet -errorsas -ifaceassert -nilfunc x_amd64/vet n-me�� ry=1 {{json .Mounts}} ache/Python/3.12.13/x64/bin/sh ache/go/1.25.8/xbash .cfg ache/go/1.25.8/x--version /usr/bin/runc.original` (dns block) > > If you need me to access, download, or install something from one of these locations, you can either: > > - Configure [Actions setup steps](https://gh.io/copilot/actions-setup-steps) to set up my environment, which run before the firewall is enabled > - Add the appropriate URLs or hosts to the custom allowlist in this repository's [Copilot coding agent settings](https://github.com/github/gh-aw-mcpg/settings/copilot/coding_agent) (admins only) > > </details>
2 parents c72cb6b + 8c251d0 commit d4f63f8

File tree

4 files changed

+107
-2
lines changed

4 files changed

+107
-2
lines changed

internal/mcp/connection.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,20 @@ func (c *Connection) GetHTTPHeaders() map[string]string {
287287
return c.headers
288288
}
289289

290+
// ServerInfo returns the backend's name and version from the MCP initialize handshake.
291+
// Returns ("", "") when no SDK session is available (plain JSON-RPC transport).
292+
func (c *Connection) ServerInfo() (name, version string) {
293+
sess := c.getSDKSession()
294+
if sess == nil {
295+
return "", ""
296+
}
297+
initResult := sess.InitializeResult()
298+
if initResult == nil || initResult.ServerInfo == nil {
299+
return "", ""
300+
}
301+
return initResult.ServerInfo.Name, initResult.ServerInfo.Version
302+
}
303+
290304
// reconnectPlainJSON re-initialises the plain JSON-RPC session with the HTTP backend.
291305
// It is safe for concurrent callers: only one reconnect runs at a time, and the updated
292306
// session ID is available to all callers once the lock is released.
@@ -482,6 +496,11 @@ func (c *Connection) callSDKMethod(method string, params interface{}) (*Response
482496

483497
// marshalToResponse marshals an SDK result into a Response object.
484498
// This helper reduces code duplication across all MCP method wrappers.
499+
//
500+
// The ID field is set to a static placeholder (1) because this Response is only
501+
// constructed after the SDK's session.XXX() call has already resolved the
502+
// request–response correlation internally. The gateway never uses this ID for
503+
// matching; it is present solely to satisfy the JSON-RPC 2.0 structure.
485504
func marshalToResponse(result interface{}) (*Response, error) {
486505
resultJSON, err := json.Marshal(result)
487506
if err != nil {
@@ -490,7 +509,7 @@ func marshalToResponse(result interface{}) (*Response, error) {
490509

491510
return &Response{
492511
JSONRPC: "2.0",
493-
ID: 1, // Placeholder ID
512+
ID: 1, // Placeholder – see function comment for safety rationale
494513
Result: resultJSON,
495514
}, nil
496515
}

internal/mcp/types.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,14 @@ type Tool struct {
3434
InputSchema map[string]interface{} `json:"inputSchema"`
3535
}
3636

37-
// CallToolParams represents parameters for calling a tool
37+
// CallToolParams represents parameters for calling a tool.
38+
//
39+
// This is a local type rather than an alias for the SDK's CallToolParams because the
40+
// gateway's plain JSON-RPC transport path needs to marshal/unmarshal tool call parameters
41+
// from raw JSON without importing the SDK's typed params (which use json.RawMessage for
42+
// Arguments). The local type uses map[string]interface{} for Arguments, matching the
43+
// gateway's internal representation and simplifying the marshal/unmarshal roundtrip in
44+
// callParamMethod. See callTool in connection.go for the bridge to the SDK type.
3845
type CallToolParams struct {
3946
Name string `json:"name"`
4047
Arguments map[string]interface{} `json:"arguments,omitempty"`

internal/server/tool_registry.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,14 @@ func (us *UnifiedServer) registerToolsFromBackend(serverID string) error {
153153
return fmt.Errorf("failed to connect: %w", err)
154154
}
155155

156+
// Surface backend server info from the MCP initialize handshake for diagnostics.
157+
// This helps debug compatibility issues between the gateway and specific backends.
158+
if name, version := conn.ServerInfo(); name != "" {
159+
logger.LogInfoWithServer(serverID, "backend", "Backend server info: name=%s, version=%s", name, version)
160+
} else {
161+
logger.LogInfoWithServer(serverID, "backend", "Backend server info unavailable (no SDK session or server omitted serverInfo)")
162+
}
163+
156164
// List tools from backend
157165
result, err := conn.SendRequestWithServerID(context.Background(), "tools/list", nil, serverID)
158166
if err != nil {

internal/server/tool_registry_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"testing"
99

1010
"github.com/github/gh-aw-mcpg/internal/config"
11+
sdk "github.com/modelcontextprotocol/go-sdk/mcp"
1112
"github.com/stretchr/testify/assert"
1213
"github.com/stretchr/testify/require"
1314
)
@@ -591,3 +592,73 @@ func TestRegisterToolsFromBackend_EmptyAllowedList(t *testing.T) {
591592
assert.Contains(us.tools, "s___tool_b")
592593
assert.Contains(us.tools, "s___tool_c")
593594
}
595+
596+
// TestSchemaBypassCanary is a canary test for SDK upgrades.
597+
//
598+
// The gateway relies on Server.AddTool (instance method) to register backend tools
599+
// WITHOUT full JSON Schema validation, because backends may emit schemas using
600+
// draft-07 features (e.g., "$ref", "definitions", "if/then/else") that the SDK's
601+
// stricter package-level AddTool function would reject.
602+
//
603+
// This test verifies that Server.AddTool still accepts such schemas. If it panics
604+
// or rejects them after an SDK upgrade, the gateway's tool registration will break
605+
// and this test serves as the early warning.
606+
//
607+
// See also: registerToolWithoutValidation in tool_registry.go.
608+
func TestSchemaBypassCanary(t *testing.T) {
609+
assert := assert.New(t)
610+
611+
server := sdk.NewServer(&sdk.Implementation{Name: "canary", Version: "1.0"}, &sdk.ServerOptions{})
612+
noop := func(ctx context.Context, req *sdk.CallToolRequest) (*sdk.CallToolResult, error) {
613+
return &sdk.CallToolResult{}, nil
614+
}
615+
616+
// Draft-07 schema with $ref and definitions — valid JSON Schema but uses
617+
// features beyond what the SDK's stricter path validates.
618+
draft07Schema := map[string]interface{}{
619+
"type": "object",
620+
"properties": map[string]interface{}{
621+
"repo": map[string]interface{}{
622+
"$ref": "#/definitions/repoName",
623+
},
624+
},
625+
"definitions": map[string]interface{}{
626+
"repoName": map[string]interface{}{
627+
"type": "string",
628+
"minLength": 1,
629+
},
630+
},
631+
}
632+
633+
// Server.AddTool (instance method) must not panic — this is the code path
634+
// that registerToolWithoutValidation uses.
635+
assert.NotPanics(func() {
636+
server.AddTool(&sdk.Tool{
637+
Name: "draft07_tool",
638+
Description: "Tool with draft-07 schema features",
639+
InputSchema: draft07Schema,
640+
}, noop)
641+
}, "Server.AddTool must accept draft-07 schemas; if this fails after an SDK upgrade, "+
642+
"registerToolWithoutValidation needs to be updated")
643+
644+
// Schema with additionalProperties and patternProperties — another draft-07
645+
// feature set that backends commonly use.
646+
extendedSchema := map[string]interface{}{
647+
"type": "object",
648+
"properties": map[string]interface{}{
649+
"query": map[string]interface{}{"type": "string"},
650+
},
651+
"additionalProperties": false,
652+
"patternProperties": map[string]interface{}{
653+
"^x-": map[string]interface{}{"type": "string"},
654+
},
655+
}
656+
657+
assert.NotPanics(func() {
658+
server.AddTool(&sdk.Tool{
659+
Name: "extended_schema_tool",
660+
Description: "Tool with extended schema features",
661+
InputSchema: extendedSchema,
662+
}, noop)
663+
}, "Server.AddTool must accept schemas with patternProperties/additionalProperties")
664+
}

0 commit comments

Comments
 (0)