feat(backend): support dual-era MCP protocol (#3164)
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+4
-7
@@ -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
|
||||
|
||||
+6
-10
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
}
|
||||
@@ -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:<port>).
|
||||
// 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
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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:<backend-port>/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;
|
||||
|
||||
@@ -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. |
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user