From 2d8b3a0c1ed5a545b64eb05518b3c7919fe2b7cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Wed, 2 Sep 2026 19:48:19 +0000 Subject: [PATCH] feat(backend): support dual-era MCP protocol (#3164) --- .agents/skills/pr/SKILL.md | 17 +- apps/backend/cmd/mock-agent/mcp_client.go | 5 +- .../mcp_client_compatibility_test.go | 16 + apps/backend/go.mod | 11 +- apps/backend/go.sum | 16 +- .../agentctl/server/api/mcp_routes_test.go | 19 + .../agentctl/types/streams/mcp_attachment.go | 11 +- .../types/streams/mcp_attachment_test.go | 26 + .../mcp_protocol_observability_test.go | 24 + .../mcp_protocol_compatibility_test.go | 205 +++++++ apps/backend/internal/jira/mcp_client_test.go | 37 ++ .../mcp/server/external_integration_test.go | 18 + .../mcp/server/protocol_compatibility_test.go | 532 ++++++++++++++++++ apps/backend/internal/mcp/server/server.go | 90 ++- .../mcp/server/tool_argument_validation.go | 7 + ...6-07-30-session-owned-mcp-observability.md | 8 +- .../2026-08-30-dual-era-mcp-protocol.md | 83 +++ docs/decisions/INDEX.md | 3 +- docs/plans/mcp-2026-07-28/plan.md | 120 ++++ .../task-01-serve-dual-era-mcp.md | 87 +++ ...02-preserve-stateless-mcp-observability.md | 90 +++ ...-protect-mcp-client-route-compatibility.md | 92 +++ ...task-04-document-negotiated-mcp-support.md | 78 +++ docs/public/automation-and-mcp.md | 24 +- docs/public/feature-status.md | 2 +- docs/specs/INDEX.md | 2 +- docs/specs/agents/README.md | 2 + .../mcp-protocol-compatibility.md | 71 +++ .../mcp-protocol-compatibility.md | 157 ++++++ .../requirements/mcp-session-observability.md | 5 +- .../mcp-session-observability.md | 15 +- 31 files changed, 1827 insertions(+), 46 deletions(-) create mode 100644 apps/backend/cmd/mock-agent/mcp_client_compatibility_test.go create mode 100644 apps/backend/internal/agentctl/server/api/mcp_routes_test.go create mode 100644 apps/backend/internal/agentctl/types/streams/mcp_protocol_observability_test.go create mode 100644 apps/backend/internal/backendapp/mcp_protocol_compatibility_test.go create mode 100644 apps/backend/internal/mcp/server/protocol_compatibility_test.go create mode 100644 docs/decisions/2026-08-30-dual-era-mcp-protocol.md create mode 100644 docs/plans/mcp-2026-07-28/plan.md create mode 100644 docs/plans/mcp-2026-07-28/task-01-serve-dual-era-mcp.md create mode 100644 docs/plans/mcp-2026-07-28/task-02-preserve-stateless-mcp-observability.md create mode 100644 docs/plans/mcp-2026-07-28/task-03-protect-mcp-client-route-compatibility.md create mode 100644 docs/plans/mcp-2026-07-28/task-04-document-negotiated-mcp-support.md create mode 100644 docs/specs/agents/requirements/mcp-protocol-compatibility.md create mode 100644 docs/specs/agents/system-design/mcp-protocol-compatibility.md diff --git a/.agents/skills/pr/SKILL.md b/.agents/skills/pr/SKILL.md index 1243de7d74..bf849777f7 100644 --- a/.agents/skills/pr/SKILL.md +++ b/.agents/skills/pr/SKILL.md @@ -72,16 +72,19 @@ explicitly requests task tracking. otherwise create as ready-for-review. **Architecture and scope gate:** Before running any PR creation command, verify - the authenticated actor's repository permission. On GitHub, query + the authenticated actor's repository permission. Do not treat a user statement + as proof of maintainer status. On GitHub, query `gh api repos/{owner}/{repo}/collaborators/{login}/permission`; `push`, `maintain`, and `admin` permissions are write-authorized, so those actors may open large or architectural PRs directly. On other hosts, use the equivalent - repository permission check. For an actor without write access, require a - linked issue with maintainer discussion before opening a large or architectural - PR. If that issue or discussion is missing, stop and report the blocker. Do not - open a PR to start the discussion. Prefer one logical change and the smallest - practical diff; split unrelated cleanup, refactoring, and feature work into - separate PRs. + repository permission check. When the host confirms write access, the + permission itself satisfies this gate and no linked issue is required solely + for this purpose. If permission cannot be verified, or the actor has no write + access, require a linked issue with maintainer discussion before opening a + large or architectural PR. If that issue or discussion is missing, stop and + report the blocker. Do not create an issue or open a PR solely to start the + discussion. Prefer one logical change and the smallest practical diff; split + unrelated cleanup, refactoring, and feature work into separate PRs. **PR title** must follow Conventional Commits format (see `/commit` for full rules). CI validates via `pr-title.yml` — the PR title becomes the squash-merge commit used for release notes. diff --git a/apps/backend/cmd/mock-agent/mcp_client.go b/apps/backend/cmd/mock-agent/mcp_client.go index c9ed768ff6..bfaabe3fb3 100644 --- a/apps/backend/cmd/mock-agent/mcp_client.go +++ b/apps/backend/cmd/mock-agent/mcp_client.go @@ -16,6 +16,9 @@ var ( mcpClientsMu sync.Mutex ) +// mcpSSEProtocolVersion keeps the mock agent on the legacy SSE protocol. +const mcpSSEProtocolVersion = mcp.ProtocolVersion20241105 + // getMCPClient returns (or creates) an initialized MCP client for the named server. func getMCPClient(serverName string) (*mcpclient.Client, error) { mcpClientsMu.Lock() @@ -41,7 +44,7 @@ func getMCPClient(serverName string) (*mcpclient.Client, error) { } initReq := mcp.InitializeRequest{} - initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION + initReq.Params.ProtocolVersion = mcpSSEProtocolVersion initReq.Params.ClientInfo = mcp.Implementation{ Name: "mock-agent", Version: "1.0", diff --git a/apps/backend/cmd/mock-agent/mcp_client_compatibility_test.go b/apps/backend/cmd/mock-agent/mcp_client_compatibility_test.go new file mode 100644 index 0000000000..97924bd958 --- /dev/null +++ b/apps/backend/cmd/mock-agent/mcp_client_compatibility_test.go @@ -0,0 +1,16 @@ +package main + +import ( + "testing" + + "github.com/mark3labs/mcp-go/mcp" +) + +func TestMCPClientUsesLegacyProtocolForSSE(t *testing.T) { + if mcp.IsModernProtocol(mcpSSEProtocolVersion) { + t.Fatalf("SSE MCP protocol version = %q, want a legacy version", mcpSSEProtocolVersion) + } + if mcpSSEProtocolVersion != mcp.ProtocolVersion20241105 { + t.Fatalf("SSE MCP protocol version = %q, want %q", mcpSSEProtocolVersion, mcp.ProtocolVersion20241105) + } +} diff --git a/apps/backend/go.mod b/apps/backend/go.mod index cfe9a98ace..05ff41057e 100644 --- a/apps/backend/go.mod +++ b/apps/backend/go.mod @@ -7,6 +7,7 @@ require ( github.com/UserExistsError/conpty v0.1.4 github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/coder/acp-go-sdk v0.13.5 + github.com/containerd/errdefs v1.0.0 github.com/creack/pty v1.1.24 github.com/gin-gonic/gin v1.9.1 github.com/godbus/dbus/v5 v5.1.0 @@ -17,8 +18,9 @@ require ( github.com/jackc/pgx/v5 v5.9.2 github.com/jmoiron/sqlx v1.4.0 github.com/kevinburke/ssh_config v1.6.0 - github.com/mark3labs/mcp-go v0.43.2 + github.com/mark3labs/mcp-go v1.0.0-beta.1 github.com/mattn/go-sqlite3 v1.14.33 + github.com/mitchellh/mapstructure v1.5.0 github.com/moby/moby/api v1.55.0 github.com/moby/moby/client v0.5.0 github.com/nats-io/nats.go v1.31.0 @@ -53,13 +55,10 @@ require ( require ( github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/bahlo/generic-list-go v0.2.0 // indirect - github.com/buger/jsonparser v1.1.2 // indirect github.com/bytedance/sonic v1.9.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect - github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/distribution/reference v0.6.0 // indirect @@ -84,10 +83,10 @@ require ( github.com/goccy/go-json v0.10.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/yamux v0.1.2 // indirect - github.com/invopop/jsonschema v0.13.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect @@ -101,7 +100,6 @@ require ( github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-isatty v0.0.19 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/spdystream v0.5.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -122,7 +120,6 @@ require ( github.com/subosito/gotenv v1.4.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect - github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect diff --git a/apps/backend/go.sum b/apps/backend/go.sum index 027bc75282..7d042aece9 100644 --- a/apps/backend/go.sum +++ b/apps/backend/go.sum @@ -48,14 +48,10 @@ github.com/UserExistsError/conpty v0.1.4 h1:+3FhJhiqhyEJa+K5qaK3/w6w+sN3Nh9O9VbJ github.com/UserExistsError/conpty v0.1.4/go.mod h1:PDglKIkX3O/2xVk0MV9a6bCWxRmPVfxqZoTG/5sSd9I= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= -github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= -github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= -github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= @@ -191,6 +187,8 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -227,8 +225,6 @@ github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8 github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= -github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -275,8 +271,8 @@ github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0V github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I= -github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= +github.com/mark3labs/mcp-go v1.0.0-beta.1 h1:hrPZlyUCfykLdAyO8Yr6Dkk0alPbpeLjkeS0BuNBGK8= +github.com/mark3labs/mcp-go v1.0.0-beta.1/go.mod h1:r2fW4o3wsoJ7IMsx1Wuq5xeP8PRGXPDfNveoGAYbb/s= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= @@ -375,8 +371,6 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= -github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= @@ -635,6 +629,8 @@ golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/apps/backend/internal/agentctl/server/api/mcp_routes_test.go b/apps/backend/internal/agentctl/server/api/mcp_routes_test.go new file mode 100644 index 0000000000..49a7b23909 --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/mcp_routes_test.go @@ -0,0 +1,19 @@ +package api + +import "testing" + +func TestMCPRoutesPreserveHTTPAndSSEPaths(t *testing.T) { + s := newTestServerWithMCP(t) + + wants := map[string]bool{ + "GET /sse": true, + "POST /message": true, + "POST /mcp": true, + } + for _, route := range s.router.Routes() { + delete(wants, route.Method+" "+route.Path) + } + if len(wants) != 0 { + t.Fatalf("missing MCP routes: %v", wants) + } +} diff --git a/apps/backend/internal/agentctl/types/streams/mcp_attachment.go b/apps/backend/internal/agentctl/types/streams/mcp_attachment.go index b266d0a8e8..2459dc6f17 100644 --- a/apps/backend/internal/agentctl/types/streams/mcp_attachment.go +++ b/apps/backend/internal/agentctl/types/streams/mcp_attachment.go @@ -87,6 +87,7 @@ const ( MCPAttachmentEvidenceUnavailable MCPAttachmentEvidenceKind = "unavailable" MCPAttachmentEvidenceDelivered MCPAttachmentEvidenceKind = "delivered" MCPAttachmentEvidenceSessionAccepted MCPAttachmentEvidenceKind = "session_accepted" + MCPAttachmentEvidenceProtocolAccepted MCPAttachmentEvidenceKind = "protocol_accepted" MCPAttachmentEvidenceInitializeObserved MCPAttachmentEvidenceKind = "initialize_observed" MCPAttachmentEvidenceToolsListObserved MCPAttachmentEvidenceKind = "tools_list_observed" MCPAttachmentEvidenceToolCallObserved MCPAttachmentEvidenceKind = "tool_call_observed" @@ -395,6 +396,8 @@ func (s *MCPServerAttachment) applyEvidenceStatus(evidence MCPAttachmentEvidence if s.Status == MCPAttachmentStatusUnknown || s.Status == MCPAttachmentStatusFiltered || s.Status == MCPAttachmentStatusUnavailable { s.Status = MCPAttachmentStatusDelivered } + case MCPAttachmentEvidenceProtocolAccepted: + s.applyProtocolAccepted(when) case MCPAttachmentEvidenceInitializeObserved: s.applyInitializeObserved(when) case MCPAttachmentEvidenceToolsListObserved: @@ -410,7 +413,13 @@ func (s *MCPServerAttachment) applyEvidenceStatus(evidence MCPAttachmentEvidence } func (s *MCPServerAttachment) applyInitializeObserved(when time.Time) { - s.ConnectedAt = &when + s.applyProtocolAccepted(when) +} + +func (s *MCPServerAttachment) applyProtocolAccepted(when time.Time) { + if s.ConnectedAt == nil { + s.ConnectedAt = &when + } if s.Status != MCPAttachmentStatusActive && s.Status != MCPAttachmentStatusFailed { s.Status = MCPAttachmentStatusConnected } diff --git a/apps/backend/internal/agentctl/types/streams/mcp_attachment_test.go b/apps/backend/internal/agentctl/types/streams/mcp_attachment_test.go index d7cc8ddd35..4c9b1a1282 100644 --- a/apps/backend/internal/agentctl/types/streams/mcp_attachment_test.go +++ b/apps/backend/internal/agentctl/types/streams/mcp_attachment_test.go @@ -69,6 +69,32 @@ func TestMCPAttachmentHistoryDoesNotRestoreFailedServerFromLateObservations(t *t } } +func TestMCPServerAttachmentConnectedAtIsWriteOnce(t *testing.T) { + history := MCPAttachmentHistory{} + history.StartAttempt(MCPAttachmentAttempt{AttemptID: "attempt-1"}) + first := time.Unix(10, 0).UTC() + second := time.Unix(20, 0).UTC() + + for _, when := range []time.Time{first, second} { + if !history.Apply(MCPAttachmentEvidence{ + AttemptID: "attempt-1", + ServerName: "kandev", + Kind: MCPAttachmentEvidenceProtocolAccepted, + OccurredAt: when, + }) { + t.Fatal("Apply() rejected protocol acceptance evidence") + } + } + + server, ok := history.CurrentServer("kandev") + if !ok || server.ConnectedAt == nil { + t.Fatalf("server = %+v, want connected timestamp", server) + } + if !server.ConnectedAt.Equal(first) { + t.Fatalf("connected_at = %v, want first acceptance %v", server.ConnectedAt, first) + } +} + func TestMCPAttachmentHistoryUpdatesToolCountToZero(t *testing.T) { history := MCPAttachmentHistory{} history.StartAttempt(MCPAttachmentAttempt{AttemptID: "attempt-1"}) diff --git a/apps/backend/internal/agentctl/types/streams/mcp_protocol_observability_test.go b/apps/backend/internal/agentctl/types/streams/mcp_protocol_observability_test.go new file mode 100644 index 0000000000..5c481c16a0 --- /dev/null +++ b/apps/backend/internal/agentctl/types/streams/mcp_protocol_observability_test.go @@ -0,0 +1,24 @@ +package streams + +import "testing" + +func TestMCPAttachmentHistoryTreatsModernProtocolAcceptanceAsConnected(t *testing.T) { + history := MCPAttachmentHistory{} + history.StartAttempt(MCPAttachmentAttempt{AttemptID: "attempt-modern"}) + + if !history.Apply(MCPAttachmentEvidence{ + AttemptID: "attempt-modern", + ServerName: "kandev", + Kind: MCPAttachmentEvidenceProtocolAccepted, + }) { + t.Fatal("Apply() rejected modern protocol evidence") + } + + server, ok := history.CurrentServer("kandev") + if !ok { + t.Fatal("CurrentServer() did not retain kandev") + } + if server.Status != MCPAttachmentStatusConnected { + t.Fatalf("status = %q, want connected", server.Status) + } +} diff --git a/apps/backend/internal/backendapp/mcp_protocol_compatibility_test.go b/apps/backend/internal/backendapp/mcp_protocol_compatibility_test.go new file mode 100644 index 0000000000..32a835ad95 --- /dev/null +++ b/apps/backend/internal/backendapp/mcp_protocol_compatibility_test.go @@ -0,0 +1,205 @@ +package backendapp + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/auth/httpmw" + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/common/logger" + mcpserver "github.com/kandev/kandev/internal/mcp/server" + userstore "github.com/kandev/kandev/internal/user/store" + ws "github.com/kandev/kandev/pkg/websocket" + "github.com/stretchr/testify/require" +) + +const externalModernProtocolVersion = "2026-07-28" + +func TestExternalMCPModernRequestsResolvePATIdentityPerRequest(t *testing.T) { + cfg := &config.Config{} + cfg.Features.Auth = true + authService := newEnabledAuthService(t, cfg) + _, token, err := authService.MintToken(context.Background(), userstore.DefaultUserID, "mcp-compatibility", 0) + require.NoError(t, err) + + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"}) + require.NoError(t, err) + requestIdentities := make(chan authn.Identity, 3) + backendIdentities := make(chan authn.Identity, 2) + dispatcher := ws.NewDispatcher() + dispatcher.RegisterFunc(ws.ActionMCPListPluginTools, func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + identity, ok := authn.IdentityFromContext(ctx) + if !ok { + t.Error("plugin refresh dispatcher context has no authenticated identity") + } else { + backendIdentities <- identity + } + return ws.NewResponse(msg.ID, msg.Action, map[string]any{ + "generation": "compatibility-test", + "revision": 1, + "tools": []any{}, + }) + }) + dispatcher.RegisterFunc(ws.ActionMCPListWorkspaces, func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + identity, ok := authn.IdentityFromContext(ctx) + if !ok { + t.Error("backend dispatcher context has no authenticated identity") + } else { + backendIdentities <- identity + } + return ws.NewResponse(msg.ID, msg.Action, map[string]any{ + "workspaces": []map[string]string{{"id": "ws-1", "name": "Test"}}, + "total": 1, + }) + }) + + srv := mcpserver.NewExternal( + mcpserver.NewExternalDispatcherBackendClient(dispatcher, log), + log, + "", + ) + t.Cleanup(func() { require.NoError(t, srv.Close(context.Background())) }) + + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use( + httpmw.Middleware(authService), + externalMCPAuthMiddleware(authService), + func(c *gin.Context) { + identity, ok := authn.FromGin(c) + if !ok { + t.Error("external MCP route has no authenticated identity") + } else { + requestIdentities <- identity + } + c.Next() + }, + ) + srv.RegisterBackendRoutes(router) + + httpServer := httptest.NewServer(router) + defer httpServer.Close() + + requests := []map[string]any{ + { + "jsonrpc": "2.0", + "id": 1, + "method": "server/discover", + "params": map[string]any{ + "_meta": externalModernRequestMeta(), + }, + }, + { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": map[string]any{ + "_meta": externalModernRequestMeta(), + }, + }, + { + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": map[string]any{ + "name": "list_workspaces_kandev", + "arguments": map[string]any{}, + "_meta": externalModernRequestMeta(), + }, + }, + } + for _, payload := range requests { + response := postExternalModernMCPRequest(t, httpServer.URL+"/mcp", token, payload) + require.Equal(t, http.StatusOK, response.StatusCode, "body = %s", response.Body) + require.Empty(t, response.Header.Get("Mcp-Session-Id"), "modern requests must stay stateless") + message := decodeExternalMCPResponse(t, response.Body) + require.NotContains(t, message, "error", "body = %s", response.Body) + if payload["method"] == "tools/call" { + require.Contains(t, response.Body, "ws-1") + } + } + + for range requests { + identity := receiveExternalMCPIdentity(t, requestIdentities, "route") + require.Equal(t, userstore.DefaultUserID, identity.UserID) + require.NotEmpty(t, identity.TokenID) + require.False(t, identity.Synthetic) + } + for range []int{0, 1} { + identity := receiveExternalMCPIdentity(t, backendIdentities, "dispatcher") + require.Equal(t, userstore.DefaultUserID, identity.UserID) + require.NotEmpty(t, identity.TokenID) + require.False(t, identity.Synthetic) + } +} + +type externalMCPHTTPResponse struct { + *http.Response + Body string +} + +func postExternalModernMCPRequest(t *testing.T, url, token string, payload map[string]any) externalMCPHTTPResponse { + t.Helper() + body, err := json.Marshal(payload) + require.NoError(t, err) + request, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + require.NoError(t, err) + request.Header.Set("Authorization", "Bearer "+token) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json, text/event-stream") + request.Header.Set("Mcp-Protocol-Version", externalModernProtocolVersion) + request.Header.Set("Mcp-Method", payload["method"].(string)) + if payload["method"] == "tools/call" { + request.Header.Set("Mcp-Name", "list_workspaces_kandev") + } + response, err := http.DefaultClient.Do(request) + require.NoError(t, err) + defer func() { _ = response.Body.Close() }() + var responseBody bytes.Buffer + _, err = responseBody.ReadFrom(response.Body) + require.NoError(t, err) + return externalMCPHTTPResponse{Response: response, Body: responseBody.String()} +} + +func externalModernRequestMeta() map[string]any { + return map[string]any{ + "io.modelcontextprotocol/protocolVersion": externalModernProtocolVersion, + "io.modelcontextprotocol/clientInfo": map[string]any{ + "name": "external-compatibility-test", + "version": "1.0.0", + }, + "io.modelcontextprotocol/clientCapabilities": map[string]any{}, + } +} + +func decodeExternalMCPResponse(t *testing.T, body string) map[string]any { + t.Helper() + for _, line := range strings.Split(body, "\n") { + if strings.HasPrefix(line, "data:") { + body = strings.TrimSpace(strings.TrimPrefix(line, "data:")) + break + } + } + var message map[string]any + require.NoError(t, json.Unmarshal([]byte(body), &message), "body = %q", body) + return message +} + +func receiveExternalMCPIdentity(t *testing.T, identities <-chan authn.Identity, source string) authn.Identity { + t.Helper() + select { + case identity := <-identities: + return identity + case <-time.After(time.Second): + t.Fatalf("timed out waiting for %s identity", source) + return authn.Identity{} + } +} diff --git a/apps/backend/internal/jira/mcp_client_test.go b/apps/backend/internal/jira/mcp_client_test.go index 6851f86144..a87805ba96 100644 --- a/apps/backend/internal/jira/mcp_client_test.go +++ b/apps/backend/internal/jira/mcp_client_test.go @@ -51,6 +51,43 @@ const ( toolResult = `{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"ok"}]}}` ) +func TestMCPClientUsesPinnedLegacyProtocolVersion(t *testing.T) { + c := NewMCPClient("token-a", "cloud", "ws", "https://x.atlassian.net") + var requestProtocol string + setMCPTestTransport(c, mcpRoundTripFunc(func(req *http.Request) (*http.Response, error) { + var body struct { + Method string `json:"method"` + Params struct { + ProtocolVersion string `json:"protocolVersion"` + } `json:"params"` + } + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + t.Fatalf("decode MCP request: %v", err) + } + switch body.Method { + case "initialize": + requestProtocol = body.Params.ProtocolVersion + resp := mcpHTTPResponse(http.StatusOK, "application/json", initializeResult) + resp.Header.Set("Mcp-Session-Id", "session-1") + return resp, nil + case "notifications/initialized": + return mcpHTTPResponse(http.StatusAccepted, "application/json", ""), nil + default: + return mcpHTTPResponse(http.StatusOK, "application/json", toolResult), nil + } + })) + + if _, err := c.call(context.Background(), "tools/call", map[string]interface{}{"name": "test", "arguments": map[string]interface{}{}}); err != nil { + t.Fatalf("call: %v", err) + } + if requestProtocol != mcpProtocol { + t.Fatalf("initialize protocol = %q, want %q", requestProtocol, mcpProtocol) + } + if mcpProtocol != mcp.ProtocolVersion20250618 { + t.Fatalf("configured Jira protocol = %q, want %q", mcpProtocol, mcp.ProtocolVersion20250618) + } +} + func TestMCPClientSendsInitializedNotification(t *testing.T) { c := NewMCPClient("token-a", "cloud", "ws", "https://x.atlassian.net") initialized := false diff --git a/apps/backend/internal/mcp/server/external_integration_test.go b/apps/backend/internal/mcp/server/external_integration_test.go index d66d1a644a..88c76d60c5 100644 --- a/apps/backend/internal/mcp/server/external_integration_test.go +++ b/apps/backend/internal/mcp/server/external_integration_test.go @@ -112,6 +112,24 @@ func TestExternalMCP_ToolsCallDispatchesToBackend(t *testing.T) { assert.Contains(t, jsonLine, "ws-1") } +func TestExternalMCPRoutesPreserveHTTPAndSSEPaths(t *testing.T) { + srv := NewExternal(nil, newTestLogger(t), "") + router := gin.New() + srv.RegisterBackendRoutes(router) + + wants := map[string]bool{ + "GET /mcp/sse": true, + "POST /mcp/message": true, + "POST /mcp": true, + } + for _, route := range router.Routes() { + delete(wants, route.Method+" "+route.Path) + } + if len(wants) != 0 { + t.Fatalf("missing external MCP routes: %v", wants) + } +} + type httpResp struct { statusCode int body string diff --git a/apps/backend/internal/mcp/server/protocol_compatibility_test.go b/apps/backend/internal/mcp/server/protocol_compatibility_test.go new file mode 100644 index 0000000000..67c7672c59 --- /dev/null +++ b/apps/backend/internal/mcp/server/protocol_compatibility_test.go @@ -0,0 +1,532 @@ +package mcp + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/agentctl/types/streams" + "github.com/kandev/kandev/internal/common/logger" + "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + modernProtocolVersion = "2026-07-28" + legacyProtocolVersion = "2025-06-18" + protocolVersionHeader = "Mcp-Protocol-Version" + methodHeader = "Mcp-Method" + nameHeader = "Mcp-Name" + protocolEchoToolName = "protocol_" + "echo" +) + +func TestMCPProtocolCompatibility_AutomaticDiscoveryAdvertisesModernVersion(t *testing.T) { + s := newProtocolCompatibilityServer(t) + + response := postMCPRequest(t, s.httpServer, map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "server/discover", + "params": map[string]any{ + "_meta": modernRequestMeta(), + }, + }, map[string]string{ + protocolVersionHeader: modernProtocolVersion, + methodHeader: "server/discover", + }) + + require.Equal(t, http.StatusOK, response.Code) + message := decodeProtocolResponse(t, response) + result, ok := message["result"].(map[string]any) + require.True(t, ok, "discovery response = %v", message) + versions, ok := result["supportedVersions"].([]any) + require.True(t, ok, "supported versions = %v", result) + require.NotEmpty(t, versions) + assert.Equal(t, modernProtocolVersion, versions[0]) + assert.Equal(t, float64(0), result["ttlMs"]) + assert.Equal(t, string(mcp.CacheScopePrivate), result["cacheScope"]) +} + +func TestMCPProtocolCompatibility_DirectModernRequestListsAndCallsTools(t *testing.T) { + s := newProtocolCompatibilityServer(t) + s.mcpServer.AddTool( + mcp.NewTool(protocolEchoToolName, mcp.WithString("text")), + func(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return mcp.NewToolResultText(request.GetString("text", "")), nil + }, + ) + + listResponse := postMCPRequest(t, s.httpServer, map[string]any{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": map[string]any{ + "_meta": modernRequestMeta(), + }, + }, map[string]string{ + protocolVersionHeader: modernProtocolVersion, + methodHeader: "tools/list", + }) + require.Equal(t, http.StatusOK, listResponse.Code) + listMessage := decodeProtocolResponse(t, listResponse) + require.NotContains(t, listMessage, "error") + listResult, ok := listMessage["result"].(map[string]any) + require.True(t, ok, "tools/list response = %v", listMessage) + tools, ok := listResult["tools"].([]any) + require.True(t, ok, "listed tools = %v", listResult["tools"]) + var echoTool map[string]any + for _, candidate := range tools { + if tool, ok := candidate.(map[string]any); ok && tool["name"] == protocolEchoToolName { + echoTool = tool + break + } + } + require.NotNil(t, echoTool, "listed tools = %v", tools) + assert.Equal(t, "object", echoTool["inputSchema"].(map[string]any)["type"]) + assert.Equal(t, float64(0), listResult["ttlMs"]) + assert.Equal(t, string(mcp.CacheScopePrivate), listResult["cacheScope"]) + + callResponse := postMCPRequest(t, s.httpServer, map[string]any{ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": map[string]any{ + "name": protocolEchoToolName, + "arguments": map[string]any{"text": "modern"}, + "_meta": modernRequestMeta(), + }, + }, map[string]string{ + protocolVersionHeader: modernProtocolVersion, + methodHeader: "tools/call", + nameHeader: protocolEchoToolName, + }) + require.Equal(t, http.StatusOK, callResponse.Code) + callMessage := decodeProtocolResponse(t, callResponse) + require.NotContains(t, callMessage, "error") + callResult, ok := callMessage["result"].(map[string]any) + require.True(t, ok, "tools/call response = %v", callMessage) + content, ok := callResult["content"].([]any) + require.True(t, ok, "call result = %v", callResult) + require.Len(t, content, 1) + assert.Equal(t, "modern", content[0].(map[string]any)["text"]) +} + +func TestMCPProtocolCompatibility_LegacyInitializeListAndCallRemainSupported(t *testing.T) { + s := newProtocolCompatibilityServer(t) + s.mcpServer.AddTool( + mcp.NewTool(protocolEchoToolName, mcp.WithString("text")), + func(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return mcp.NewToolResultText(request.GetString("text", "")), nil + }, + ) + + initializeResponse := postMCPRequest(t, s.httpServer, map[string]any{ + "jsonrpc": "2.0", + "id": 10, + "method": "initialize", + "params": map[string]any{ + "protocolVersion": legacyProtocolVersion, + "capabilities": map[string]any{}, + "clientInfo": map[string]any{ + "name": "legacy-test-client", + "version": "1.0.0", + }, + }, + }, map[string]string{ + protocolVersionHeader: legacyProtocolVersion, + }) + require.Equal(t, http.StatusOK, initializeResponse.Code) + require.NotEmpty(t, initializeResponse.Header().Get("Mcp-Session-Id")) + require.NotContains(t, decodeProtocolResponse(t, initializeResponse), "error") + + sessionID := initializeResponse.Header().Get("Mcp-Session-Id") + listResponse := postMCPRequest(t, s.httpServer, map[string]any{ + "jsonrpc": "2.0", + "id": 11, + "method": "tools/list", + "params": map[string]any{}, + }, map[string]string{ + protocolVersionHeader: legacyProtocolVersion, + "Mcp-Session-Id": sessionID, + }) + require.Equal(t, http.StatusOK, listResponse.Code) + require.NotContains(t, decodeProtocolResponse(t, listResponse), "error") + + callResponse := postMCPRequest(t, s.httpServer, map[string]any{ + "jsonrpc": "2.0", + "id": 12, + "method": "tools/call", + "params": map[string]any{ + "name": protocolEchoToolName, + "arguments": map[string]any{"text": "legacy"}, + }, + }, map[string]string{ + protocolVersionHeader: legacyProtocolVersion, + "Mcp-Session-Id": sessionID, + }) + require.Equal(t, http.StatusOK, callResponse.Code) + callMessage := decodeProtocolResponse(t, callResponse) + require.NotContains(t, callMessage, "error") + callResult := callMessage["result"].(map[string]any) + assert.Equal(t, "legacy", callResult["content"].([]any)[0].(map[string]any)["text"]) +} + +func TestMCPProtocolCompatibility_LegacyDeleteKeepsConnectionOwnedEvidence(t *testing.T) { + s := newProtocolCompatibilityServer(t) + s.SetAttachmentAttempt(streams.MCPAttachmentAttempt{AttemptID: "attempt-legacy"}) + events := make(chan streams.MCPAttachmentEvidence, 4) + s.SetAttachmentReporter(func(evidence streams.MCPAttachmentEvidence) { events <- evidence }) + + initializeResponse := postMCPRequest(t, s.httpServer, map[string]any{ + "jsonrpc": "2.0", + "id": 13, + "method": "initialize", + "params": map[string]any{ + "protocolVersion": legacyProtocolVersion, + "capabilities": map[string]any{}, + "clientInfo": map[string]any{"name": "legacy", "version": "1"}, + }, + }, map[string]string{protocolVersionHeader: legacyProtocolVersion}) + require.Equal(t, http.StatusOK, initializeResponse.Code) + sessionID := initializeResponse.Header().Get("Mcp-Session-Id") + require.NotEmpty(t, sessionID) + assert.Equal(t, streams.MCPAttachmentEvidenceInitializeObserved, nextMCPObservation(t, events).Kind) + assert.Equal(t, streams.MCPAttachmentEvidenceSessionAccepted, nextMCPObservation(t, events).Kind) + + request := httptest.NewRequest(http.MethodDelete, "http://localhost/mcp", nil) + request.Header.Set(protocolVersionHeader, legacyProtocolVersion) + request.Header.Set("Mcp-Session-Id", sessionID) + response := httptest.NewRecorder() + s.httpServer.ServeHTTP(response, request) + + require.Equal(t, http.StatusOK, response.Code) + closed := nextMCPObservation(t, events) + assert.Equal(t, streams.MCPAttachmentEvidenceConnectionClosed, closed.Kind) + assert.Equal(t, "attempt-legacy", closed.AttemptID) + assert.NotEmpty(t, closed.ConnectionID) +} + +func TestMCPProtocolCompatibility_ModernAndLegacyRequestsCanRunConcurrently(t *testing.T) { + s := newProtocolCompatibilityServer(t) + + modernStarted := make(chan struct{}) + releaseModern := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseModern) }) } + defer release() + s.mcpServer.GetHooks().AddBeforeListTools(func(ctx context.Context, _ any, _ *mcp.ListToolsRequest) { + if !mcpserver.IsModernRequest(ctx) { + return + } + close(modernStarted) + <-releaseModern + }) + + modernResponse := make(chan *httptest.ResponseRecorder, 1) + legacyResponse := make(chan *httptest.ResponseRecorder, 1) + + go func() { + modernResponse <- postMCPRequest(t, s.httpServer, map[string]any{ + "jsonrpc": "2.0", + "id": 20, + "method": "tools/list", + "params": map[string]any{"_meta": modernRequestMeta()}, + }, map[string]string{ + protocolVersionHeader: modernProtocolVersion, + methodHeader: "tools/list", + }) + }() + select { + case <-modernStarted: + case <-time.After(time.Second): + t.Fatal("modern request did not reach the deterministic overlap barrier") + } + + go func() { + legacyResponse <- postMCPRequest(t, s.httpServer, map[string]any{ + "jsonrpc": "2.0", + "id": 21, + "method": "initialize", + "params": map[string]any{ + "protocolVersion": legacyProtocolVersion, + "capabilities": map[string]any{}, + "clientInfo": map[string]any{"name": "legacy", "version": "1"}, + }, + }, map[string]string{ + protocolVersionHeader: legacyProtocolVersion, + }) + }() + + var legacy *httptest.ResponseRecorder + select { + case legacy = <-legacyResponse: + case <-time.After(time.Second): + t.Fatal("legacy request did not complete while modern request was blocked") + } + release() + + var modern *httptest.ResponseRecorder + select { + case modern = <-modernResponse: + case <-time.After(time.Second): + t.Fatal("modern request did not complete after the overlap barrier was released") + } + require.Equal(t, http.StatusOK, modern.Code) + require.Equal(t, http.StatusOK, legacy.Code) + modernMessage := decodeProtocolResponse(t, modern) + require.NotContains(t, modernMessage, "error") + modernResult, ok := modernMessage["result"].(map[string]any) + require.True(t, ok, "modern tools/list response = %v", modernMessage) + assert.Equal(t, float64(0), modernResult["ttlMs"]) + assert.Equal(t, string(mcp.CacheScopePrivate), modernResult["cacheScope"]) + + legacyMessage := decodeProtocolResponse(t, legacy) + require.NotContains(t, legacyMessage, "error") + legacyResult, ok := legacyMessage["result"].(map[string]any) + require.True(t, ok, "legacy initialize response = %v", legacyMessage) + assert.Equal(t, legacyProtocolVersion, legacyResult["protocolVersion"]) +} + +func TestMCPProtocolCompatibility_ModernEvidenceUsesRequestAttemptSnapshotAcrossRollover(t *testing.T) { + s := newProtocolCompatibilityServer(t) + s.SetAttachmentAttempt(streams.MCPAttachmentAttempt{AttemptID: "attempt-before-rollover"}) + events := make(chan streams.MCPAttachmentEvidence, 4) + s.SetAttachmentReporter(func(evidence streams.MCPAttachmentEvidence) { events <- evidence }) + + modernStarted := make(chan struct{}) + releaseModern := make(chan struct{}) + s.mcpServer.GetHooks().AddBeforeListTools(func(ctx context.Context, _ any, _ *mcp.ListToolsRequest) { + if !mcpserver.IsModernRequest(ctx) { + return + } + close(modernStarted) + <-releaseModern + }) + + responseCh := make(chan *httptest.ResponseRecorder, 1) + go func() { + responseCh <- postMCPRequest(t, s.httpServer, map[string]any{ + "jsonrpc": "2.0", + "id": 22, + "method": "tools/list", + "params": map[string]any{"_meta": modernRequestMeta()}, + }, map[string]string{ + protocolVersionHeader: modernProtocolVersion, + methodHeader: "tools/list", + }) + }() + + select { + case <-modernStarted: + case <-time.After(time.Second): + close(releaseModern) + t.Fatal("modern request did not reach the rollover barrier") + } + s.SetAttachmentAttempt(streams.MCPAttachmentAttempt{AttemptID: "attempt-after-rollover"}) + close(releaseModern) + + response := <-responseCh + require.Equal(t, http.StatusOK, response.Code) + accepted := nextMCPObservation(t, events) + list := nextMCPObservation(t, events) + assert.Equal(t, streams.MCPAttachmentEvidenceProtocolAccepted, accepted.Kind) + assert.Equal(t, "attempt-before-rollover", accepted.AttemptID) + assert.Equal(t, streams.MCPAttachmentEvidenceToolsListObserved, list.Kind) + assert.Equal(t, "attempt-before-rollover", list.AttemptID) +} + +func TestMCPProtocolCompatibility_UnregisteredLegacyRequestDoesNotUseAttemptFallback(t *testing.T) { + s := newProtocolCompatibilityServer(t) + s.SetAttachmentAttempt(streams.MCPAttachmentAttempt{AttemptID: "attempt-current"}) + initializeResponse := postMCPRequest(t, s.httpServer, map[string]any{ + "jsonrpc": "2.0", + "id": 23, + "method": "initialize", + "params": map[string]any{ + "protocolVersion": legacyProtocolVersion, + "capabilities": map[string]any{}, + "clientInfo": map[string]any{"name": "legacy", "version": "1"}, + }, + }, map[string]string{protocolVersionHeader: legacyProtocolVersion}) + require.Equal(t, http.StatusOK, initializeResponse.Code) + sessionID := initializeResponse.Header().Get("Mcp-Session-Id") + require.NotEmpty(t, sessionID) + + events := make(chan streams.MCPAttachmentEvidence, 4) + s.SetAttachmentReporter(func(evidence streams.MCPAttachmentEvidence) { events <- evidence }) + s.attachmentMu.Lock() + delete(s.attachmentAttempts, sessionID) + s.attachmentMu.Unlock() + + response := postMCPRequest(t, s.httpServer, map[string]any{ + "jsonrpc": "2.0", + "id": 24, + "method": "tools/list", + "params": map[string]any{}, + }, map[string]string{ + protocolVersionHeader: legacyProtocolVersion, + "Mcp-Session-Id": sessionID, + }) + require.Equal(t, http.StatusOK, response.Code) + + select { + case evidence := <-events: + t.Fatalf("unregistered legacy request emitted evidence: %+v", evidence) + case <-time.After(50 * time.Millisecond): + } +} + +func TestMCPProtocolCompatibility_InvalidModernMetadataIsNotDowngradedToLegacy(t *testing.T) { + s := newProtocolCompatibilityServer(t) + + response := postMCPRequest(t, s.httpServer, map[string]any{ + "jsonrpc": "2.0", + "id": 30, + "method": "tools/list", + "params": map[string]any{}, + }, map[string]string{ + protocolVersionHeader: modernProtocolVersion, + methodHeader: "tools/list", + }) + + assert.Equal(t, http.StatusBadRequest, response.Code) + message := decodeProtocolResponse(t, response) + errorDetails, ok := message["error"].(map[string]any) + require.True(t, ok, "response = %v", message) + assert.NotEqual(t, float64(-32601), errorDetails["code"], "must not enter legacy method handling") + assert.Empty(t, response.Header().Get("Mcp-Session-Id")) +} + +func TestMCPProtocolCompatibility_ModernEvidenceUsesAttemptWithoutConnectionLifecycle(t *testing.T) { + s := newProtocolCompatibilityServer(t) + s.SetAttachmentAttempt(streams.MCPAttachmentAttempt{AttemptID: "attempt-modern"}) + events := make(chan streams.MCPAttachmentEvidence, 8) + s.SetAttachmentReporter(func(evidence streams.MCPAttachmentEvidence) { events <- evidence }) + s.mcpServer.AddTool( + mcp.NewTool(protocolEchoToolName, mcp.WithString("text")), + func(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return mcp.NewToolResultText(request.GetString("text", "")), nil + }, + ) + + discovery := postMCPRequest(t, s.httpServer, map[string]any{ + "jsonrpc": "2.0", "id": 40, "method": "server/discover", + "params": map[string]any{"_meta": modernRequestMeta()}, + }, map[string]string{ + protocolVersionHeader: modernProtocolVersion, + methodHeader: "server/discover", + }) + require.Equal(t, http.StatusOK, discovery.Code) + accepted := nextMCPObservation(t, events) + assert.Equal(t, streams.MCPAttachmentEvidenceProtocolAccepted, accepted.Kind) + assert.Equal(t, "attempt-modern", accepted.AttemptID) + assert.Empty(t, accepted.ConnectionID) + + list := postMCPRequest(t, s.httpServer, map[string]any{ + "jsonrpc": "2.0", "id": 41, "method": "tools/list", + "params": map[string]any{"_meta": modernRequestMeta()}, + }, map[string]string{ + protocolVersionHeader: modernProtocolVersion, + methodHeader: "tools/list", + }) + require.Equal(t, http.StatusOK, list.Code) + assert.Equal(t, streams.MCPAttachmentEvidenceProtocolAccepted, nextMCPObservation(t, events).Kind) + listEvidence := nextMCPObservation(t, events) + assert.Equal(t, streams.MCPAttachmentEvidenceToolsListObserved, listEvidence.Kind) + assert.Equal(t, listEvidence.ToolCount, len(listEvidence.Tools)) + require.NotEmpty(t, listEvidence.Tools) + var foundEcho bool + for _, tool := range listEvidence.Tools { + if tool.Name == protocolEchoToolName { + foundEcho = true + break + } + } + assert.True(t, foundEcho, "tool evidence = %+v", listEvidence.Tools) + assert.Empty(t, listEvidence.ConnectionID) + + call := postMCPRequest(t, s.httpServer, map[string]any{ + "jsonrpc": "2.0", "id": 42, "method": "tools/call", + "params": map[string]any{ + "name": protocolEchoToolName, "arguments": map[string]any{"text": "modern"}, + "_meta": modernRequestMeta(), + }, + }, map[string]string{ + protocolVersionHeader: modernProtocolVersion, + methodHeader: "tools/call", + nameHeader: protocolEchoToolName, + }) + require.Equal(t, http.StatusOK, call.Code) + assert.Equal(t, streams.MCPAttachmentEvidenceProtocolAccepted, nextMCPObservation(t, events).Kind) + callEvidence := nextMCPObservation(t, events) + assert.Equal(t, streams.MCPAttachmentEvidenceToolCallObserved, callEvidence.Kind) + assert.Empty(t, callEvidence.ConnectionID) + + select { + case evidence := <-events: + t.Fatalf("unexpected extra modern evidence: %+v", evidence) + case <-time.After(50 * time.Millisecond): + } +} + +func newProtocolCompatibilityServer(t *testing.T) *Server { + t.Helper() + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"}) + require.NoError(t, err) + s := New(nil, "protocol-session", "protocol-task", 10005, log, "", false, ModeTask) + t.Cleanup(func() { + _ = s.Close(context.Background()) + }) + return s +} + +func modernRequestMeta() map[string]any { + return map[string]any{ + "io.modelcontextprotocol/protocolVersion": modernProtocolVersion, + "io.modelcontextprotocol/clientInfo": map[string]any{ + "name": "modern-test-client", + "version": "1.0.0", + }, + "io.modelcontextprotocol/clientCapabilities": map[string]any{}, + } +} + +func postMCPRequest(t *testing.T, handler http.Handler, payload map[string]any, headers map[string]string) *httptest.ResponseRecorder { + t.Helper() + body, err := json.Marshal(payload) + require.NoError(t, err) + request := httptest.NewRequest(http.MethodPost, "http://localhost/mcp", bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json, text/event-stream") + for name, value := range headers { + request.Header.Set(name, value) + } + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + return response +} + +func decodeProtocolResponse(t *testing.T, response *httptest.ResponseRecorder) map[string]any { + t.Helper() + var message map[string]any + require.NoError(t, json.Unmarshal(response.Body.Bytes(), &message), "body = %q", response.Body.String()) + return message +} + +func nextMCPObservation(t *testing.T, events <-chan streams.MCPAttachmentEvidence) streams.MCPAttachmentEvidence { + t.Helper() + select { + case evidence := <-events: + return evidence + case <-time.After(time.Second): + t.Fatal("timed out waiting for MCP evidence") + return streams.MCPAttachmentEvidence{} + } +} diff --git a/apps/backend/internal/mcp/server/server.go b/apps/backend/internal/mcp/server/server.go index 4ac6cb24ab..b1f6d70978 100644 --- a/apps/backend/internal/mcp/server/server.go +++ b/apps/backend/internal/mcp/server/server.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "encoding/json" "fmt" + "net/http" "slices" "strings" "sync" @@ -140,6 +141,8 @@ func WithMCPToolNamespacingByServer(enabled bool) ServerOption { } } +type mcpAttachmentAttemptContextKey struct{} + // New creates a new MCP server for agentctl. // port is the HTTP server port used to build the SSE base URL (http://localhost:). // mcpLogFile is an optional file path for MCP debug logging; pass "" to disable. @@ -160,6 +163,7 @@ func New(backend BackendClient, sessionID, taskID string, port int, log *logger. // Create Streamable HTTP server for Codex s.httpServer = server.NewStreamableHTTPServer(s.mcpServer, server.WithEndpointPath("/mcp"), + server.WithHTTPContextFunc(s.mcpHTTPContext), ) return s @@ -179,6 +183,7 @@ func NewWithProfile(backend BackendClient, sessionID, taskID string, port int, l ) s.httpServer = server.NewStreamableHTTPServer(s.mcpServer, server.WithEndpointPath("/mcp"), + server.WithHTTPContextFunc(s.mcpHTTPContext), ) return s } @@ -202,6 +207,7 @@ func NewExternal(backend BackendClient, log *logger.Logger, mcpLogFile string) * // Streamable HTTP transport handler — mounted at /mcp on the backend. s.httpServer = server.NewStreamableHTTPServer(s.mcpServer, server.WithEndpointPath("/mcp"), + server.WithHTTPContextFunc(s.mcpHTTPContext), ) return s @@ -253,29 +259,35 @@ func newServerWithProfile(backend BackendClient, sessionID, taskID string, log * "kandev-mcp", "1.0.0", server.WithToolCapabilities(true), + server.WithCacheHints(0, mcp.CacheScopePrivate), server.WithHooks(hooks), ) + hooks.AddBeforeAny(func(ctx context.Context, _ any, _ mcp.MCPMethod, _ any) { + if server.IsModernRequest(ctx) { + s.observeMCPRequest(ctx, streams.MCPAttachmentEvidenceProtocolAccepted, 0, "") + } + }) hooks.AddOnRegisterSession(func(_ context.Context, session server.ClientSession) { s.registerMCPConnection(session.SessionID()) }) hooks.AddAfterInitialize(func(ctx context.Context, _ any, _ *mcp.InitializeRequest, _ *mcp.InitializeResult) { - s.observeMCPConnection(mcpConnectionID(ctx), streams.MCPAttachmentEvidenceInitializeObserved, 0, "") + s.observeMCPRequest(ctx, streams.MCPAttachmentEvidenceInitializeObserved, 0, "") }) hooks.AddBeforeCallTool(func(_ context.Context, _ any, request *mcp.CallToolRequest) { s.restoreCanonicalToolName(request) }) hooks.AddAfterListTools(func(ctx context.Context, _ any, _ *mcp.ListToolsRequest, result *mcp.ListToolsResult) { s.presentTransportToolNames(result) - s.observeMCPToolsList(mcpConnectionID(ctx), result.Tools) + s.observeMCPToolsListForRequest(ctx, result.Tools) }) hooks.AddBeforeListTools(func(ctx context.Context, _ any, _ *mcp.ListToolsRequest) { s.syncPluginTools(ctx) }) - hooks.AddAfterCallTool(func(ctx context.Context, _ any, _ *mcp.CallToolRequest, _ *mcp.CallToolResult) { - s.observeMCPConnection(mcpConnectionID(ctx), streams.MCPAttachmentEvidenceToolCallObserved, 0, "") + hooks.AddAfterCallTool(func(ctx context.Context, _ any, _ *mcp.CallToolRequest, _ any) { + s.observeMCPRequest(ctx, streams.MCPAttachmentEvidenceToolCallObserved, 0, "") }) hooks.AddOnError(func(ctx context.Context, _ any, _ mcp.MCPMethod, _ any, err error) { - s.observeMCPConnection(mcpConnectionID(ctx), streams.MCPAttachmentEvidenceExplicitError, 0, err.Error()) + s.observeMCPRequest(ctx, streams.MCPAttachmentEvidenceExplicitError, 0, err.Error()) }) hooks.AddOnUnregisterSession(func(_ context.Context, session server.ClientSession) { s.unregisterMCPConnection(session.SessionID()) @@ -356,10 +368,30 @@ func (s *Server) SetAttachmentAttempt(attempt streams.MCPAttachmentAttempt) { s.attachmentAttempt = attempt } +// mcpHTTPContext snapshots the backend-owned attachment attempt before the MCP +// transport dispatches a request. Modern requests are stateless, so their +// hooks must retain the attempt that accepted the request even if a later +// lifecycle operation rolls the server over to a new attempt. +func (s *Server) mcpHTTPContext(ctx context.Context, _ *http.Request) context.Context { + s.attachmentMu.RLock() + attempt := s.attachmentAttempt + s.attachmentMu.RUnlock() + return context.WithValue(ctx, mcpAttachmentAttemptContextKey{}, attempt) +} + +func mcpAttachmentAttemptFromContext(ctx context.Context) (streams.MCPAttachmentAttempt, bool) { + attempt, ok := ctx.Value(mcpAttachmentAttemptContextKey{}).(streams.MCPAttachmentAttempt) + return attempt, ok +} + func (s *Server) observeMCPConnection(connectionID string, kind streams.MCPAttachmentEvidenceKind, toolCount int, summary string) { s.observeMCPConnectionWithTools(connectionID, kind, toolCount, summary, nil) } +func (s *Server) observeMCPRequest(ctx context.Context, kind streams.MCPAttachmentEvidenceKind, toolCount int, summary string) { + s.observeMCPRequestWithTools(ctx, kind, toolCount, summary, nil) +} + func (s *Server) observeMCPToolsList(connectionID string, tools []mcp.Tool) { summaries := make([]streams.MCPToolSummary, 0, len(tools)) for _, tool := range tools { @@ -368,6 +400,14 @@ func (s *Server) observeMCPToolsList(connectionID string, tools []mcp.Tool) { s.observeMCPConnectionWithTools(connectionID, streams.MCPAttachmentEvidenceToolsListObserved, len(tools), "", summaries) } +func (s *Server) observeMCPToolsListForRequest(ctx context.Context, tools []mcp.Tool) { + summaries := make([]streams.MCPToolSummary, 0, len(tools)) + for _, tool := range tools { + summaries = append(summaries, summarizeMCPTool(tool)) + } + s.observeMCPRequestWithTools(ctx, streams.MCPAttachmentEvidenceToolsListObserved, len(tools), "", summaries) +} + func summarizeMCPTool(tool mcp.Tool) streams.MCPToolSummary { summary := streams.MCPToolSummary{ Name: tool.Name, @@ -413,6 +453,46 @@ func (s *Server) observeMCPConnectionWithTools( s.reportMCPConnectionWithTools(reporter, attempt, connectionID, kind, toolCount, summary, tools) } +func (s *Server) observeMCPRequestWithTools( + ctx context.Context, + kind streams.MCPAttachmentEvidenceKind, + toolCount int, + summary string, + tools []streams.MCPToolSummary, +) { + if !server.IsModernRequest(ctx) { + connectionID := mcpConnectionID(ctx) + s.attachmentMu.RLock() + attempt, ok := s.attachmentAttempts[connectionID] + reporter := s.attachmentReporter + if !ok && connectionID != "" && kind == streams.MCPAttachmentEvidenceInitializeObserved { + // Streamable HTTP registers a legacy POST initialize session after + // handling the request. Attribute its initialize evidence to the + // current attempt until the registration hook records the connection. + attempt = s.attachmentAttempt + ok = attempt.AttemptID != "" + } + s.attachmentMu.RUnlock() + if !ok || reporter == nil || attempt.AttemptID == "" { + return + } + s.reportMCPConnectionWithTools(reporter, attempt, connectionID, kind, toolCount, summary, tools) + return + } + + attempt, hasSnapshot := mcpAttachmentAttemptFromContext(ctx) + s.attachmentMu.RLock() + if !hasSnapshot { + attempt = s.attachmentAttempt + } + reporter := s.attachmentReporter + s.attachmentMu.RUnlock() + if reporter == nil || attempt.AttemptID == "" { + return + } + s.reportMCPConnectionWithTools(reporter, attempt, "", kind, toolCount, summary, tools) +} + func (s *Server) registerMCPConnection(connectionID string) { s.attachmentMu.Lock() attempt := s.attachmentAttempt diff --git a/apps/backend/internal/mcp/server/tool_argument_validation.go b/apps/backend/internal/mcp/server/tool_argument_validation.go index 160f1fe5e0..dd5cc4ee66 100644 --- a/apps/backend/internal/mcp/server/tool_argument_validation.go +++ b/apps/backend/internal/mcp/server/tool_argument_validation.go @@ -70,6 +70,13 @@ func compileToolArgumentSchema(toolName string, tool mcp.Tool) (*jsonschema.Sche func (s *Server) validateToolArguments(toolName string, req mcp.CallToolRequest) (mcp.CallToolRequest, error) { arguments := req.GetRawArguments() + if raw, ok := arguments.(json.RawMessage); ok { + var decoded any + if err := json.Unmarshal(raw, &decoded); err != nil { + return req, fmt.Errorf("invalid arguments for %s: failed to decode arguments", toolName) + } + arguments = decoded + } if arguments == nil { arguments = map[string]any{} } diff --git a/docs/decisions/2026-07-30-session-owned-mcp-observability.md b/docs/decisions/2026-07-30-session-owned-mcp-observability.md index 6bd3c5cf71..1a2bec97a7 100644 --- a/docs/decisions/2026-07-30-session-owned-mcp-observability.md +++ b/docs/decisions/2026-07-30-session-owned-mcp-observability.md @@ -1,6 +1,6 @@ # ADR-2026-07-30-session-owned-mcp-observability: Keep MCP Attachment Evidence Session Owned -**Status:** accepted +**Status:** accepted (amended by 2026-08-30-dual-era-mcp-protocol) **Date:** 2026-07-30 **Area:** backend, frontend, protocol, security @@ -38,8 +38,10 @@ MCP attachment observability is owned by the Kandev session and execution: - Agentctl's instance identity is the authoritative execution generation for local MCP observations. The server injects task and session ownership from that instance; agent-supplied IDs never select the report. -- Each MCP transport client gets an opaque connection ID. Shared connections - remain shared; Kandev does not infer internal subagent identity. +- Each legacy MCP transport client gets an opaque connection ID. Shared + connections remain shared; Kandev does not infer internal subagent identity. + Modern stateless requests instead attach evidence to the current attachment + attempt without a fabricated connection ID or per-request disconnect. - Configuration resolution, filtering, delivery, session acceptance, initialize, `tools/list`, tool use, explicit errors, disconnect, and supersession are separate evidence events. The UI displays the strongest diff --git a/docs/decisions/2026-08-30-dual-era-mcp-protocol.md b/docs/decisions/2026-08-30-dual-era-mcp-protocol.md new file mode 100644 index 0000000000..4aad8b51fb --- /dev/null +++ b/docs/decisions/2026-08-30-dual-era-mcp-protocol.md @@ -0,0 +1,83 @@ +# ADR-2026-08-30-dual-era-mcp-protocol: Use One Dual-Era MCP Endpoint + +**Status:** accepted +**Date:** 2026-08-30 +**Area:** backend, agentctl, protocol, security + +## Context + +Kandev currently uses `github.com/mark3labs/mcp-go v0.43.2`. It supports MCP +through `2025-06-18`. MCP `2026-07-28` adds an optional discovery exchange and +new request metadata. Modern requests are stateless, while Kandev must still +serve clients that use legacy initialize sessions or SSE. + +All Kandev MCP tool profiles share one server package. They include task, +task-title, configuration, Office, automation, and external profiles. A second +endpoint or a hand-written protocol fork would duplicate this shared security +and tool-selection boundary. + +The mark3labs v1 line supports modern and legacy MCP on one handler. The +official `github.com/modelcontextprotocol/go-sdk` also supports both eras, but +moving to it would require a larger rewrite of handlers, hooks, transports, and +tests. The mark3labs v1 beta has known gaps in the optional MCP Tasks extension +and authorization hardening. Neither gap is required for Kandev's current tool +server change. + +## Decision + +Kandev will serve modern and legacy MCP from the existing `/mcp` routes. + +- Upgrade `github.com/mark3labs/mcp-go` to a v1 release that supports MCP + `2026-07-28`. Start with `v1.0.0-beta.1`, unless a newer compatible v1 + release exists when implementation starts. +- Let the SDK select the protocol era for each request. Do not add `/mcp/v2` + or a protocol feature flag. +- Support automatic discovery and valid direct modern requests. +- Preserve the legacy initialize path and legacy SSE routes. +- Keep all Kandev tool, profile, identity, and authorization behavior + independent from the selected era. +- Keep configured third-party MCP servers as direct agent-to-server + connections. Kandev does not negotiate on their behalf. +- Treat modern observations as attachment-attempt evidence. Do not fabricate a + connection ID or a disconnect for each stateless HTTP request. +- Do not add the MCP Tasks extension or change OAuth and authorization flows in + this work. + +## Consequences + +Modern clients can select `2026-07-28` without a new Kandev route. Legacy +clients keep working on the same server and through SSE. Kandev maintains one +tool and authorization implementation. + +The implementation takes a prerelease dependency until mark3labs publishes a +stable v1. The focused compatibility suite must therefore protect discovery, +direct modern calls, legacy initialize calls, SSE, authorization, hooks, and +dynamic tools. If no acceptable mark3labs v1 release exists at implementation +time, work stops at dependency selection instead of adding a local protocol +fork. + +Modern attachment evidence is less connection-specific than legacy evidence. +The status can still prove protocol acceptance, tool loading, and tool use for +the current attempt. + +This decision amends the connection-only wording in +[ADR-2026-07-30-session-owned-mcp-observability](2026-07-30-session-owned-mcp-observability.md). + +## Alternatives Considered + +- **Add `/mcp/v2`.** Rejected because the client and server can negotiate on + the existing endpoint. Another route would duplicate configuration and + increase migration work. +- **Replace mark3labs with the official Go SDK now.** Rejected because it + causes a broad server rewrite without a compatibility benefit for this + change. Kandev can reconsider it when another requirement needs the official + SDK's distinct features. +- **Wait and keep legacy-only support.** Rejected because a compatible upgrade + path exists and modern clients need a Kandev server that can negotiate the + new protocol. +- **Implement the modern wire contract inside Kandev.** Rejected because + protocol parsing, validation, and negotiation belong in a maintained MCP + SDK. +- **Gate modern MCP with a runtime feature flag.** Rejected because protocol + selection is per request and does not replace the legacy path. The + compatibility suite is the safer release control. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index da29c09fd6..1ec4d158c4 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -103,7 +103,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-07-29-quarantine-retention-override | [Make Quarantine Retention Overridable but Visible](2026-07-29-quarantine-retention-override.md) | accepted | backend, frontend | 2026-07-29 | | 2026-07-29-interactive-accent-surface-semantics | [Separate Brand Accent from Interactive Surface Fills](2026-07-29-interactive-accent-surface-semantics.md) | accepted | frontend | 2026-07-29 | | 2026-07-30-embedded-editor-executor-capabilities | [Derive Embedded Editor Availability from the Active Executor](2026-07-30-embedded-editor-executor-capabilities.md) | accepted | backend, frontend, protocol | 2026-07-30 | -| 2026-07-30-session-owned-mcp-observability | [Keep MCP Attachment Evidence Session Owned](2026-07-30-session-owned-mcp-observability.md) | accepted | backend, frontend, protocol, security | 2026-07-30 | +| 2026-07-30-session-owned-mcp-observability | [Keep MCP Attachment Evidence Session Owned](2026-07-30-session-owned-mcp-observability.md) | accepted (amended by 2026-08-30-dual-era-mcp-protocol) | backend, frontend, protocol, security | 2026-07-30 | | 2026-07-30-file-backed-diagnostic-bundles | [File-backed diagnostic bundles](2026-07-30-file-backed-diagnostic-bundles.md) | accepted (amended 2026-08-23; backend file retention amended by 2026-08-22) | backend, frontend, infra, protocol, workflow | 2026-07-30 | | 2026-07-30-runtime-task-state-before-running-event | [Publish Task State Before Running Session State](2026-07-30-runtime-task-state-before-running-event.md) | accepted | backend, frontend, protocol, workflow | 2026-07-30 | | 2026-07-31-authenticated-plugin-actions | [Authenticated Plugin Actions](2026-07-31-authenticated-plugin-actions.md) | accepted | backend, frontend, protocol, security | 2026-07-31 | @@ -221,6 +221,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-08-30-context-reset-quiesces-active-turn | [Quiesce Active Turns Before Context Reset](2026-08-30-context-reset-quiesces-active-turn.md) | accepted | backend, workflow | 2026-08-30 | | 2026-08-30-empty-remote-bootstrap-publication | [Keep Empty Remote Bootstrap Local Until Publication](2026-08-30-empty-remote-bootstrap-publication.md) | accepted | backend, frontend, protocol, security | 2026-08-30 | | 2026-08-30-bound-untracked-dependency-enumeration | [Bound Untracked Dependency Enumeration](2026-08-30-bound-untracked-dependency-enumeration.md) | accepted | backend | 2026-08-30 | +| 2026-08-30-dual-era-mcp-protocol | [Use One Dual-Era MCP Endpoint](2026-08-30-dual-era-mcp-protocol.md) | accepted | backend, agentctl, protocol, security | 2026-08-30 | | 2026-08-30-e2e-browser-cache | [Cache host-runner E2E browser provisioning](2026-08-30-e2e-browser-cache.md) | accepted | infra, workflow | 2026-08-30 | | 2026-08-31-generic-plugin-host-boundary | [Generic plugin Host boundary](2026-08-31-generic-plugin-host-boundary.md) | proposed | backend, frontend, protocol, security, workflow | 2026-08-31 | | 2026-08-30-environment-owned-git-status | [Persist Current Git Status with the Task Environment](2026-08-30-environment-owned-git-status.md) | accepted | backend, frontend, protocol | 2026-08-31 | diff --git a/docs/plans/mcp-2026-07-28/plan.md b/docs/plans/mcp-2026-07-28/plan.md new file mode 100644 index 0000000000..7922d050a6 --- /dev/null +++ b/docs/plans/mcp-2026-07-28/plan.md @@ -0,0 +1,120 @@ +--- +spec: docs/specs/agents/requirements/mcp-protocol-compatibility.md +design: docs/specs/agents/system-design/mcp-protocol-compatibility.md +decision: docs/decisions/2026-08-30-dual-era-mcp-protocol.md +created: 2026-08-30 +status: complete +--- + +# Implementation Plan: MCP 2026-07-28 Compatibility + +## Objective + +Serve MCP `2026-07-28` and legacy MCP from each existing Kandev endpoint. Let +clients select their supported era without changing tool behavior, +authorization, third-party server delivery, or legacy SSE support. + +## Current state + +- Kandev uses `github.com/mark3labs/mcp-go v1.0.0-beta.1` for modern `2026-07-28` and legacy protocol support. +- The dependency supports legacy `2025-11-25`, `2025-06-18`, `2025-03-26`, and `2024-11-05` negotiation. +- All Kandev tool profiles share `apps/backend/internal/mcp/server`. +- Agentctl and passthrough injection already prefer `/mcp` and retain SSE. +- The external MCP endpoint authenticates each request with a personal access + token. +- The Jira MCP client pins `2025-06-18`. The mock agent uses legacy `2024-11-05` over SSE. +- Third-party configured MCP servers connect directly to agents. + +## Decision summary + +Use one negotiated `/mcp` endpoint. Keep mark3labs and upgrade to its v1 line. +Retain legacy initialize and SSE. Do not add a runtime feature flag, a second +endpoint, the MCP Tasks extension, or a Kandev proxy for third-party servers. + +The current implementation target is `mcp-go v1.0.0-beta.1`. Task 01 must +check for a newer compatible v1 release before it changes the dependency. If +no acceptable v1 release exists, stop and revise the dependency decision. Do +not implement the modern wire contract inside Kandev. + +## Work packages + +| Order | Work order | Result | Depends on | +| --- | --- | --- | --- | +| 1 | [Serve dual-era MCP](task-01-serve-dual-era-mcp.md) | The shared server negotiates modern and legacy requests. | None | +| 2 | [Preserve stateless MCP observability](task-02-preserve-stateless-mcp-observability.md) | Modern evidence is attempt-owned and legacy evidence stays connection-owned. | Task 01 | +| 2 | [Protect MCP client and route compatibility](task-03-protect-mcp-client-route-compatibility.md) | Existing clients, routes, auth, and passthrough behavior have regression coverage. | Task 01 | +| 3 | [Document negotiated MCP support](task-04-document-negotiated-mcp-support.md) | Public docs explain negotiation, compatibility, and exclusions. | Tasks 02 and 03 | + +Tasks 02 and 03 can run in parallel if their owners do not edit the same MCP +server test file. Task 02 owns observability hooks and evidence. Task 03 owns +route, client, and injection regression tests. + +## Compatibility matrix + +The completed change must prove these cases: + +| Case | Expected result | +| --- | --- | +| Modern automatic client to Kandev | Discovery selects `2026-07-28`; list and call succeed. | +| Modern direct client to Kandev | A valid request succeeds without discovery. | +| Legacy HTTP client to Kandev | Initialize, list, and call keep legacy behavior. | +| Legacy SSE client to Kandev | Initialize, list, and call keep working. | +| Modern and legacy clients at the same time | Each request uses its selected era. | +| Modern automatic client to a legacy-only fixture | The client selects a shared legacy version or uses its documented fallback. | +| Authenticated external modern requests | Each stateless request resolves the same caller and tool permissions. | +| Dynamic plugin tools | Modern tool-list metadata prevents shared or stale caching. | + +## Requirement and acceptance mapping + +| Requirement or criterion | Work orders | +| --- | --- | +| `REQ-AGENTS-MCP-PROTOCOL-001` | 01, 02, 03, 04 | +| `AC-AGENTS-MCP-PROTOCOL-001.1` through `.4` | 01 | +| `AC-AGENTS-MCP-PROTOCOL-001.5` | 01, 03 | +| `AC-AGENTS-MCP-PROTOCOL-001.6` and `.7` | 03 | +| `AC-AGENTS-MCP-PROTOCOL-001.8` | 01 | +| `AC-PLATFORM-MCP-SESSION-OBSERVABILITY-001.4` and `.9` | 02 | + +## Integrated verification + +After all work orders finish, run: + +```bash +make -C apps/backend test +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +python3 scripts/lint-spec-files.test.py +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +## Risks and controls + +- **Prerelease SDK:** Focused protocol tests cover discovery, direct modern + requests, legacy HTTP, SSE, hooks, and concurrent eras. Implementation must + prefer a compatible stable v1 if one exists. +- **SDK API changes:** The v1 hook API returns a general tool result. Task 01 + adapts the hook without weakening result or error handling. +- **Stateless authorization:** External MCP resolves auth on each request. + Task 03 proves that no session-local identity is required. +- **False observability:** Task 02 does not create a connection ID or close + event for modern HTTP requests. +- **Dynamic tool caching:** The modern server advertises private, zero-lifetime + tool-list caching so tool permissions and plugin tools stay current. + +## Exclusions + +- MCP Tasks extension support. +- New MCP OAuth or authorization behavior. +- Migration to `github.com/modelcontextprotocol/go-sdk`. +- A `/mcp/v2` route or runtime feature flag. +- Protocol proxying for third-party MCP servers. +- Modernizing the Jira MCP client or mock-agent SSE client. + +## Result + +- Implemented one negotiated `/mcp` endpoint for modern `2026-07-28` and legacy initialize clients, while retaining legacy SSE routes. +- Added attempt-owned modern observability, connection-owned legacy observability, per-request external PAT coverage, route regressions, cache-hint coverage, and the legacy mock-agent SSE pin. +- Updated the public MCP guide and feature-status boundary with protocol versions, routes, authentication, direct third-party delivery, and exclusions. +- Integrated verification passed: `make -C apps/backend test` with the task-workspace bootstrap configuration unset and a temporary home directory, 363 MCP server race tests, 61 public-doc tests, 41 published-doc validation checks, 20 specification-lint tests, full specification lint, and `git diff --check`. An ambient run first failed because the task process forced `/root/.kandev/config.yaml` into config and launcher discovery; the clean rerun passed without code changes. +- The implementation uses `github.com/mark3labs/mcp-go v1.0.0-beta.1`; the prerelease SDK remains the primary release risk. diff --git a/docs/plans/mcp-2026-07-28/task-01-serve-dual-era-mcp.md b/docs/plans/mcp-2026-07-28/task-01-serve-dual-era-mcp.md new file mode 100644 index 0000000000..29e5050d85 --- /dev/null +++ b/docs/plans/mcp-2026-07-28/task-01-serve-dual-era-mcp.md @@ -0,0 +1,87 @@ +--- +id: "01-serve-dual-era-mcp" +title: "Serve dual-era MCP" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/agents/requirements/mcp-protocol-compatibility.md" +--- + +# Task 01: Serve Dual-Era MCP + +## Inputs + +- `REQ-AGENTS-MCP-PROTOCOL-001` and acceptance criteria `.1` through `.5` + and `.8`. +- `docs/specs/agents/system-design/mcp-protocol-compatibility.md` sections + `Shared server`, `Protocol selection`, and `Failure behavior`. +- `docs/decisions/2026-08-30-dual-era-mcp-protocol.md`. +- The current shared server in `apps/backend/internal/mcp/server/server.go`. + +## Change + +1. Check the available mark3labs v1 releases and their protocol support. + Select the newest compatible v1 release. Use `v1.0.0-beta.1` if no newer + compatible release exists. +2. Write failing server tests for automatic discovery, direct modern + requests, legacy initialize, concurrent eras, and invalid modern metadata. +3. Upgrade `github.com/mark3labs/mcp-go` and tidy the backend module. +4. Adapt the shared server to the v1 constructor, transport, and hook APIs. + The current after-call hook expects `*mcp.CallToolResult`; v1 supplies a + general result value. +5. Keep one Streamable HTTP handler for every tool profile. Preserve tool + filtering, handlers, results, annotations, and dynamic plugin tools. +6. Keep modern tool-list cache metadata private with a zero lifetime. + +## Acceptance + +- A modern automatic client selects `2026-07-28` on `/mcp` and can list and + call a representative tool. +- A valid direct modern request works without `server/discover`. +- A legacy client can initialize, list, and call a tool on the same handler. +- Modern and legacy requests work concurrently without shared protocol state. +- Invalid modern metadata returns a protocol error and does not enter the + legacy path. +- Every Kandev tool profile builds with the same dual-era server. +- Tool-list cache metadata is private and has no positive lifetime. + +## Verification + +```bash +cd apps/backend +go mod tidy +go test -race ./internal/mcp/server +git diff --check -- go.mod go.sum internal/mcp/server +``` + +Repeat the tests if later edits change the module files or shared server. + +## Files likely touched + +- `apps/backend/go.mod` +- `apps/backend/go.sum` +- `apps/backend/internal/mcp/server/server.go` +- `apps/backend/internal/mcp/server/server_test.go` +- New focused protocol fixture files under + `apps/backend/internal/mcp/server/`, if needed. + +## Dependencies + +None. This task establishes the SDK and server API used by later work. + +## Output contract + +Report the selected SDK version, protocol cases covered, API adaptations, +files changed, commands run, failures, and remaining prerelease risks. Update +this work order and `plan.md` with the result. + +## Result + +- Selected `github.com/mark3labs/mcp-go v1.0.0-beta.1`; no newer compatible v1 release was available when checked. +- Added tests for modern discovery, direct modern list and call, legacy initialize/list/call, concurrent eras, invalid modern metadata, and modern cache hints. +- Kept one shared Streamable HTTP handler and legacy SSE handlers for every Kandev MCP profile. +- Added private, zero-lifetime cache hints for modern list and discovery results. +- Adapted the v1 general-result call hook and decoded v1 raw JSON tool arguments before schema validation. +- Verification passed: `go test -race ./internal/mcp/server` with the full package suite, plus `git diff --check` for the module and MCP server files. +- Remaining risk: the selected SDK is a beta release. diff --git a/docs/plans/mcp-2026-07-28/task-02-preserve-stateless-mcp-observability.md b/docs/plans/mcp-2026-07-28/task-02-preserve-stateless-mcp-observability.md new file mode 100644 index 0000000000..9a4dbcbf46 --- /dev/null +++ b/docs/plans/mcp-2026-07-28/task-02-preserve-stateless-mcp-observability.md @@ -0,0 +1,90 @@ +--- +id: "02-preserve-stateless-mcp-observability" +title: "Preserve stateless MCP observability" +status: done +wave: 2 +depends_on: ["01-serve-dual-era-mcp"] +plan: "plan.md" +spec: "../../specs/platform/requirements/mcp-session-observability.md" +--- + +# Task 02: Preserve Stateless MCP Observability + +## Inputs + +- `AC-PLATFORM-MCP-SESSION-OBSERVABILITY-001.4` and `.9`. +- `docs/specs/agents/system-design/mcp-protocol-compatibility.md` section + `Attachment observability`. +- `docs/specs/platform/system-design/mcp-session-observability.md` sections + `Evidence model`, `Session, execution, and attempt ownership`, and + `Release-safe attachment report`. +- The amendment in + `docs/decisions/2026-08-30-dual-era-mcp-protocol.md`. + +## Change + +1. Write failing evidence and hook tests for modern discovery, direct modern + `tools/list`, modern tool calls, and legacy initialize and close behavior. +2. Add `protocol_accepted` to the attachment evidence kinds. Keep + `initialize_observed` readable for legacy and stored history. +3. Record modern protocol acceptance against the current attachment attempt. +4. Keep modern `tools/list` catalog and tool-call evidence on that attempt. +5. Do not create an MCP connection ID for modern requests. Do not record + `connection_closed` when a stateless HTTP request ends. +6. Preserve the current legacy connection ID, initialize, list, call, and + close evidence. +7. Confirm that stored reports with only `initialize_observed` still rehydrate + and derive the same status. + +## Acceptance + +- Modern discovery or another accepted modern request records + `protocol_accepted` for the current attachment attempt. +- A direct modern `tools/list` makes the Kandev attachment Active and stores + the safe tool catalog without a discovery event. +- Modern evidence contains no fabricated connection ID or per-request close + event. +- Legacy initialize and connection close keep their existing evidence and + status behavior. +- Old attachment histories that contain `initialize_observed` remain valid. + +## Verification + +```bash +cd apps/backend +go test -race ./internal/agentctl/types/streams ./internal/mcp/server ./internal/agent/runtime/lifecycle +git diff --check -- internal/agentctl/types/streams internal/mcp/server internal/agent/runtime/lifecycle +``` + +## Files likely touched + +- `apps/backend/internal/agentctl/types/streams/mcp_attachment.go` +- `apps/backend/internal/agentctl/types/streams/mcp_attachment_test.go` +- `apps/backend/internal/mcp/server/server.go` +- `apps/backend/internal/mcp/server/server_test.go` +- `apps/backend/internal/agent/runtime/lifecycle/mcp_attachment_snapshot_test.go` + +## Dependencies + +Task 01. Its SDK hooks define the available request and session context. + +## Parallelism + +Parallel-safe with Task 03. This task owns evidence types and MCP hook tests. + +## Output contract + +Report the modern and legacy evidence transitions, compatibility behavior for +stored history, files changed, commands run, failures, and risks. Update this +work order and `plan.md` with the result. + +## Result + +- Added `protocol_accepted` evidence and mapped it to the existing Connected status. +- Modern discovery, tools/list, and tools/call evidence uses the current attachment attempt and an empty connection ID. +- Modern requests do not create connection-close evidence. +- Legacy initialize evidence remains connection-owned. The v1 transport registers a legacy initialize session after handling the request, so initialization falls back to the current attempt until registration records the connection. +- Modern request hooks snapshot the attachment attempt before dispatch, so rollover during an in-flight request cannot move its evidence to a successor attempt. +- Legacy fallback is limited to initialize evidence, and `ConnectedAt` is retained from the first protocol acceptance. +- Legacy DELETE cleanup still emits connection-close evidence, and existing initialize history remains valid. +- Verification passed: focused rollover, fallback-scope, and write-once tests, followed by `go test -race ./internal/agentctl/types/streams ./internal/mcp/server ./internal/agent/runtime/lifecycle` and the scoped diff check. diff --git a/docs/plans/mcp-2026-07-28/task-03-protect-mcp-client-route-compatibility.md b/docs/plans/mcp-2026-07-28/task-03-protect-mcp-client-route-compatibility.md new file mode 100644 index 0000000000..609a820510 --- /dev/null +++ b/docs/plans/mcp-2026-07-28/task-03-protect-mcp-client-route-compatibility.md @@ -0,0 +1,92 @@ +--- +id: "03-protect-mcp-client-route-compatibility" +title: "Protect MCP client and route compatibility" +status: done +wave: 2 +depends_on: ["01-serve-dual-era-mcp"] +plan: "plan.md" +spec: "../../specs/agents/requirements/mcp-protocol-compatibility.md" +--- + +# Task 03: Protect MCP Client and Route Compatibility + +## Inputs + +- `AC-AGENTS-MCP-PROTOCOL-001.5` through `.7`. +- `docs/specs/agents/system-design/mcp-protocol-compatibility.md` sections + `Routes and profiles`, `Application and security boundaries`, and + `Compatibility boundaries`. +- Existing route and client code in agentctl, backendapp, lifecycle, Jira, and + the mock agent. + +## Change + +1. Write route tests that prove agentctl still exposes `/mcp`, `/sse`, and + `/message`, and the external backend still exposes its three existing MCP + routes. +2. Extend external endpoint integration tests with modern stateless requests. + Prove that every request resolves bearer-token identity and tool + permissions without protocol-session state. +3. Keep agent and passthrough injection pointed at the existing HTTP `/mcp` + URL with SSE fallback. Add regression assertions where current tests do not + cover that ordering. +4. Keep the Jira client on its explicit `2025-06-18` request. Adapt it only as + required by the SDK compile-time API. +5. Keep the mock agent on legacy SSE. Adapt it only as required by the SDK + compile-time API. +6. Prove that configured third-party server definitions remain unchanged when + Kandev passes them to an agent. + +## Acceptance + +- Existing HTTP and SSE route paths do not change. +- An authenticated external modern client can discover, list, and call tools. + Each stateless request enforces the same caller permissions. +- Agentctl and passthrough configurations still prefer `/mcp` and retain SSE + compatibility where the client supports it. +- The Jira client still requests `2025-06-18`. +- The mock agent still uses SSE. +- Kandev does not add protocol metadata to a third-party server definition. + +## Verification + +```bash +cd apps/backend +go test -race ./internal/agentctl/server/api ./internal/backendapp ./internal/agent/runtime/lifecycle ./internal/jira ./cmd/mock-agent +git diff --check -- internal/agentctl/server/api internal/backendapp internal/agent/runtime/lifecycle internal/jira cmd/mock-agent +``` + +## Files likely touched + +- `apps/backend/internal/agentctl/server/api/agent_test.go` +- `apps/backend/internal/backendapp/helpers_test.go` +- External MCP route or integration tests under + `apps/backend/internal/backendapp/`. +- `apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_test.go` +- `apps/backend/internal/jira/mcp_client.go` +- `apps/backend/internal/jira/mcp_client_test.go` +- `apps/backend/cmd/mock-agent/mcp_client.go` +- `apps/backend/cmd/mock-agent/mock_agent_test.go` + +## Dependencies + +Task 01. These tests run against its dual-era server and SDK API. + +## Parallelism + +Parallel-safe with Task 02. This task does not own evidence types or MCP server +hook tests. + +## Output contract + +Report the routes, auth cases, client pins, injection cases, files changed, +commands run, failures, and compatibility risks. Update this work order and +`plan.md` with the result. + +## Result + +- Added route assertions for agentctl `/mcp`, `/sse`, and `/message`, and external `/mcp`, `/mcp/sse`, and `/mcp/message`. +- Added an authenticated external modern-client test for discovery, tools/list, and tools/call. It sends a PAT on every request and checks the same identity at the route and dispatcher. +- Preserved existing HTTP-first `/mcp` injection, SSE fallback, and third-party MCP definitions. Existing injection and passthrough tests cover these paths. +- Kept Jira at `2025-06-18` and pinned the mock agent's SSE initialize request to legacy `2024-11-05` after the SDK latest version changed to modern. +- Verification passed: `go test -race ./internal/agentctl/server/api ./internal/backendapp ./internal/agent/runtime/lifecycle ./internal/jira ./cmd/mock-agent` with 3,632 tests, plus the scoped diff check. diff --git a/docs/plans/mcp-2026-07-28/task-04-document-negotiated-mcp-support.md b/docs/plans/mcp-2026-07-28/task-04-document-negotiated-mcp-support.md new file mode 100644 index 0000000000..adbb9ed3e2 --- /dev/null +++ b/docs/plans/mcp-2026-07-28/task-04-document-negotiated-mcp-support.md @@ -0,0 +1,78 @@ +--- +id: "04-document-negotiated-mcp-support" +title: "Document negotiated MCP support" +status: done +wave: 3 +depends_on: + - "02-preserve-stateless-mcp-observability" + - "03-protect-mcp-client-route-compatibility" +plan: "plan.md" +spec: "../../specs/agents/requirements/mcp-protocol-compatibility.md" +--- + +# Task 04: Document Negotiated MCP Support + +## Inputs + +- The completed behavior from Tasks 01 through 03. +- `REQ-AGENTS-MCP-PROTOCOL-001`. +- `docs/public/automation-and-mcp.md`. +- `docs/public/feature-status.md`. +- The `docs-maintainer` and `simple-english` skills. + +## Change + +1. Update the public MCP guide with the exact supported protocol versions and + existing routes. +2. Explain that compatible clients can negotiate `2026-07-28` on `/mcp` and + that legacy clients keep their current behavior. +3. State that client-side automatic negotiation can require an explicit client + option. Do not claim that every v2 SDK client enables it by default. +4. Explain that configured third-party MCP servers negotiate directly with + the agent. Kandev does not upgrade or proxy them. +5. Keep the feature-status table consistent with the shipped behavior. +6. List the exclusions: MCP Tasks, new OAuth behavior, and third-party proxying. + +## Acceptance + +- Public docs identify `2026-07-28` and the legacy compatibility path. +- The docs describe one `/mcp` endpoint and retained SSE routes. +- The docs do not call the protocol simply “MCP v2” without the date version. +- The docs distinguish Kandev-owned servers from configured third-party + servers. +- The feature-status page matches the implemented and tested state. + +## Verification + +```bash +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +git diff --check -- docs/public +``` + +## Files likely touched + +- `docs/public/automation-and-mcp.md` +- `docs/public/feature-status.md` + +## Dependencies + +Tasks 02 and 03. Documentation must describe the verified final behavior. + +## Parallelism + +Sequential. This task uses the results from every implementation work order. + +## Output contract + +Report the documented versions, routes, compatibility notes, exclusions, +files changed, commands run, failures, and any user-facing caveats. Update this +work order and `plan.md` with the result. + +## Result + +- Updated `docs/public/automation-and-mcp.md` with modern `2026-07-28`, legacy `2025-11-25`, `2025-06-18`, `2025-03-26`, and `2024-11-05` support. +- Documented `/mcp`, agentctl `/sse` and `/message`, external `/mcp/sse` and `/mcp/message`, automatic-negotiation limits, per-request authentication, and direct third-party delivery. +- Listed the exclusions for MCP Tasks, new OAuth behavior, and third-party proxying. +- Updated the feature-status row to match modern negotiation, legacy SSE, and authentication behavior. +- Verification passed: all 61 public-doc tests, live validation of 41 published pages, and `git diff --check -- docs/public`. diff --git a/docs/public/automation-and-mcp.md b/docs/public/automation-and-mcp.md index cef7f78b2c..1c2b43fac0 100644 --- a/docs/public/automation-and-mcp.md +++ b/docs/public/automation-and-mcp.md @@ -668,7 +668,8 @@ A profile server can show **Delivered, connection unverified**. That server connects directly to the agent, so Kandev cannot inspect its `tools/list` result, descriptions, schemas, or token estimates. The explorer still shows safe status metadata. The built-in Kandev server becomes **Connected** after -MCP initialize. It becomes **Active** after it serves `tools/list`. Missing +protocol acceptance, either legacy MCP initialize or an accepted modern +request. It becomes **Active** after it serves `tools/list`. Missing observation is not a failure. Red appears only for an explicit sanitized error. The report is per Kandev session and execution. It stores only bounded, @@ -692,6 +693,27 @@ http://127.0.0.1:/mcp SSE compatibility uses `/mcp/sse` with messages sent to `/mcp/message`. A reverse proxy must support long-lived streaming connections. +### MCP protocol versions + +The `/mcp` endpoint supports protocol negotiation for modern and legacy clients. + +- Modern clients can select `2026-07-28` with `server/discover`, or send a direct request with the modern request metadata. +- Legacy clients use `initialize` and can negotiate `2025-11-25`, `2025-06-18`, `2025-03-26`, or `2024-11-05`. +- Modern requests are stateless. They do not use `Mcp-Session-Id`. +- SSE remains a legacy transport. Use `/mcp` when the client supports `2026-07-28`. + +Automatic client negotiation depends on the client SDK. Some clients need an explicit option to enable discovery. Kandev does not enable modern protocol use for every client by default. + +Agent-attached MCP servers keep `/mcp`, `/sse`, and `/message` on the agentctl port. The external server keeps `/mcp`, `/mcp/sse`, and `/mcp/message`. + +### Configured third-party MCP servers + +Kandev delivers configured third-party MCP server definitions directly to the agent. The agent and each third-party server negotiate their own protocol and authentication. + +Kandev does not upgrade or proxy configured third-party MCP servers. Their supported versions depend on the agent, client, and server. + +This compatibility work does not add MCP Tasks, new OAuth behavior, or third-party MCP proxying. + External MCP exposes 42 tools in these groups: - workspace/workflow configuration: list workspaces, workflows, repositories, and workflow steps; create, update, delete, import, or export workflows; create, update, delete, or reorder steps; diff --git a/docs/public/feature-status.md b/docs/public/feature-status.md index f88fdb49d9..38ab82973a 100644 --- a/docs/public/feature-status.md +++ b/docs/public/feature-status.md @@ -118,7 +118,7 @@ Released builds can lag this page. Check **Settings > System > About** or `kande | ------------------------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Task-scoped Kandev MCP | Dependency-bound | Injected into compatible task sessions with scoped task/session, plan, walkthrough, branch, workflow-step, and coordination tools. Available tools vary by MCP mode and task/session context. | | Profile-defined external MCP servers | Limited | Stdio, HTTP, SSE, and streamable HTTP are implemented, but current launch resolution starts from an allow-all baseline and overlays only explicit executor policy. Blank remote policies therefore allow transports instead of applying the separate deny-all remote default. A stdio process is per-session and cannot be shared like a network server. | -| External Kandev MCP endpoint | Supported | **Settings > External MCP** exposes streamable HTTP at `/mcp` and SSE at `/mcp/sse` for configuration and task management. It has no Kandev user authentication; keep it on loopback/VPN or place it behind an authenticated proxy. It intentionally omits `stop_task_kandev` along with live-session question, plan, walkthrough, spawn, and message tools. | +| External Kandev MCP endpoint | Supported | **Settings > External MCP** exposes `/mcp` with modern `2026-07-28` negotiation and legacy initialize support, plus legacy SSE at `/mcp/sse` with messages at `/mcp/message`. When authentication is enabled, clients use a personal access token on each request; same-origin browser sessions can also authenticate. Keep remote access behind a VPN, firewall, or authenticated TLS proxy. It intentionally omits `stop_task_kandev` along with live-session question, plan, walkthrough, spawn, and message tools. | | External MCP tool preview | Limited | The endpoint registration is authoritative. The current settings preview omits several registered tools and should not be used as a complete allow-list. | | Office MCP and autonomous coordination | In progress | Office-only document, team, routine, and coordinator behavior is feature-gated and is not part of the supported external or regular task MCP contract. Automation sessions have a fixed workspace-scoped coordinator surface with trusted principal resolution and self-target denial. | | CLI/browser runtime, Linux/macOS service, Docker deployment, and remote hosting | Dependency-bound | Platform, filesystem, browser, systemd/launchd, container, reverse-proxy, and network configuration determine availability. The web app, API, WebSocket, and external MCP add no Kandev user-login boundary; protect the whole remotely reachable origin. | diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 4b69031342..d01c7c8e3d 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -6,7 +6,7 @@ This catalog is the entry point for the system-oriented specification layout. Ea | System | Index | Migration | Canonical documents | | --- | --- | --- | --- | -| Agents | [README](agents/README.md) | complete | 26 requirements, 16 designs | +| Agents | [README](agents/README.md) | complete | 27 requirements, 17 designs | | Auth | [README](auth/README.md) | complete | 7 requirements, 2 designs | | CLI | [README](cli/README.md) | complete | 2 requirements, 0 designs | | Costs | [README](costs/README.md) | complete | 2 requirements, 0 designs | diff --git a/docs/specs/agents/README.md b/docs/specs/agents/README.md index 7e0bd2392a..a6700cd464 100644 --- a/docs/specs/agents/README.md +++ b/docs/specs/agents/README.md @@ -56,6 +56,7 @@ surface shared by task and Office consumers. - [Agent Roles — Security, QA, and DevOps](requirements/roles.md) - [Managed npm runtime recovery](requirements/managed-npm-runtime-recovery.md) - [Managed Agent Runtime Versions and Updates](requirements/runtime-updates.md) +- [MCP Protocol Compatibility](requirements/mcp-protocol-compatibility.md) - [Simplify the agent settings profile layout](requirements/settings-profile-layout.md) - [Spawn Session Effective Agent Profile](requirements/spawn-session-effective-profile.md) - [Subagent context persistence](requirements/subagent-context-persistence.md) @@ -75,6 +76,7 @@ surface shared by task and Office consumers. - [Managed Agent Runtime Versions and Updates System Design Part 1](system-design/runtime-updates-01.md) - [Managed Agent Runtime Versions and Updates System Design Part 2](system-design/runtime-updates-02.md) - [Managed npm runtime recovery](system-design/managed-npm-runtime-recovery.md) +- [MCP Protocol Compatibility](system-design/mcp-protocol-compatibility.md) - [Subagent context persistence System Design Part 1](system-design/subagent-context-persistence-01.md) - [Subagent context persistence System Design Part 2](system-design/subagent-context-persistence-02.md) - [Subagent context persistence System Design Part 3](system-design/subagent-context-persistence-03.md) diff --git a/docs/specs/agents/requirements/mcp-protocol-compatibility.md b/docs/specs/agents/requirements/mcp-protocol-compatibility.md new file mode 100644 index 0000000000..b5e41cbfe3 --- /dev/null +++ b/docs/specs/agents/requirements/mcp-protocol-compatibility.md @@ -0,0 +1,71 @@ +--- +status: draft +system: agents +created: 2026-08-30 +owners: + - Kandev +--- + +# MCP Protocol Compatibility Requirements + +## Overview + +Kandev exposes agent-facing MCP servers for tasks, task titles, configuration, +Office, automations, and external integrations. New MCP clients can use the +2026-07-28 protocol. Existing clients still use the 2025 protocol family and +legacy SSE transports. + +The agent system owns this contract because it defines the MCP behavior that +all Kandev tool profiles expose to agents. The integrations system continues to +own external endpoint authentication and tool permissions. + +## Terminology + +- **Modern protocol:** MCP protocol version `2026-07-28`. +- **Legacy protocol:** An MCP protocol version that predates `2026-07-28`. +- **Automatic negotiation:** A client sends `server/discover`, selects a + compatible version, and sends requests for that version. +- **Direct modern request:** A valid modern request that does not first send + `server/discover`. + +## Requirements + +### REQ-AGENTS-MCP-PROTOCOL-001: Dual-era Kandev MCP compatibility + +**Intent:** Agents must use the best MCP protocol that they share with Kandev +without breaking agents that support only the current legacy protocol. + +#### Acceptance criteria + +- **AC-AGENTS-MCP-PROTOCOL-001.1:** When a client supports and enables + automatic negotiation, the existing Kandev `/mcp` endpoint shall advertise + `2026-07-28` and complete requests with that version. +- **AC-AGENTS-MCP-PROTOCOL-001.2:** When a client sends a valid direct modern + request, the existing Kandev `/mcp` endpoint shall process it without a + preceding discovery request. +- **AC-AGENTS-MCP-PROTOCOL-001.3:** When a legacy client sends its existing + initialize and tool requests, Kandev shall keep the legacy protocol behavior + and tool results. +- **AC-AGENTS-MCP-PROTOCOL-001.4:** When modern and legacy clients use the same + Kandev endpoint at the same time, each request shall use the protocol era + selected by that client. +- **AC-AGENTS-MCP-PROTOCOL-001.5:** Task, task-title, configuration, Office, + automation, and external MCP profiles shall expose the same authorized tools + and application behavior in both protocol eras. +- **AC-AGENTS-MCP-PROTOCOL-001.6:** Existing Kandev SSE endpoints shall remain + available for clients that do not support Streamable HTTP. +- **AC-AGENTS-MCP-PROTOCOL-001.7:** When Kandev passes a configured third-party + MCP server to an agent, the agent and that server shall negotiate their own + protocol without Kandev changing the server definition. +- **AC-AGENTS-MCP-PROTOCOL-001.8:** When a request declares an unsupported or + malformed modern protocol version, Kandev shall return a protocol error and + shall not silently reinterpret that request as legacy MCP. + +## Out of scope + +- Kandev does not implement the MCP Tasks extension as part of this change. +- Kandev does not change OAuth or MCP authorization flows as part of this + change. +- Kandev does not proxy third-party MCP traffic. +- Kandev does not require its internal Jira client or mock agent to adopt the + modern protocol in this change. diff --git a/docs/specs/agents/system-design/mcp-protocol-compatibility.md b/docs/specs/agents/system-design/mcp-protocol-compatibility.md new file mode 100644 index 0000000000..f1e5d34e3e --- /dev/null +++ b/docs/specs/agents/system-design/mcp-protocol-compatibility.md @@ -0,0 +1,157 @@ +--- +status: draft +system: agents +requirements: + - REQ-AGENTS-MCP-PROTOCOL-001 +created: 2026-08-30 +owners: + - Kandev +--- + +# MCP Protocol Compatibility System Design + +## Purpose and boundaries + +This design defines how every Kandev-owned MCP server supports the modern and +legacy MCP protocol eras on one endpoint. It covers the shared server, route +wiring, application authorization, tool profiles, and protocol evidence. + +The design does not change third-party MCP servers. Agent adapters still pass +those server definitions directly to clients. The +[external MCP requirement](../../integrations/requirements/external-mcp.md) +owns the public bearer-token boundary. The +[MCP observability design](../../platform/system-design/mcp-session-observability.md) +owns attachment status and evidence retention. + +## Requirement mapping + +| Requirement | Design sections | +| --- | --- | +| `REQ-AGENTS-MCP-PROTOCOL-001` | [Shared server](#shared-server), [Protocol selection](#protocol-selection), [Compatibility boundaries](#compatibility-boundaries), [Failure behavior](#failure-behavior) | + +## Shared server + +`apps/backend/internal/mcp/server` remains the single implementation for task, +task-title, configuration, external, Office, and automation tool profiles. +The package continues to construct Streamable HTTP and legacy SSE handlers. + +The backend upgrades `github.com/mark3labs/mcp-go` from `v0.43.2` to the v1 +line that supports MCP `2026-07-28`. The first planned target is +`v1.0.0-beta.1`. Implementation must re-check for a newer compatible v1 +release before it changes `go.mod`. + +The shared server uses the SDK's dual-era request handling. Kandev does not +fork protocol parsing or maintain a local modern-protocol shim. + +## Routes and profiles + +Agentctl keeps these routes: + +- `/mcp` for negotiated Streamable HTTP; +- `/sse` and `/message` for legacy SSE. + +The external backend keeps `/mcp`, `/mcp/sse`, and `/mcp/message`. No route or +configuration key identifies a protocol era. + +Agent injection continues to prefer the Streamable HTTP `/mcp` URL and retains +SSE as a compatibility transport. Passthrough adapters keep their current +client-specific configuration format. + +All tool profiles use the same protocol handler. Protocol selection must not +change profile filtering, dynamic plugin tools, annotations, result data, or +Kandev task and session identity. + +## Protocol selection + +The SDK selects the protocol era for each request. + +1. A modern automatic client sends `server/discover` to `/mcp`. +2. Kandev advertises `2026-07-28` and supported legacy versions. +3. The client selects a version. +4. Modern calls include the required protocol metadata and HTTP headers. +5. Kandev validates and handles each call as a stateless modern request. + +A client can skip discovery and send a valid direct modern request. Kandev +handles it with the same modern path. A legacy client keeps the existing +`initialize` sequence and session behavior. + +The TypeScript MCP SDK v2 does not enable automatic negotiation by default. +Client compatibility tests must enable its automatic mode when they prove the +modern path. Default legacy mode remains a valid compatibility test. + +## Application and security boundaries + +Protocol negotiation changes the wire contract only. Tool handlers keep their +existing Kandev authorization and application context. + +Agentctl binds task and session identity from the server instance. A client +cannot select another Kandev task or session through MCP metadata. The external +backend resolves its personal access token on every HTTP request. This is +required because modern requests are stateless and can arrive on separate HTTP +connections. + +The modern tool-list cache metadata stays conservative for dynamic tool +profiles. Kandev advertises no shared cache and no positive cache lifetime. +This prevents one external caller or attachment from reusing another caller's +dynamic tool list. + +## Compatibility boundaries + +Kandev keeps these clients on their existing explicit behavior: + +- `apps/backend/internal/jira/mcp_client.go` continues to request + `2025-06-18` until a separate change adopts automatic negotiation. +- `apps/backend/cmd/mock-agent` continues to use legacy SSE. +- Configured third-party MCP servers continue to flow from Kandev to the agent + without a Kandev protocol proxy. + +The compatibility test matrix covers: + +| Client | Kandev server | Expected result | +| --- | --- | --- | +| Modern automatic client | Dual-era `/mcp` | Select `2026-07-28` and use tools. | +| Modern direct client | Dual-era `/mcp` | Use tools without discovery. | +| Legacy client | Dual-era `/mcp` | Complete initialize and use tools. | +| Legacy SSE client | Legacy SSE routes | Use tools without behavior changes. | +| Modern automatic client | Legacy-only fixture | Select a shared legacy version or use the client's documented fallback. | + +Tests also cover concurrent modern and legacy calls, external bearer-token +authorization, every Kandev tool profile, and a dynamic plugin-tool list. + +## Attachment observability + +Legacy sessions retain their opaque MCP connection ID, initialize evidence, +tool-list evidence, tool-call evidence, and close evidence. + +Modern MCP is stateless. A successful discovery or other validated modern +request records protocol-accepted evidence against the current attachment +attempt. A successful `tools/list` still makes the Kandev server Active and +stores the safe catalog. Modern requests do not create a fabricated connection +ID. The end of one HTTP request does not record connection-closed evidence. + +The event model adds a general `protocol_accepted` evidence kind. Existing +`initialize_observed` history remains readable and continues to represent +legacy initialization. + +## Failure behavior + +Kandev returns the SDK's protocol error for missing, malformed, or unsupported +modern metadata. The server does not downgrade the same invalid request to the +legacy handler. A client can make another request through its own documented +negotiation or fallback path. + +Failure in one protocol era does not disable the other era. The endpoint does +not use a runtime feature flag because the SDK selects the era per request and +the legacy behavior remains available. + +## Persistence and migration + +The protocol change needs no database migration. The optional +`protocol_accepted` evidence kind extends the existing bounded attachment +history. Readers must continue to accept stored `initialize_observed` events. + +## Related decisions + +- [Use one dual-era MCP endpoint](../../../decisions/2026-08-30-dual-era-mcp-protocol.md) +- [Keep MCP attachment evidence session owned](../../../decisions/2026-07-30-session-owned-mcp-observability.md) +- [Compose MCP tool profiles from typed context](../../../decisions/2026-08-08-mcp-tool-profiles.md) diff --git a/docs/specs/platform/requirements/mcp-session-observability.md b/docs/specs/platform/requirements/mcp-session-observability.md index 44410fe2d6..67b0124b97 100644 --- a/docs/specs/platform/requirements/mcp-session-observability.md +++ b/docs/specs/platform/requirements/mcp-session-observability.md @@ -2,7 +2,7 @@ status: active system: platform created: 2026-07-30 -updated: 2026-08-18 +updated: 2026-08-30 owners: - Kandev --- @@ -23,11 +23,12 @@ The chat toolbar currently derives its MCP list from agent-profile configuration - **AC-PLATFORM-MCP-SESSION-OBSERVABILITY-001.1:** **GIVEN** two agents are running inside the same task, **WHEN** only one reaches `tools/list` on its Kandev MCP endpoint, **THEN** only that session's toolbar lists Kandev as Active. - **AC-PLATFORM-MCP-SESSION-OBSERVABILITY-001.2:** **GIVEN** a session is restarted or reset after an Active connection, **WHEN** the new attachment attempt has not contacted MCP, **THEN** the toolbar shows the new attempt as Delivered or unverified and retains the previous evidence only as historical diagnostics. - **AC-PLATFORM-MCP-SESSION-OBSERVABILITY-001.3:** **GIVEN** an ACP agent accepts `session/new` with a third-party MCP server, **WHEN** Kandev cannot observe the direct connection, **THEN** the row says Delivered · connection unverified instead of Active or Failed. -- **AC-PLATFORM-MCP-SESSION-OBSERVABILITY-001.4:** **GIVEN** Kandev's in-session MCP endpoint receives initialize and `tools/list`, **WHEN** the status surface is opened, **THEN** the Kandev row is green, shows the tool count, and identifies the current execution and connection. +- **AC-PLATFORM-MCP-SESSION-OBSERVABILITY-001.4:** **GIVEN** Kandev's in-session MCP endpoint accepts the client's protocol and serves `tools/list`, **WHEN** the status surface is opened, **THEN** the Kandev row is green, shows the tool count, and identifies the current execution and attachment attempt. - **AC-PLATFORM-MCP-SESSION-OBSERVABILITY-001.5:** **GIVEN** an agent reports a server-specific connection refusal, **WHEN** the status surface is opened, **THEN** that server is red with a sanitized reason and the other servers keep their own evidence states. - **AC-PLATFORM-MCP-SESSION-OBSERVABILITY-001.6:** **GIVEN** a release-mode user has an unverified attachment, **WHEN** they run Test endpoint and copy diagnostics, **THEN** the test runs from the session's executor, its result is distinguished from agent attachment, and the copied report contains no secrets or raw agent output. - **AC-PLATFORM-MCP-SESSION-OBSERVABILITY-001.7:** **GIVEN** an Active Kandev server, **WHEN** a precise-pointer user hovers or focuses the MCP trigger, **THEN** the tooltip shows a green Kandev row and its Active status. - **AC-PLATFORM-MCP-SESSION-OBSERVABILITY-001.8:** **GIVEN** a precise-pointer user, **WHEN** they click the MCP trigger, **THEN** a wide dialog lists the active session's MCP servers. +- **AC-PLATFORM-MCP-SESSION-OBSERVABILITY-001.9:** **GIVEN** a modern stateless MCP request, **WHEN** Kandev records protocol evidence, **THEN** the evidence belongs to the current attachment attempt without a fabricated connection ID or a connection-closed event at the end of the HTTP request. ## System design diff --git a/docs/specs/platform/system-design/mcp-session-observability.md b/docs/specs/platform/system-design/mcp-session-observability.md index 1c88c26eeb..4ee89db7d2 100644 --- a/docs/specs/platform/system-design/mcp-session-observability.md +++ b/docs/specs/platform/system-design/mcp-session-observability.md @@ -4,7 +4,7 @@ system: platform requirements: - REQ-PLATFORM-MCP-SESSION-OBSERVABILITY-001 created: 2026-07-30 -updated: 2026-08-18 +updated: 2026-08-30 owners: - Kandev --- @@ -56,7 +56,7 @@ turning absence into failure: | Configured | The server exists in the selected profile or is Kandev's built-in task server. | | Filtered | Kandev deliberately omitted the server because the agent, executor policy, transport, or passthrough strategy could not expose it. | | Delivered | The server was included in ACP `session/new`, `session/load`, or `session/reset`, or was materialized into a passthrough CLI's effective config. | -| Connected | Kandev's in-session MCP endpoint observed that client connection initialize successfully. | +| Connected | Kandev's in-session MCP endpoint accepted the client's protocol. For a legacy client, initialize supplies this evidence. | | Tools loaded | Kandev's in-session endpoint served `tools/list` successfully to that connection. | | Used | Kandev's in-session endpoint observed at least one tool call on that connection. | | Failed | Kandev received an explicit server-specific attachment error. | @@ -81,9 +81,12 @@ from profile configuration, agent capability flags, a successful ACP - The report carries the backend-owned `task_id`, `session_id`, `execution_id`, `attachment_attempt_id`, `agent_id`, and `agent_profile_id`. It also records the provider's `acp_session_id` when available. -- Each observed MCP transport client receives a connection ID. Connection - evidence is attributed to the agentctl instance's backend-owned task and - session identity, never to IDs supplied by the agent. +- Each observed legacy MCP transport client receives a connection ID. + Connection evidence is attributed to the agentctl instance's backend-owned + task and session identity, never to IDs supplied by the agent. +- Modern MCP requests are stateless. Their protocol, tool-list, and tool-call + evidence belongs to the current attachment attempt. Kandev does not invent a + connection ID and does not treat the end of an HTTP request as a disconnect. - Multiple agents inside one task remain distinct because they have distinct Kandev session IDs. Restarting one session creates a new execution report; evidence from the superseded execution cannot keep the current execution @@ -104,7 +107,7 @@ Each attempt timeline is bounded and can contain these events: - server filtered, with a stable reason code; - server delivered; - agent session accepted; -- MCP initialize observed; +- MCP protocol accepted, including legacy initialize where applicable; - tools list observed, including tool count; - tool call observed, without tool arguments or result; - explicit attachment error;