feat(backend): support dual-era MCP protocol (#3164)

This commit is contained in:
Carlos Florêncio
2026-09-02 19:48:19 +00:00
committed by GitHub
parent 750b23a745
commit 2d8b3a0c1e
31 changed files with 1827 additions and 46 deletions
+10 -7
View File
@@ -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.
+4 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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{}
}
}
+85 -5
View File
@@ -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.
+2 -1
View File
@@ -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 |
+120
View File
@@ -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`.
+23 -1
View File
@@ -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;
+1 -1
View File
@@ -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
View File
@@ -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 |
+2
View File
@@ -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;