fix(mcp): expose standard JSON Schema formats in tool schemas
The gnostic OpenAPI generator hardcodes `format: enum`, `format: bytes`, and `format: field-mask` on string properties. These are OpenAPI-only markers; MCP clients treat tool schemas as JSON Schema 2020-12 and warn or fail on them (zod-based parsers log "unknown format" for every tool on every load, ajv strict mode rejects the definitions). Normalize schemas as they leave the resolver and when parameter schemas are cloned: drop `format: enum` (the `enum` keyword is already present), rewrite `format: bytes` to `contentEncoding: base64`, and drop `format: field-mask`. The generated openapi.yaml is unchanged. A catalog test now walks every curated tool's input and output schema and fails on any format outside the registered allowlist. Fixes #6262
This commit is contained in:
parent
b88a71540a
commit
652957c0f9
7 changed files with 361 additions and 7 deletions
|
|
@ -42,7 +42,11 @@ fast on any inconsistency:
|
||||||
IDs or duplicate tool names are construction errors.
|
IDs or duplicate tool names are construction errors.
|
||||||
4. Each tool is registered with `server.AddTool(tool, newMCPToolHandler(...))`.
|
4. Each tool is registered with `server.AddTool(tool, newMCPToolHandler(...))`.
|
||||||
5. `sdkmcp.NewStreamableHTTPHandler` wraps the server in stateless,
|
5. `sdkmcp.NewStreamableHTTPHandler` wraps the server in stateless,
|
||||||
JSON-response mode (no SSE, no session tracking).
|
JSON-response mode (no SSE, no session tracking). Stateless mode is also
|
||||||
|
what lets the SDK serve protocol version `2026-07-28`; older clients still
|
||||||
|
negotiate `2025-11-25` and earlier through the legacy `initialize` handshake.
|
||||||
|
The transport body limit is set to the API-wide limit so attachment uploads
|
||||||
|
are not cut off by the SDK's 4 MiB default.
|
||||||
|
|
||||||
## Request flow
|
## Request flow
|
||||||
|
|
||||||
|
|
@ -105,6 +109,14 @@ response has no JSON body, the fallback is:
|
||||||
- **Endpoint:** `POST /mcp` (the SDK may also use `GET`/`DELETE` on the same
|
- **Endpoint:** `POST /mcp` (the SDK may also use `GET`/`DELETE` on the same
|
||||||
path for the Streamable HTTP transport).
|
path for the Streamable HTTP transport).
|
||||||
- **Transport:** Streamable HTTP, **stateless**, JSON responses.
|
- **Transport:** Streamable HTTP, **stateless**, JSON responses.
|
||||||
|
- **Protocol versions:** `2026-07-28` down to `2024-11-05`. Clients on
|
||||||
|
`2026-07-28` skip `initialize`, call `server/discover`, carry
|
||||||
|
`_meta.io.modelcontextprotocol/protocolVersion` on every request, and must
|
||||||
|
send the `Mcp-Protocol-Version`, `Mcp-Method`, and (for `tools/call`)
|
||||||
|
`Mcp-Name` headers.
|
||||||
|
- **Capabilities:** tools only, without `listChanged` — the catalog is fixed at
|
||||||
|
startup, so `tools/list` and `server/discover` advertise a 24-hour `ttlMs`
|
||||||
|
instead of change notifications.
|
||||||
- **Request size:** request bodies are limited to 256 MiB before SDK dispatch.
|
- **Request size:** request bodies are limited to 256 MiB before SDK dispatch.
|
||||||
- **Auth:** the caller's `Authorization: Bearer <token>` header is forwarded to
|
- **Auth:** the caller's `Authorization: Bearer <token>` header is forwarded to
|
||||||
the in-process API request. Mutating tools therefore require a valid token
|
the in-process API request. Mutating tools therefore require a valid token
|
||||||
|
|
@ -261,7 +273,9 @@ go test ./server/router/mcp/...
|
||||||
- `catalog_test.go` — tool selection, naming, schema and annotation building.
|
- `catalog_test.go` — tool selection, naming, schema and annotation building.
|
||||||
- `adapter_test.go` — request construction and in-process execution (`adapter.go`), plus result normalization and error shaping (`result.go`).
|
- `adapter_test.go` — request construction and in-process execution (`adapter.go`), plus result normalization and error shaping (`result.go`).
|
||||||
- `validation_test.go` — argument validation against input schemas.
|
- `validation_test.go` — argument validation against input schemas.
|
||||||
- `service_test.go` — the origin-header check, plus the end-to-end MCP protocol
|
- `service_test.go` — the origin-header check, the stateless `2026-07-28`
|
||||||
|
flow (`server/discover`, `tools/list`, `tools/call` with MCP headers), the
|
||||||
|
request body limit, plus the legacy end-to-end MCP protocol
|
||||||
(`initialize`, `tools/list`, `tools/call`) confirming object-shaped
|
(`initialize`, `tools/list`, `tools/call`) confirming object-shaped
|
||||||
`structuredContent`.
|
`structuredContent`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,7 @@ func inputSchemaForOperation(operation *openAPIOperation) jsonSchema {
|
||||||
required := []string{}
|
required := []string{}
|
||||||
defs := map[string]any{}
|
defs := map[string]any{}
|
||||||
for _, parameter := range operation.Parameters {
|
for _, parameter := range operation.Parameters {
|
||||||
schema := cloneSchema(parameter.Schema)
|
schema := jsonSchema(sanitizeSchemaValue(parameter.Schema).(map[string]any))
|
||||||
if parameter.Description != "" {
|
if parameter.Description != "" {
|
||||||
schema["description"] = parameter.Description
|
schema["description"] = parameter.Description
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
|
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|
@ -387,3 +389,56 @@ func TestBuildCuratedToolsRejectsDuplicateToolNames(t *testing.T) {
|
||||||
_, _, err := buildCuratedTools(registry)
|
_, _, err := buildCuratedTools(registry)
|
||||||
require.ErrorContains(t, err, "duplicate MCP tool name")
|
require.ErrorContains(t, err, "duplicate MCP tool name")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildCuratedToolsUseStandardSchemaFormats(t *testing.T) {
|
||||||
|
spec, err := loadOpenAPISpec("../../../proto/gen/openapi.yaml")
|
||||||
|
require.NoError(t, err)
|
||||||
|
registry, err := buildOperationRegistry(spec)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tools, _, err := buildCuratedTools(registry)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Formats registered by JSON Schema or ajv-formats; anything else trips
|
||||||
|
// strict clients (see https://github.com/usememos/memos/issues/6262).
|
||||||
|
allowedFormats := map[string]struct{}{
|
||||||
|
"date-time": {},
|
||||||
|
"int32": {},
|
||||||
|
"int64": {},
|
||||||
|
"float": {},
|
||||||
|
"double": {},
|
||||||
|
}
|
||||||
|
var collectFormats func(t *testing.T, tool string, path string, value any)
|
||||||
|
collectFormats = func(t *testing.T, tool string, path string, value any) {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
if format, ok := typed["format"]; ok {
|
||||||
|
formatName, isString := format.(string)
|
||||||
|
require.True(t, isString, "%s: %s has non-string format %v", tool, path, format)
|
||||||
|
require.Contains(t, allowedFormats, formatName, "%s: %s uses non-standard format %q", tool, path, formatName)
|
||||||
|
}
|
||||||
|
for key, item := range typed {
|
||||||
|
collectFormats(t, tool, path+"/"+key, item)
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for index, item := range typed {
|
||||||
|
collectFormats(t, tool, path+"/"+strconv.Itoa(index), item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sawBase64Content := false
|
||||||
|
for _, tool := range tools {
|
||||||
|
for label, schema := range map[string]any{"inputSchema": tool.InputSchema, "outputSchema": tool.OutputSchema} {
|
||||||
|
encoded, err := json.Marshal(schema)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var decoded any
|
||||||
|
require.NoError(t, json.Unmarshal(encoded, &decoded))
|
||||||
|
collectFormats(t, tool.Name, label, decoded)
|
||||||
|
if strings.Contains(string(encoded), `"contentEncoding":"base64"`) {
|
||||||
|
sawBase64Content = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.True(t, sawBase64Content, "expected attachment content to advertise contentEncoding base64")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -186,9 +186,55 @@ func resolveSchemaMap(spec *openAPISpec, schema map[string]any, defs map[string]
|
||||||
}
|
}
|
||||||
resolved[key] = resolvedValue
|
resolved[key] = resolvedValue
|
||||||
}
|
}
|
||||||
|
normalizeSchemaFormat(resolved)
|
||||||
return resolved, nil
|
return resolved, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeSchemaFormat rewrites the OpenAPI-only "format" markers emitted by
|
||||||
|
// the gnostic generator into standard JSON Schema so MCP clients can consume
|
||||||
|
// tool schemas without warnings or strict-mode failures.
|
||||||
|
//
|
||||||
|
// - "enum" duplicates the "enum" keyword and is dropped.
|
||||||
|
// - "bytes" becomes "contentEncoding: base64", matching the protojson wire encoding.
|
||||||
|
// - "field-mask" has no JSON Schema equivalent and is dropped.
|
||||||
|
func normalizeSchemaFormat(schema map[string]any) {
|
||||||
|
format, ok := schema["format"].(string)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch format {
|
||||||
|
case "enum", "field-mask":
|
||||||
|
delete(schema, "format")
|
||||||
|
case "bytes":
|
||||||
|
delete(schema, "format")
|
||||||
|
schema["contentEncoding"] = "base64"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeSchemaValue deep-copies a schema value that did not pass through the
|
||||||
|
// component resolver (such as parameter schemas) and normalizes its formats.
|
||||||
|
func sanitizeSchemaValue(value any) any {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case jsonSchema:
|
||||||
|
return sanitizeSchemaValue(map[string]any(typed))
|
||||||
|
case map[string]any:
|
||||||
|
sanitized := make(map[string]any, len(typed))
|
||||||
|
for key, item := range typed {
|
||||||
|
sanitized[key] = sanitizeSchemaValue(item)
|
||||||
|
}
|
||||||
|
normalizeSchemaFormat(sanitized)
|
||||||
|
return sanitized
|
||||||
|
case []any:
|
||||||
|
sanitized := make([]any, 0, len(typed))
|
||||||
|
for _, item := range typed {
|
||||||
|
sanitized = append(sanitized, sanitizeSchemaValue(item))
|
||||||
|
}
|
||||||
|
return sanitized
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func resolveComponentSchema(spec *openAPISpec, name string, defs map[string]any, resolving map[string]bool) (map[string]any, error) {
|
func resolveComponentSchema(spec *openAPISpec, name string, defs map[string]any, resolving map[string]bool) (map[string]any, error) {
|
||||||
component, ok := spec.Components.Schemas[name]
|
component, ok := spec.Components.Schemas[name]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
|
||||||
|
|
@ -190,3 +190,49 @@ func TestBuildOperationRegistryUsesOKSchemaForEmptySuccessResponse(t *testing.T)
|
||||||
require.Equal(t, "object", responseSchema["type"])
|
require.Equal(t, "object", responseSchema["type"])
|
||||||
require.Contains(t, responseSchema["properties"], "ok")
|
require.Contains(t, responseSchema["properties"], "ok")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveSchemaRefNormalizesNonStandardFormats(t *testing.T) {
|
||||||
|
spec := &openAPISpec{
|
||||||
|
Components: openAPIComponents{
|
||||||
|
Schemas: map[string]jsonSchema{
|
||||||
|
"Attachment": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"state": map[string]any{"type": "string", "format": "enum", "enum": []any{"NORMAL", "ARCHIVED"}},
|
||||||
|
"content": map[string]any{"type": "string", "format": "bytes"},
|
||||||
|
"updateMask": map[string]any{"type": "string", "format": "field-mask"},
|
||||||
|
"createTime": map[string]any{"type": "string", "format": "date-time"},
|
||||||
|
"tags": map[string]any{"type": "array", "items": map[string]any{"type": "string", "format": "enum"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
schema, err := resolveSchemaRef(spec, jsonSchema{"$ref": "#/components/schemas/Attachment"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
properties := schema["properties"].(map[string]any)
|
||||||
|
|
||||||
|
state := properties["state"].(map[string]any)
|
||||||
|
require.NotContains(t, state, "format")
|
||||||
|
require.Equal(t, []any{"NORMAL", "ARCHIVED"}, state["enum"])
|
||||||
|
|
||||||
|
content := properties["content"].(map[string]any)
|
||||||
|
require.NotContains(t, content, "format")
|
||||||
|
require.Equal(t, "base64", content["contentEncoding"])
|
||||||
|
|
||||||
|
require.NotContains(t, properties["updateMask"].(map[string]any), "format")
|
||||||
|
require.Equal(t, "date-time", properties["createTime"].(map[string]any)["format"])
|
||||||
|
require.NotContains(t, properties["tags"].(map[string]any)["items"].(map[string]any), "format")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeSchemaValueDoesNotMutateInput(t *testing.T) {
|
||||||
|
original := jsonSchema{"type": "string", "format": "enum", "items": map[string]any{"format": "bytes"}}
|
||||||
|
|
||||||
|
sanitized := sanitizeSchemaValue(original).(map[string]any)
|
||||||
|
require.NotContains(t, sanitized, "format")
|
||||||
|
require.Equal(t, "base64", sanitized["items"].(map[string]any)["contentEncoding"])
|
||||||
|
|
||||||
|
require.Equal(t, "enum", original["format"])
|
||||||
|
require.Equal(t, "bytes", original["items"].(map[string]any)["format"])
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
"github.com/labstack/echo/v5/middleware"
|
"github.com/labstack/echo/v5/middleware"
|
||||||
|
|
@ -17,9 +18,16 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxMCPRequestBytes caps the /mcp request body. It tracks the API limit because
|
// maxMCPRequestBytes caps the /mcp request body. It tracks the API limit because
|
||||||
// every tool call is forwarded in-process through the API routes.
|
// every tool call is forwarded in-process through the API routes. The SDK
|
||||||
|
// enforces its own 4 MiB default, so the value is passed to the transport as
|
||||||
|
// well; otherwise attachment uploads over MCP fail with 413 before Echo sees them.
|
||||||
const maxMCPRequestBytes int64 = apiv1.MaxAPIRequestBytes
|
const maxMCPRequestBytes int64 = apiv1.MaxAPIRequestBytes
|
||||||
|
|
||||||
|
// toolCatalogTTL is the freshness hint advertised on tools/list and
|
||||||
|
// server/discover results. The catalog is fixed at startup, so clients may cache
|
||||||
|
// it for a long time instead of re-listing on every session.
|
||||||
|
const toolCatalogTTL = 24 * time.Hour
|
||||||
|
|
||||||
// MCPService serves the OpenAPI-driven MCP endpoint.
|
// MCPService serves the OpenAPI-driven MCP endpoint.
|
||||||
type MCPService struct {
|
type MCPService struct {
|
||||||
profile *profile.Profile
|
profile *profile.Profile
|
||||||
|
|
@ -50,7 +58,16 @@ func NewMCPService(profile *profile.Profile, echoServer *echo.Echo) (*MCPService
|
||||||
server := sdkmcp.NewServer(&sdkmcp.Implementation{
|
server := sdkmcp.NewServer(&sdkmcp.Implementation{
|
||||||
Name: "memos",
|
Name: "memos",
|
||||||
Version: version,
|
Version: version,
|
||||||
}, nil)
|
}, &sdkmcp.ServerOptions{
|
||||||
|
// The tool catalog never changes after startup, so do not advertise
|
||||||
|
// listChanged: new-protocol clients would otherwise hold a
|
||||||
|
// subscriptions/listen stream open for notifications that never come.
|
||||||
|
// Leaving Logging unset also drops the deprecated logging capability.
|
||||||
|
Capabilities: &sdkmcp.ServerCapabilities{
|
||||||
|
Tools: &sdkmcp.ToolCapabilities{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
server.AddReceivingMiddleware(catalogCacheMiddleware)
|
||||||
|
|
||||||
adapter := newAPIAdapter(echoServer)
|
adapter := newAPIAdapter(echoServer)
|
||||||
for _, tool := range tools {
|
for _, tool := range tools {
|
||||||
|
|
@ -61,8 +78,11 @@ func NewMCPService(profile *profile.Profile, echoServer *echo.Echo) (*MCPService
|
||||||
streamableHandler := sdkmcp.NewStreamableHTTPHandler(func(*http.Request) *sdkmcp.Server {
|
streamableHandler := sdkmcp.NewStreamableHTTPHandler(func(*http.Request) *sdkmcp.Server {
|
||||||
return server
|
return server
|
||||||
}, &sdkmcp.StreamableHTTPOptions{
|
}, &sdkmcp.StreamableHTTPOptions{
|
||||||
Stateless: true,
|
// Stateless is also what lets the SDK serve protocol version
|
||||||
JSONResponse: true,
|
// 2026-07-28; stateful handlers negotiate down to 2025-11-25.
|
||||||
|
Stateless: true,
|
||||||
|
JSONResponse: true,
|
||||||
|
MaxRequestBodyBytes: maxMCPRequestBytes,
|
||||||
// memos is typically served behind a reverse proxy with the app bound to a
|
// memos is typically served behind a reverse proxy with the app bound to a
|
||||||
// loopback address while the public Host header is a real domain. The SDK's
|
// loopback address while the public Host header is a real domain. The SDK's
|
||||||
// DNS-rebinding guard treats that shape as an attack and rejects every
|
// DNS-rebinding guard treats that shape as an attack and rejects every
|
||||||
|
|
@ -78,6 +98,24 @@ func NewMCPService(profile *profile.Profile, echoServer *echo.Echo) (*MCPService
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// catalogCacheMiddleware stamps the static-catalog TTL onto results the SDK
|
||||||
|
// would otherwise mark immediately stale (ttlMs: 0).
|
||||||
|
func catalogCacheMiddleware(next sdkmcp.MethodHandler) sdkmcp.MethodHandler {
|
||||||
|
return func(ctx context.Context, method string, request sdkmcp.Request) (sdkmcp.Result, error) {
|
||||||
|
result, err := next(ctx, method, request)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
switch typed := result.(type) {
|
||||||
|
case *sdkmcp.ListToolsResult:
|
||||||
|
typed.TTLMs = int(toolCatalogTTL.Milliseconds())
|
||||||
|
case *sdkmcp.DiscoverResult:
|
||||||
|
typed.TTLMs = int(toolCatalogTTL.Milliseconds())
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func loadMCPServiceOpenAPISpec() (*openAPISpec, error) {
|
func loadMCPServiceOpenAPISpec() (*openAPISpec, error) {
|
||||||
spec := &openAPISpec{}
|
spec := &openAPISpec{}
|
||||||
if err := yaml.Unmarshal(memosproto.OpenAPIYAML(), spec); err != nil {
|
if err := yaml.Unmarshal(memosproto.OpenAPIYAML(), spec); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
|
|
@ -473,3 +474,157 @@ func postMCP(t *testing.T, echoServer *echo.Echo, payload map[string]any) map[st
|
||||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
|
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMCPStatelessProtocol2026 drives the endpoint the way a 2026-07-28 client
|
||||||
|
// does: no initialize handshake, per-request _meta, and the standard MCP headers.
|
||||||
|
func TestMCPStatelessProtocol2026(t *testing.T) {
|
||||||
|
echoServer := echo.New()
|
||||||
|
var forwardedAuthorization string
|
||||||
|
echoServer.GET("/api/v1/memos", func(c *echo.Context) error {
|
||||||
|
forwardedAuthorization = c.Request().Header.Get("Authorization")
|
||||||
|
return c.JSON(http.StatusOK, map[string]any{"memos": []any{}})
|
||||||
|
})
|
||||||
|
service, err := NewMCPService(&profile.Profile{Version: "test-version"}, echoServer)
|
||||||
|
require.NoError(t, err)
|
||||||
|
service.RegisterRoutes(echoServer)
|
||||||
|
|
||||||
|
meta := map[string]any{
|
||||||
|
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
||||||
|
"io.modelcontextprotocol/clientCapabilities": map[string]any{},
|
||||||
|
"io.modelcontextprotocol/clientInfo": map[string]any{"name": "memos-test", "version": "1.0.0"},
|
||||||
|
}
|
||||||
|
|
||||||
|
discover := postMCPWithHeaders(t, echoServer, map[string]any{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "server/discover",
|
||||||
|
"params": map[string]any{"_meta": meta},
|
||||||
|
}, map[string]string{"Mcp-Method": "server/discover"})
|
||||||
|
discoverResult, ok := discover["result"].(map[string]any)
|
||||||
|
require.True(t, ok, discover)
|
||||||
|
require.Contains(t, discoverResult["supportedVersions"], "2026-07-28")
|
||||||
|
require.Equal(t, "complete", discoverResult["resultType"])
|
||||||
|
capabilities, ok := discoverResult["capabilities"].(map[string]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, map[string]any{}, capabilities["tools"], "tools capability must not advertise listChanged")
|
||||||
|
require.NotContains(t, capabilities, "logging", "logging is deprecated and unused")
|
||||||
|
require.Positive(t, discoverResult["ttlMs"])
|
||||||
|
|
||||||
|
list := postMCPWithHeaders(t, echoServer, map[string]any{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 2,
|
||||||
|
"method": "tools/list",
|
||||||
|
"params": map[string]any{"_meta": meta},
|
||||||
|
}, map[string]string{"Mcp-Method": "tools/list"})
|
||||||
|
listResult, ok := list["result"].(map[string]any)
|
||||||
|
require.True(t, ok, list)
|
||||||
|
require.Len(t, listResult["tools"], len(curatedOperationIDs))
|
||||||
|
require.Equal(t, float64(toolCatalogTTL.Milliseconds()), listResult["ttlMs"])
|
||||||
|
require.Equal(t, "public", listResult["cacheScope"])
|
||||||
|
|
||||||
|
call := postMCPWithHeaders(t, echoServer, map[string]any{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 3,
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": map[string]any{
|
||||||
|
"_meta": meta,
|
||||||
|
"name": "memo_list_memos",
|
||||||
|
"arguments": map[string]any{},
|
||||||
|
},
|
||||||
|
}, map[string]string{
|
||||||
|
"Mcp-Method": "tools/call",
|
||||||
|
"Mcp-Name": "memo_list_memos",
|
||||||
|
"Authorization": "Bearer test-token",
|
||||||
|
})
|
||||||
|
callResult, ok := call["result"].(map[string]any)
|
||||||
|
require.True(t, ok, call)
|
||||||
|
require.Equal(t, "complete", callResult["resultType"])
|
||||||
|
require.Equal(t, map[string]any{"memos": []any{}}, callResult["structuredContent"])
|
||||||
|
require.Equal(t, "Bearer test-token", forwardedAuthorization)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMCPStatelessRejectsSessionMethods confirms the stateless transport shape
|
||||||
|
// required by 2026-07-28: no GET stream, no DELETE session teardown.
|
||||||
|
func TestMCPStatelessRejectsSessionMethods(t *testing.T) {
|
||||||
|
echoServer := echo.New()
|
||||||
|
service, err := NewMCPService(&profile.Profile{Version: "test-version"}, echoServer)
|
||||||
|
require.NoError(t, err)
|
||||||
|
service.RegisterRoutes(echoServer)
|
||||||
|
|
||||||
|
for _, method := range []string{http.MethodGet, http.MethodDelete} {
|
||||||
|
request := httptest.NewRequest(method, "/mcp", nil)
|
||||||
|
request.Header.Set("Accept", "application/json, text/event-stream")
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
echoServer.ServeHTTP(recorder, request)
|
||||||
|
require.Equal(t, http.StatusMethodNotAllowed, recorder.Code, method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMCPRequestBodyLimitMatchesAPI guards against the SDK's 4 MiB default
|
||||||
|
// overriding the API-wide limit, which would break attachment uploads over MCP.
|
||||||
|
func TestMCPRequestBodyLimitMatchesAPI(t *testing.T) {
|
||||||
|
echoServer := echo.New()
|
||||||
|
var receivedBytes int
|
||||||
|
echoServer.POST("/api/v1/attachments", func(c *echo.Context) error {
|
||||||
|
body := map[string]any{}
|
||||||
|
require.NoError(t, json.NewDecoder(c.Request().Body).Decode(&body))
|
||||||
|
content, _ := body["content"].(string)
|
||||||
|
receivedBytes = len(content)
|
||||||
|
return c.JSON(http.StatusOK, map[string]any{"name": "attachments/1"})
|
||||||
|
})
|
||||||
|
service, err := NewMCPService(&profile.Profile{Version: "test-version"}, echoServer)
|
||||||
|
require.NoError(t, err)
|
||||||
|
service.RegisterRoutes(echoServer)
|
||||||
|
|
||||||
|
content := strings.Repeat("A", 8<<20)
|
||||||
|
response := postMCPWithHeaders(t, echoServer, map[string]any{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": map[string]any{
|
||||||
|
"name": "attachment_create_attachment",
|
||||||
|
"arguments": map[string]any{
|
||||||
|
"body": map[string]any{
|
||||||
|
"filename": "large.bin",
|
||||||
|
"type": "application/octet-stream",
|
||||||
|
"content": content,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil)
|
||||||
|
require.Contains(t, response, "result", response)
|
||||||
|
require.Equal(t, len(content), receivedBytes)
|
||||||
|
|
||||||
|
// Declare an over-limit Content-Length instead of allocating the body.
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader("{}"))
|
||||||
|
request.ContentLength = maxMCPRequestBytes + 1
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("Accept", "application/json, text/event-stream")
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
echoServer.ServeHTTP(recorder, request)
|
||||||
|
require.Equal(t, http.StatusRequestEntityTooLarge, recorder.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func postMCPWithHeaders(t *testing.T, echoServer *echo.Echo, payload map[string]any, headers map[string]string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(data))
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("Accept", "application/json, text/event-stream")
|
||||||
|
if _, hasMeta := payload["params"].(map[string]any)["_meta"]; hasMeta {
|
||||||
|
request.Header.Set("Mcp-Protocol-Version", "2026-07-28")
|
||||||
|
}
|
||||||
|
for key, value := range headers {
|
||||||
|
request.Header.Set(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
echoServer.ServeHTTP(recorder, request)
|
||||||
|
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||||
|
|
||||||
|
var response map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue