feat(mcp): add session attachment diagnostics (#2061)
This commit is contained in:
@@ -29,6 +29,7 @@ test -x apps/backend/bin/acpdbg || make -C apps/backend build-acpdbg
|
||||
acpdbg list # enumerate registered ACP agents
|
||||
acpdbg probe <agent> # initialize + session/new + close
|
||||
acpdbg probe --exec "<cmd> [args...]" # probe an arbitrary binary not in the registry
|
||||
acpdbg mcp-probe <agent> # inject a temporary MCP sentinel into session/new
|
||||
acpdbg prompt --prompt "..." [--model M] [--mode M] <agent>
|
||||
acpdbg session-load --session-id <id> <agent>
|
||||
acpdbg matrix # probe every ACP agent in parallel
|
||||
@@ -58,6 +59,7 @@ acpdbg matrix # probe every ACP agent i
|
||||
- "test a prompt against X" → `acpdbg prompt --prompt "..." X`
|
||||
- "compare all agents" / "run the matrix" → `acpdbg matrix`
|
||||
- "resume session <id>" → `acpdbg session-load --session-id <id> X`
|
||||
- "did this agent attach the injected MCP server" → `acpdbg mcp-probe X`
|
||||
- "try this random binary" → `acpdbg probe --exec "path/to/bin --acp"`
|
||||
|
||||
### 2. Run the command
|
||||
@@ -128,6 +130,14 @@ Common failure modes:
|
||||
|
||||
Re-run with `--stderr` whenever the child exits before the handshake completes; the stderr lines land in the JSONL and usually contain the root cause.
|
||||
|
||||
For `mcp-probe`, distinguish `sentinel_delivered` (the agent accepted the
|
||||
`session/new` configuration) from `initialize_observed` and
|
||||
`tools_list_observed` (the temporary endpoint received MCP traffic). An
|
||||
`unobserved` result is intentionally not a generic agent failure: a provider
|
||||
can attach lazily or ignore the supplied transport. Sentinel metadata has only
|
||||
opaque connection IDs and timestamps; the explicit acpdbg JSONL still contains
|
||||
raw ACP frames and must stay a developer-only artifact.
|
||||
|
||||
### 6. For `matrix`, read `matrix-summary.json` too
|
||||
|
||||
```bash
|
||||
|
||||
@@ -26,6 +26,7 @@ Or invoke the binary directly: `apps/backend/bin/acpdbg <subcommand> [flags]`.
|
||||
| `list` | Enumerate registered ACP agents with their spawn command |
|
||||
| `probe <agent>` | `initialize` → `session/new` → close. Shows models, modes, auth methods. |
|
||||
| `probe --exec "<cmd> [args...]"` | Same but against an arbitrary binary not in the registry |
|
||||
| `mcp-probe <agent>` | Inject a temporary HTTP MCP sentinel into `session/new` and report delivery separately from observed initialize, `tools/list`, and tool use. |
|
||||
| `prompt <agent> --prompt "..." [--model M] [--mode M]` | Full prompt round-trip, collects text chunks from `session/update` |
|
||||
| `session-load <agent> --session-id <id> [--prompt "..."]` | Load an existing session in `--workdir`, then optionally verify it with a prompt |
|
||||
| `matrix` | Probe every registered ACP agent in parallel, write one JSONL per agent + `matrix-summary.json` |
|
||||
@@ -61,6 +62,22 @@ apps/backend/bin/acpdbg session-load codex-acp \
|
||||
JSONL wire frames are authoritative when a provider replays earlier session
|
||||
messages during load.
|
||||
|
||||
### Probe MCP attachment
|
||||
|
||||
```bash
|
||||
apps/backend/bin/acpdbg mcp-probe --timeout 45s auggie
|
||||
```
|
||||
|
||||
The summary lists the agent's advertised HTTP/SSE capability and the sentinel
|
||||
milestones separately. `sentinel_delivered: true` means `session/new` accepted
|
||||
the injected configuration. `initialize_observed` and `tools_list_observed`
|
||||
are direct traffic seen by the sentinel. If the result is `unobserved`, the
|
||||
agent did not contact the temporary endpoint during the bounded probe window;
|
||||
that is not a portable failure because agents may attach lazily or use a
|
||||
different transport. Sentinel JSONL metadata includes only opaque connection
|
||||
IDs and timestamps, while ordinary raw frames remain available because this is
|
||||
an explicit developer command.
|
||||
|
||||
## JSONL format
|
||||
|
||||
One JSON object per line, chronologically ordered. `direction` is one of:
|
||||
|
||||
@@ -42,6 +42,8 @@ func run() int {
|
||||
err = runList(args)
|
||||
case "probe":
|
||||
err = runProbe(ctx, args)
|
||||
case "mcp-probe":
|
||||
err = runMCPProbe(ctx, args)
|
||||
case "prompt":
|
||||
err = runPrompt(ctx, args)
|
||||
case "session-load":
|
||||
@@ -70,6 +72,7 @@ Usage:
|
||||
acpdbg list
|
||||
acpdbg probe <agent> [flags]
|
||||
acpdbg probe --exec "<cmd> [args...]" [flags]
|
||||
acpdbg mcp-probe <agent> [flags]
|
||||
acpdbg prompt <agent> --prompt TEXT [--model M] [--mode M] [flags]
|
||||
acpdbg session-load <agent> --session-id ID [--prompt TEXT] [flags]
|
||||
acpdbg matrix [flags]
|
||||
@@ -186,6 +189,46 @@ func runProbe(ctx context.Context, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- mcp-probe ---
|
||||
|
||||
func runMCPProbe(ctx context.Context, args []string) error {
|
||||
fs := flag.NewFlagSet("mcp-probe", flag.ExitOnError)
|
||||
shared := registerShared(fs)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := resolveRunConfig(fs, shared, "mcp-probe")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jsonlPath := resolveJSONLPath(shared, cfg.AgentID, "mcp-probe")
|
||||
runCtx, cancel := context.WithTimeout(ctx, shared.timeout)
|
||||
defer cancel()
|
||||
runner, err := acpdbg.NewRunner(runCtx, jsonlPath, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer runner.Close("completed")
|
||||
|
||||
result, err := acpdbg.MCPProbe(runCtx, runner)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "mcp probe failed: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "jsonl: %s\n", runner.Path())
|
||||
return err
|
||||
}
|
||||
printProbeSummary(cfg.AgentID, runner.Path(), &result.ProbeResult)
|
||||
fmt.Printf("mcp_http_advertised: %t\n", result.MCPHTTP)
|
||||
fmt.Printf("mcp_sse_advertised: %t\n", result.MCPSSE)
|
||||
fmt.Printf("sentinel_delivered: %t\n", result.Delivered)
|
||||
fmt.Printf("initialize_observed: %t\n", result.Sentinel.InitializeObserved)
|
||||
fmt.Printf("tools_list_observed: %t (tools: %d)\n", result.Sentinel.ToolsListObserved, result.Sentinel.ToolCount)
|
||||
fmt.Printf("tool_call_observed: %t\n", result.Sentinel.ToolCallObserved)
|
||||
if !result.Sentinel.InitializeObserved {
|
||||
fmt.Println("status: unobserved (the agent accepted delivery but did not contact the sentinel during the probe window)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- prompt ---
|
||||
|
||||
func runPrompt(ctx context.Context, args []string) error {
|
||||
|
||||
@@ -164,6 +164,7 @@ func run(cfg *config.Config, log *logger.Logger) {
|
||||
|
||||
// Create MCP server using the channel-based backend client
|
||||
mcpSrv := mcpserver.New(mcpBackendClient, instCfg.SessionID, instCfg.TaskID, instCfg.Port, instLog, cfg.McpLogFile, instCfg.DisableAskQuestion, instCfg.McpMode)
|
||||
mcpSrv.SetAttachmentReporter(procMgr.PublishMCPAttachment)
|
||||
instLog.Info("MCP server enabled (channel-based)",
|
||||
zap.String("session_id", instCfg.SessionID))
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package acpdbg
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
mcpInitializeMethod = "initialize"
|
||||
mcpToolsListMethod = "tools/list"
|
||||
mcpToolsCallMethod = "tools/call"
|
||||
)
|
||||
|
||||
// MCPSentinel is a deliberately tiny streamable-HTTP MCP server used only by
|
||||
// acpdbg. It records protocol milestones without recording MCP payloads.
|
||||
// Absence of a milestone is an unobserved result, not an agent failure.
|
||||
type MCPSentinel struct {
|
||||
recorder *Recorder
|
||||
server *httptest.Server
|
||||
|
||||
mu sync.Mutex
|
||||
connections map[string]*sentinelConnection
|
||||
}
|
||||
|
||||
type sentinelConnection struct {
|
||||
initialized bool
|
||||
toolsListed bool
|
||||
toolCalled bool
|
||||
toolCount int
|
||||
}
|
||||
|
||||
// MCPSentinelSummary separates actual endpoint observations from ACP delivery.
|
||||
type MCPSentinelSummary struct {
|
||||
InitializeObserved bool
|
||||
ToolsListObserved bool
|
||||
ToolCallObserved bool
|
||||
ToolCount int
|
||||
}
|
||||
|
||||
// NewMCPSentinel starts an in-process sentinel. Call Close after session/new
|
||||
// has returned; the server never exposes a host project endpoint or credentials.
|
||||
func NewMCPSentinel(recorder *Recorder) *MCPSentinel {
|
||||
s := &MCPSentinel{recorder: recorder, connections: make(map[string]*sentinelConnection)}
|
||||
s.server = httptest.NewServer(http.HandlerFunc(s.serveHTTP))
|
||||
_ = recorder.Meta("mcp_sentinel_started", map[string]any{"target": s.server.URL + "/mcp"})
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *MCPSentinel) URL() string { return s.server.URL + "/mcp" }
|
||||
|
||||
func (s *MCPSentinel) Close() {
|
||||
if s.server != nil {
|
||||
s.server.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MCPSentinel) Summary() MCPSentinelSummary {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var result MCPSentinelSummary
|
||||
for _, connection := range s.connections {
|
||||
result.InitializeObserved = result.InitializeObserved || connection.initialized
|
||||
result.ToolsListObserved = result.ToolsListObserved || connection.toolsListed
|
||||
result.ToolCallObserved = result.ToolCallObserved || connection.toolCalled
|
||||
if connection.toolCount > result.ToolCount {
|
||||
result.ToolCount = connection.toolCount
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *MCPSentinel) serveHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/mcp" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
defer func() { _ = r.Body.Close() }()
|
||||
var request struct {
|
||||
ID any `json:"id"`
|
||||
Method string `json:"method"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid JSON-RPC request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
connectionID := s.connectionID(r)
|
||||
s.record(connectionID, request.Method)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Mcp-Session-Id", connectionID)
|
||||
response := map[string]any{"jsonrpc": "2.0", "id": request.ID}
|
||||
switch request.Method {
|
||||
case mcpInitializeMethod:
|
||||
response["result"] = map[string]any{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": map[string]any{"tools": map[string]any{}},
|
||||
"serverInfo": map[string]any{"name": "acpdbg-sentinel", "version": "1"},
|
||||
}
|
||||
case mcpToolsListMethod:
|
||||
response["result"] = map[string]any{"tools": []map[string]any{{
|
||||
"name": "acpdbg_sentinel", "description": "ACP debug sentinel", "inputSchema": map[string]any{"type": "object"},
|
||||
}}}
|
||||
case mcpToolsCallMethod:
|
||||
response["result"] = map[string]any{"content": []map[string]string{{"type": "text", "text": "sentinel"}}}
|
||||
default:
|
||||
response["error"] = map[string]any{"code": -32601, "message": "method not found"}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
func (s *MCPSentinel) connectionID(r *http.Request) string {
|
||||
if id := r.Header.Get("Mcp-Session-Id"); id != "" {
|
||||
return opaqueConnectionID(id)
|
||||
}
|
||||
return opaqueConnectionID(r.RemoteAddr)
|
||||
}
|
||||
|
||||
func opaqueConnectionID(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return "sentinel-" + hex.EncodeToString(sum[:4])
|
||||
}
|
||||
|
||||
func (s *MCPSentinel) record(connectionID, method string) {
|
||||
s.mu.Lock()
|
||||
connection := s.connections[connectionID]
|
||||
if connection == nil {
|
||||
connection = &sentinelConnection{}
|
||||
s.connections[connectionID] = connection
|
||||
}
|
||||
switch method {
|
||||
case mcpInitializeMethod:
|
||||
connection.initialized = true
|
||||
case mcpToolsListMethod:
|
||||
connection.toolsListed = true
|
||||
connection.toolCount = 1
|
||||
case mcpToolsCallMethod:
|
||||
connection.toolCalled = true
|
||||
}
|
||||
s.mu.Unlock()
|
||||
_ = s.recorder.Meta("mcp_sentinel_"+method, map[string]any{
|
||||
"connection_id": connectionID,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *MCPSentinel) String() string {
|
||||
return fmt.Sprintf("MCP sentinel at %s", s.URL())
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package acpdbg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMCPSentinelRecordsOnlyProtocolMilestones(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "sentinel.jsonl")
|
||||
recorder, err := NewRecorder(path)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = recorder.Close() })
|
||||
sentinel := NewMCPSentinel(recorder)
|
||||
t.Cleanup(sentinel.Close)
|
||||
|
||||
for _, method := range []string{"initialize", "tools/list", "tools/call"} {
|
||||
body := []byte(`{"jsonrpc":"2.0","id":1,"method":"` + method + `","params":{}}`)
|
||||
request, err := http.NewRequest(http.MethodPost, sentinel.URL(), bytes.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
request.Header.Set("Mcp-Session-Id", "agent-supplied-secret")
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
require.NoError(t, err)
|
||||
_ = response.Body.Close()
|
||||
require.Equal(t, http.StatusOK, response.StatusCode)
|
||||
}
|
||||
|
||||
summary := sentinel.Summary()
|
||||
require.True(t, summary.InitializeObserved)
|
||||
require.True(t, summary.ToolsListObserved)
|
||||
require.True(t, summary.ToolCallObserved)
|
||||
require.Equal(t, 1, summary.ToolCount)
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(content), "agent-supplied-secret")
|
||||
require.Contains(t, string(content), "sentinel-")
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IsAuthErrorMessage is a coarse substring heuristic mirroring
|
||||
@@ -36,6 +37,17 @@ type ProbeResult struct {
|
||||
Modes []string
|
||||
CurrentModeID string
|
||||
SessionID string
|
||||
MCPHTTP bool
|
||||
MCPSSE bool
|
||||
}
|
||||
|
||||
// MCPProbeResult describes what was delivered to an agent separately from
|
||||
// what the injected sentinel observed. An unobserved sentinel is normal for
|
||||
// agents which defer MCP connections or do not support the supplied transport.
|
||||
type MCPProbeResult struct {
|
||||
ProbeResult
|
||||
Delivered bool
|
||||
Sentinel MCPSentinelSummary
|
||||
}
|
||||
|
||||
// Probe runs the minimum ACP handshake: initialize → session/new → close.
|
||||
@@ -56,6 +68,40 @@ func Probe(ctx context.Context, r *Runner) (*ProbeResult, error) {
|
||||
return buildProbeResult(initResp, newResp), nil
|
||||
}
|
||||
|
||||
// MCPProbe runs initialize and injects a short-lived HTTP sentinel into
|
||||
// session/new. The caller's timeout bounds both ACP delivery and observation.
|
||||
func MCPProbe(ctx context.Context, r *Runner) (*MCPProbeResult, error) {
|
||||
initResp, err := sendInitialize(ctx, r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("initialize: %w", err)
|
||||
}
|
||||
sentinel := NewMCPSentinel(r.rec)
|
||||
defer sentinel.Close()
|
||||
newResp, err := sendSessionNewWithMCP(ctx, r, []any{map[string]any{
|
||||
"name": "acpdbg-sentinel",
|
||||
"type": "http",
|
||||
"url": sentinel.URL(),
|
||||
}})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("session/new: %w", err)
|
||||
}
|
||||
result := &MCPProbeResult{
|
||||
ProbeResult: *buildProbeResult(initResp, newResp),
|
||||
Delivered: true,
|
||||
}
|
||||
// Give an asynchronously connecting agent a small, bounded opportunity to
|
||||
// reach the sentinel without turning missing traffic into a failure.
|
||||
wait := time.NewTimer(750 * time.Millisecond)
|
||||
defer wait.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-wait.C:
|
||||
}
|
||||
result.Sentinel = sentinel.Summary()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PromptOptions configures the Prompt operation.
|
||||
type PromptOptions struct {
|
||||
Model string // if non-empty, set via session/set_model (or unstable variant)
|
||||
@@ -174,9 +220,13 @@ func sendInitialize(ctx context.Context, r *Runner) (Frame, error) {
|
||||
}
|
||||
|
||||
func sendSessionNew(ctx context.Context, r *Runner) (Frame, error) {
|
||||
return sendSessionNewWithMCP(ctx, r, []any{})
|
||||
}
|
||||
|
||||
func sendSessionNewWithMCP(ctx context.Context, r *Runner, mcpServers []any) (Frame, error) {
|
||||
req, _ := r.Framer().NewRequest("session/new", map[string]any{
|
||||
"cwd": r.cfg.Workdir,
|
||||
"mcpServers": []any{},
|
||||
"mcpServers": mcpServers,
|
||||
})
|
||||
resp, err := r.Request(ctx, req)
|
||||
if err != nil {
|
||||
@@ -341,6 +391,12 @@ func parseInitResult(initResult map[string]any, out *ProbeResult) {
|
||||
if methods, ok := initResult["authMethods"].([]any); ok {
|
||||
out.AuthMethods = extractStringField(methods, "id")
|
||||
}
|
||||
if capabilities, ok := initResult["capabilities"].(map[string]any); ok {
|
||||
if mcp, ok := capabilities["mcpCapabilities"].(map[string]any); ok {
|
||||
out.MCPHTTP, _ = mcp["http"].(bool)
|
||||
out.MCPSSE, _ = mcp["sse"].(bool)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseSessionResult(newResult map[string]any, out *ProbeResult) {
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
package acpdbg
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewRunnerUsesResolvedTemporaryWorkdirForProtocolRequests(t *testing.T) {
|
||||
runner, err := NewRunner(context.Background(), t.TempDir()+"/probe.jsonl", RunConfig{
|
||||
AgentID: "test-agent",
|
||||
Command: []string{"cat"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRunner: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { runner.Close("test complete") })
|
||||
if runner.cfg.Workdir == "" {
|
||||
t.Fatal("runner workdir is empty; session/new would receive an empty cwd")
|
||||
}
|
||||
if runner.cfg.Workdir != runner.tmpDir {
|
||||
t.Fatalf("protocol workdir = %q, child temporary dir = %q", runner.cfg.Workdir, runner.tmpDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionLoadParamsIncludeChangedWorkdir(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -110,6 +132,45 @@ func TestBuildProbeResult_PrefersLegacyModes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPProbeInjectsSentinelAndSummarizesDelivery(t *testing.T) {
|
||||
const peer = `
|
||||
IFS= read -r init
|
||||
printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"scripted-agent","version":"1.0"},"capabilities":{"mcpCapabilities":{"http":true}}}}'
|
||||
IFS= read -r session
|
||||
case "$session" in
|
||||
*'"name":"acpdbg-sentinel"'*'"type":"http"'*) ;;
|
||||
*) printf '%s\n' '{"jsonrpc":"2.0","id":2,"error":{"code":-32602,"message":"missing sentinel"}}'; exit 0 ;;
|
||||
esac
|
||||
printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"sessionId":"session-1"}}'
|
||||
`
|
||||
runner, err := NewRunner(context.Background(), t.TempDir()+"/probe.jsonl", RunConfig{
|
||||
AgentID: "scripted-agent", Command: []string{"sh", "-c", peer},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRunner: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { runner.Close("test complete") })
|
||||
|
||||
result, err := MCPProbe(context.Background(), runner)
|
||||
if err != nil {
|
||||
t.Fatalf("MCPProbe: %v", err)
|
||||
}
|
||||
if !result.Delivered || result.SessionID != "session-1" || result.AgentName != "scripted-agent" || !result.MCPHTTP {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
if result.Sentinel.InitializeObserved || result.Sentinel.ToolsListObserved || result.Sentinel.ToolCallObserved {
|
||||
t.Fatalf("unexpected sentinel observation = %+v", result.Sentinel)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(runner.Path())
|
||||
if err != nil {
|
||||
t.Fatalf("read probe JSONL: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(content), "acpdbg-sentinel") {
|
||||
t.Fatalf("session/new did not record injected sentinel: %s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProbeResult_FallsBackToConfigOptionGroupedModels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -89,6 +89,10 @@ func NewRunner(ctx context.Context, jsonlPath string, cfg RunConfig) (*Runner, e
|
||||
workdir = wd
|
||||
tmpDir = wd
|
||||
}
|
||||
// Keep the resolved directory in the runner configuration as well as on the
|
||||
// child command. ACP session/new and session/load use r.cfg.Workdir, so
|
||||
// leaving this empty would make wire-level probes differ from the child cwd.
|
||||
cfg.Workdir = workdir
|
||||
|
||||
// Record start meta before spawning so the file always has something
|
||||
// useful even if exec fails.
|
||||
|
||||
@@ -198,6 +198,12 @@ type AgentStreamEventData struct {
|
||||
|
||||
// PlanContent contains rich markdown plan content (from "agent_plan" event).
|
||||
PlanContent string `json:"plan_content,omitempty"`
|
||||
|
||||
// MCPAttachment is one safe MCP delivery or observation fact.
|
||||
MCPAttachment *streams.MCPAttachmentEvidence `json:"mcp_attachment,omitempty"`
|
||||
|
||||
// MCPAttachmentAttempt starts a new backend-owned MCP evidence timeline.
|
||||
MCPAttachmentAttempt *streams.MCPAttachmentAttempt `json:"mcp_attachment_attempt,omitempty"`
|
||||
}
|
||||
|
||||
// AgentStreamEventPayload is the payload for agent stream events (WebSocket streaming).
|
||||
@@ -541,11 +547,45 @@ func LoadSessionModelsSnapshot(raw any) (SessionModelsSnapshot, bool) {
|
||||
return snapshot, snapshot.CurrentModelID != "" || len(snapshot.Models) > 0 || len(snapshot.ConfigOptions) > 0
|
||||
}
|
||||
|
||||
// LoadMCPAttachmentHistory decodes typed and JSON-rehydrated MCP attachment
|
||||
// metadata. It accepts only the current schema and a current attempt so a
|
||||
// partial or malformed value cannot look like a healthy report after reload.
|
||||
func LoadMCPAttachmentHistory(raw any) (streams.MCPAttachmentHistory, bool) {
|
||||
if raw == nil {
|
||||
return streams.MCPAttachmentHistory{}, false
|
||||
}
|
||||
if history, ok := raw.(streams.MCPAttachmentHistory); ok {
|
||||
return history, history.Valid()
|
||||
}
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return streams.MCPAttachmentHistory{}, false
|
||||
}
|
||||
var history streams.MCPAttachmentHistory
|
||||
if err := json.Unmarshal(data, &history); err != nil {
|
||||
return streams.MCPAttachmentHistory{}, false
|
||||
}
|
||||
return history, history.Valid()
|
||||
}
|
||||
|
||||
// GetSessionID returns the session ID for this event (used by event routing).
|
||||
func (p SessionModelsEventPayload) GetSessionID() string {
|
||||
return p.SessionID
|
||||
}
|
||||
|
||||
// SessionMCPStatusEventPayload is the persisted, safe MCP status history for
|
||||
// one task session. It is deliberately session-scoped so parallel agents never
|
||||
// overwrite each other's diagnostics.
|
||||
type SessionMCPStatusEventPayload struct {
|
||||
TaskID string `json:"task_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
History streams.MCPAttachmentHistory `json:"history"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// GetSessionID returns the task-session routing key.
|
||||
func (p SessionMCPStatusEventPayload) GetSessionID() string { return p.SessionID }
|
||||
|
||||
// SessionInfoEventPayload is the payload for ACP session info updates.
|
||||
type SessionInfoEventPayload struct {
|
||||
TaskID string `json:"task_id"`
|
||||
|
||||
@@ -208,6 +208,8 @@ func (p *EventPublisher) PublishAgentStreamEvent(execution *AgentExecution, even
|
||||
Usage: event.Usage,
|
||||
PlanEntries: event.PlanEntries,
|
||||
PlanContent: event.PlanContent,
|
||||
MCPAttachment: event.MCPAttachment,
|
||||
MCPAttachmentAttempt: event.MCPAttachmentAttempt,
|
||||
}
|
||||
|
||||
// Build agent event message payload
|
||||
|
||||
@@ -631,6 +631,26 @@ func (m *Manager) handlePromptHandoffEvent(
|
||||
|
||||
// handleAgentEvent processes incoming agent events from the agent
|
||||
func (m *Manager) handleAgentEvent(execution *AgentExecution, event agentctl.AgentEvent) {
|
||||
if event.Type == streams.EventTypeMCPAttachment {
|
||||
if event.MCPAttachmentAttempt != nil {
|
||||
if event.MCPAttachmentAttempt.ExecutionID != "" && event.MCPAttachmentAttempt.ExecutionID != execution.ID {
|
||||
m.logger.Warn("dropping stale MCP attachment attempt",
|
||||
zap.String("event_execution_id", event.MCPAttachmentAttempt.ExecutionID),
|
||||
zap.String("live_execution_id", execution.ID))
|
||||
return
|
||||
}
|
||||
attempt := *event.MCPAttachmentAttempt
|
||||
attempt.TaskID = execution.TaskID
|
||||
attempt.SessionID = execution.SessionID
|
||||
attempt.ExecutionID = execution.ID
|
||||
attempt.AgentID = execution.AgentID
|
||||
event.MCPAttachmentAttempt = &attempt
|
||||
}
|
||||
// Attachment diagnostics must not count as model activity or alter turn
|
||||
// ownership. They flow directly to the orchestrator for persistence.
|
||||
m.eventPublisher.PublishAgentStreamEvent(execution, event)
|
||||
return
|
||||
}
|
||||
if event.PromptGeneration == 0 || (event.Type != toolStatusComplete && event.Type != "error") {
|
||||
m.recordActivity(execution, event)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
||||
)
|
||||
|
||||
func TestLoadMCPAttachmentHistoryDecodesTypedAndJSONMetadata(t *testing.T) {
|
||||
want := streams.MCPAttachmentHistory{
|
||||
Version: streams.MCPAttachmentSchemaVersion,
|
||||
Current: streams.MCPAttachmentAttempt{
|
||||
AttemptID: "attempt-1",
|
||||
Servers: []streams.MCPServerAttachment{{Name: "kandev", Status: streams.MCPAttachmentStatusActive}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, raw := range []any{want, mustJSONMap(t, want)} {
|
||||
got, ok := LoadMCPAttachmentHistory(raw)
|
||||
if !ok {
|
||||
t.Fatalf("LoadMCPAttachmentHistory(%T) returned false", raw)
|
||||
}
|
||||
if got.Current.AttemptID != want.Current.AttemptID || len(got.Current.Servers) != 1 || got.Current.Servers[0].Status != streams.MCPAttachmentStatusActive {
|
||||
t.Fatalf("history = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMCPAttachmentHistoryRejectsMalformedMetadata(t *testing.T) {
|
||||
if _, ok := LoadMCPAttachmentHistory(map[string]any{"version": "not-a-number"}); ok {
|
||||
t.Fatal("LoadMCPAttachmentHistory() accepted malformed metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSONMap(t *testing.T, value any) map[string]any {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/coder/acp-go-sdk"
|
||||
"github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared"
|
||||
@@ -15,6 +16,12 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const kandevMCPServerName = "kandev"
|
||||
|
||||
// PublishesMCPAttachmentResults reports that this adapter emits attachment
|
||||
// results for the servers that survive its own capability filtering.
|
||||
func (a *Adapter) PublishesMCPAttachmentResults() bool { return true }
|
||||
|
||||
// NewSession creates a new agent session.
|
||||
func (a *Adapter) NewSession(ctx context.Context, mcpServers []types.McpServer) (string, error) {
|
||||
a.mu.Lock()
|
||||
@@ -43,12 +50,22 @@ func (a *Adapter) NewSession(ctx context.Context, mcpServers []types.McpServer)
|
||||
defer span.End()
|
||||
|
||||
caps := effectiveMcpCapabilities(a.capabilities.McpCapabilities, a.cfg)
|
||||
filteredServers := filterMcpServersByCapabilities(mcpServers, caps, a.logger)
|
||||
filteredServers, decisions := filterMcpServersWithDecisions(mcpServers, caps, a.logger)
|
||||
for _, decision := range decisions {
|
||||
kind := streams.MCPAttachmentEvidenceDelivered
|
||||
if !decision.Included {
|
||||
kind = streams.MCPAttachmentEvidenceFiltered
|
||||
}
|
||||
a.emitMCPAttachmentEvidence(ctx, decision.Server, kind, decision.ReasonCode, "")
|
||||
}
|
||||
resp, err := conn.NewSession(ctx, acp.NewSessionRequest{
|
||||
Cwd: a.cfg.WorkDir,
|
||||
McpServers: toACPMcpServers(filteredServers),
|
||||
})
|
||||
if err != nil {
|
||||
for _, server := range filteredServers {
|
||||
a.emitMCPAttachmentEvidence(ctx, server, streams.MCPAttachmentEvidenceExplicitError, "session_new_failed", err.Error())
|
||||
}
|
||||
// An agent may have emitted provisional usage before returning an RPC
|
||||
// error. Clear at the FIFO boundary so an already queued notification
|
||||
// cannot repopulate the tracker after this failure cleanup.
|
||||
@@ -61,6 +78,9 @@ func (a *Adapter) NewSession(ctx context.Context, mcpServers []types.McpServer)
|
||||
}
|
||||
return "", fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
for _, server := range filteredServers {
|
||||
a.emitMCPAttachmentEvidence(ctx, server, streams.MCPAttachmentEvidenceSessionAccepted, "", "")
|
||||
}
|
||||
|
||||
sessionID := string(resp.SessionId)
|
||||
if !a.syncNotifQueueThen(func() {
|
||||
@@ -184,31 +204,103 @@ func effectiveMcpCapabilities(caps acp.McpCapabilities, cfg *shared.Config) acp.
|
||||
// surviving entry is kept to prevent duplicate tool registration.
|
||||
//
|
||||
//nolint:goconst // "sse"/"http"/"streamable_http" are ACP protocol-type string literals; constants would obscure the type discriminant
|
||||
const (
|
||||
mcpFilterReasonSSEUnsupported = "sse_unsupported"
|
||||
mcpFilterReasonHTTPUnsupported = "http_unsupported"
|
||||
mcpFilterReasonDuplicateName = "duplicate_name"
|
||||
)
|
||||
|
||||
type mcpServerFilterDecision struct {
|
||||
Server types.McpServer
|
||||
Included bool
|
||||
ReasonCode string
|
||||
}
|
||||
|
||||
func filterMcpServersByCapabilities(servers []types.McpServer, caps acp.McpCapabilities, logger *logger.Logger) []types.McpServer {
|
||||
filtered, _ := filterMcpServersWithDecisions(servers, caps, logger)
|
||||
return filtered
|
||||
}
|
||||
|
||||
// filterMcpServersWithDecisions preserves the existing first-surviving-server
|
||||
// selection while retaining an explicit decision for every input server.
|
||||
func filterMcpServersWithDecisions(
|
||||
servers []types.McpServer,
|
||||
caps acp.McpCapabilities,
|
||||
logger *logger.Logger,
|
||||
) ([]types.McpServer, []mcpServerFilterDecision) {
|
||||
filtered := make([]types.McpServer, 0, len(servers))
|
||||
decisions := make([]mcpServerFilterDecision, 0, len(servers))
|
||||
seenNames := make(map[string]bool)
|
||||
for _, s := range servers {
|
||||
decision := mcpServerFilterDecision{Server: s}
|
||||
switch s.Type {
|
||||
case "sse":
|
||||
if !caps.Sse {
|
||||
logger.Warn("filtering out SSE MCP server (agent does not support SSE)", zap.String("name", s.Name))
|
||||
decision.ReasonCode = mcpFilterReasonSSEUnsupported
|
||||
decisions = append(decisions, decision)
|
||||
continue
|
||||
}
|
||||
case "http", "streamable_http":
|
||||
if !caps.Http {
|
||||
logger.Warn("filtering out HTTP MCP server (agent does not support HTTP)", zap.String("name", s.Name), zap.String("type", s.Type))
|
||||
decision.ReasonCode = mcpFilterReasonHTTPUnsupported
|
||||
decisions = append(decisions, decision)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Skip duplicate names - first surviving entry wins
|
||||
if seenNames[s.Name] {
|
||||
logger.Debug("skipping duplicate MCP server name", zap.String("name", s.Name), zap.String("type", s.Type))
|
||||
decision.ReasonCode = mcpFilterReasonDuplicateName
|
||||
decisions = append(decisions, decision)
|
||||
continue
|
||||
}
|
||||
seenNames[s.Name] = true
|
||||
filtered = append(filtered, s)
|
||||
decision.Included = true
|
||||
decisions = append(decisions, decision)
|
||||
}
|
||||
return filtered
|
||||
return filtered, decisions
|
||||
}
|
||||
|
||||
// emitMCPAttachmentEvidence forwards only safe, backend-attributed attachment
|
||||
// facts through the adapter's existing update stream. A missing context means
|
||||
// this call was made outside agentctl's lifecycle boundary, so it deliberately
|
||||
// produces no ambiguous attribution.
|
||||
func (a *Adapter) emitMCPAttachmentEvidence(
|
||||
ctx context.Context,
|
||||
server types.McpServer,
|
||||
kind streams.MCPAttachmentEvidenceKind,
|
||||
reasonCode string,
|
||||
summary string,
|
||||
) {
|
||||
attachmentContext, ok := streams.MCPAttachmentContextFromContext(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
target := streams.SanitizeMCPStdioTarget(server.Command)
|
||||
if server.Type == "sse" || server.Type == "http" || server.Type == "streamable_http" {
|
||||
target = streams.SanitizeMCPNetworkTarget(server.URL)
|
||||
}
|
||||
source := streams.MCPServerSourceProfile
|
||||
if server.Name == kandevMCPServerName {
|
||||
source = streams.MCPServerSourceKandev
|
||||
}
|
||||
a.sendUpdate(AgentEvent{
|
||||
Type: streams.EventTypeMCPAttachment,
|
||||
MCPAttachment: &streams.MCPAttachmentEvidence{
|
||||
AttemptID: attachmentContext.Attempt.AttemptID,
|
||||
ServerName: server.Name,
|
||||
Kind: kind,
|
||||
OccurredAt: time.Now().UTC(),
|
||||
Source: source,
|
||||
Transport: server.Type,
|
||||
Target: target,
|
||||
ReasonCode: reasonCode,
|
||||
Summary: streams.SanitizeMCPErrorSummary(summary),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
//nolint:goconst // "sse"/"http"/"streamable_http" are ACP protocol-type string literals; constants would obscure the type discriminant
|
||||
@@ -318,7 +410,14 @@ func (a *Adapter) LoadSession(ctx context.Context, sessionID string, mcpServers
|
||||
|
||||
// Filter MCP servers by agent capabilities (same logic as NewSession).
|
||||
caps := effectiveMcpCapabilities(a.capabilities.McpCapabilities, a.cfg)
|
||||
filteredServers := filterMcpServersByCapabilities(mcpServers, caps, a.logger)
|
||||
filteredServers, decisions := filterMcpServersWithDecisions(mcpServers, caps, a.logger)
|
||||
for _, decision := range decisions {
|
||||
kind := streams.MCPAttachmentEvidenceDelivered
|
||||
if !decision.Included {
|
||||
kind = streams.MCPAttachmentEvidenceFiltered
|
||||
}
|
||||
a.emitMCPAttachmentEvidence(ctx, decision.Server, kind, decision.ReasonCode, "")
|
||||
}
|
||||
|
||||
// Suppress history replay notifications during load.
|
||||
// ACP session/load replays the entire conversation history asynchronously.
|
||||
@@ -337,6 +436,9 @@ func (a *Adapter) LoadSession(ctx context.Context, sessionID string, mcpServers
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
for _, server := range filteredServers {
|
||||
a.emitMCPAttachmentEvidence(ctx, server, streams.MCPAttachmentEvidenceExplicitError, "session_load_failed", err.Error())
|
||||
}
|
||||
// Notifications sent before the failed RPC response may still be queued
|
||||
// on the adapter worker. Clear replay suppression only at a FIFO barrier,
|
||||
// otherwise those frames can escape as live messages, tools, or plans
|
||||
@@ -355,6 +457,9 @@ func (a *Adapter) LoadSession(ctx context.Context, sessionID string, mcpServers
|
||||
span.RecordError(err)
|
||||
return fmt.Errorf("failed to load session: %w", err)
|
||||
}
|
||||
for _, server := range filteredServers {
|
||||
a.emitMCPAttachmentEvidence(ctx, server, streams.MCPAttachmentEvidenceSessionAccepted, "", "")
|
||||
}
|
||||
|
||||
// The SDK may finish the load RPC while replay notifications are still
|
||||
// queued in the adapter worker. Keep suppression active until a FIFO barrier
|
||||
|
||||
@@ -1,14 +1,89 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/acp-go-sdk"
|
||||
"github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared"
|
||||
"github.com/kandev/kandev/internal/agentctl/types"
|
||||
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
)
|
||||
|
||||
func TestEmitMCPAttachmentEvidenceUsesBackendOwnedAttempt(t *testing.T) {
|
||||
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
|
||||
if err != nil {
|
||||
t.Fatalf("new logger: %v", err)
|
||||
}
|
||||
a := &Adapter{updatesCh: make(chan AgentEvent, 1), logger: log}
|
||||
ctx := streams.WithMCPAttachmentContext(context.Background(), streams.MCPAttachmentAttempt{
|
||||
AttemptID: "attempt-1", TaskID: "task-1", SessionID: "session-1", ExecutionID: "execution-1",
|
||||
})
|
||||
|
||||
a.emitMCPAttachmentEvidence(ctx, types.McpServer{
|
||||
Name: "kandev", Type: "http", URL: "https://token@example.test/mcp",
|
||||
}, streams.MCPAttachmentEvidenceDelivered, "", "")
|
||||
|
||||
select {
|
||||
case event := <-a.updatesCh:
|
||||
if event.Type != streams.EventTypeMCPAttachment || event.MCPAttachment == nil {
|
||||
t.Fatalf("event = %+v, want MCP attachment", event)
|
||||
}
|
||||
if event.MCPAttachment.AttemptID != "attempt-1" || event.MCPAttachment.Target != "https://example.test" {
|
||||
t.Fatalf("evidence = %+v", event.MCPAttachment)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected attachment evidence")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterMcpServersWithDecisionsReportsFilteredAndDuplicateServers(t *testing.T) {
|
||||
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
|
||||
if err != nil {
|
||||
t.Fatalf("new logger: %v", err)
|
||||
}
|
||||
servers := []types.McpServer{
|
||||
{Name: "kandev", Type: "http", URL: "https://kandev.example/mcp"},
|
||||
{Name: "kandev", Type: "sse", URL: "https://kandev.example/sse"},
|
||||
{Name: "stdio", Type: "stdio", Command: "mcp-server"},
|
||||
}
|
||||
|
||||
selected, decisions := filterMcpServersWithDecisions(servers, acp.McpCapabilities{Sse: true}, log)
|
||||
if len(selected) != 2 || selected[0].Type != "sse" || selected[1].Name != "stdio" {
|
||||
t.Fatalf("selected = %+v, want SSE kandev and stdio", selected)
|
||||
}
|
||||
if len(decisions) != len(servers) {
|
||||
t.Fatalf("decision count = %d, want %d", len(decisions), len(servers))
|
||||
}
|
||||
if decisions[0].Included || decisions[0].ReasonCode != mcpFilterReasonHTTPUnsupported {
|
||||
t.Fatalf("HTTP decision = %+v", decisions[0])
|
||||
}
|
||||
if !decisions[1].Included || decisions[1].ReasonCode != "" {
|
||||
t.Fatalf("SSE decision = %+v", decisions[1])
|
||||
}
|
||||
if !decisions[2].Included || decisions[2].ReasonCode != "" {
|
||||
t.Fatalf("stdio decision = %+v", decisions[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterMcpServersWithDecisionsMarksSupportedDuplicateAsFiltered(t *testing.T) {
|
||||
log := newTestLoggerForMcp()
|
||||
servers := []types.McpServer{
|
||||
{Name: "kandev", Type: "http", URL: "https://kandev.example/mcp"},
|
||||
{Name: "kandev", Type: "sse", URL: "https://kandev.example/sse"},
|
||||
}
|
||||
|
||||
selected, decisions := filterMcpServersWithDecisions(servers, acp.McpCapabilities{Http: true, Sse: true}, log)
|
||||
if len(selected) != 1 || selected[0].Type != "http" {
|
||||
t.Fatalf("selected = %+v, want one HTTP kandev server", selected)
|
||||
}
|
||||
if decisions[1].Included || decisions[1].ReasonCode != mcpFilterReasonDuplicateName {
|
||||
t.Fatalf("duplicate decision = %+v", decisions[1])
|
||||
}
|
||||
}
|
||||
|
||||
// --- mapToEnvVars ---
|
||||
|
||||
func TestMapToEnvVars_Empty(t *testing.T) {
|
||||
|
||||
@@ -9,10 +9,12 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/kandev/kandev/internal/agentctl/server/adapter"
|
||||
acptransport "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/acp"
|
||||
"github.com/kandev/kandev/internal/agentctl/types"
|
||||
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
||||
"github.com/kandev/kandev/internal/common/constants"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
@@ -347,6 +349,80 @@ func (s *Server) injectKandevMcpServers(mcpServers []types.McpServer) []types.Mc
|
||||
return filtered
|
||||
}
|
||||
|
||||
// startMCPAttachmentAttempt records backend-owned identity before an adapter
|
||||
// receives MCP configuration. The context is passed only to the adapter call;
|
||||
// agents cannot choose the task, session, execution, or attempt identities.
|
||||
func (s *Server) startMCPAttachmentAttempt(ctx context.Context, servers []types.McpServer) context.Context {
|
||||
attempt := streams.MCPAttachmentAttempt{
|
||||
AttemptID: uuid.NewString(),
|
||||
TaskID: s.cfg.TaskID,
|
||||
SessionID: s.cfg.SessionID,
|
||||
ExecutionID: s.cfg.InstanceID,
|
||||
AgentID: s.cfg.AgentType,
|
||||
StartedAt: time.Now().UTC(),
|
||||
}
|
||||
s.procMgr.PublishMCPAttachmentAttempt(attempt)
|
||||
if s.mcpServer != nil {
|
||||
s.mcpServer.SetAttachmentAttempt(attempt)
|
||||
}
|
||||
for _, mcpServer := range servers {
|
||||
s.procMgr.PublishMCPAttachment(mcpAttachmentEvidence(attempt.AttemptID, mcpServer, streams.MCPAttachmentEvidenceConfigured, "", ""))
|
||||
}
|
||||
return streams.WithMCPAttachmentContext(ctx, attempt)
|
||||
}
|
||||
|
||||
func (s *Server) publishMCPAttachmentResult(attemptID string, servers []types.McpServer, err error) {
|
||||
if adapter, ok := s.procMgr.GetAdapter().(mcpAttachmentResultPublisher); ok && adapter.PublishesMCPAttachmentResults() {
|
||||
return
|
||||
}
|
||||
kind := streams.MCPAttachmentEvidenceSessionAccepted
|
||||
reasonCode := ""
|
||||
summary := ""
|
||||
if err != nil {
|
||||
kind = streams.MCPAttachmentEvidenceExplicitError
|
||||
reasonCode = "session_request_failed"
|
||||
summary = err.Error()
|
||||
}
|
||||
for _, mcpServer := range servers {
|
||||
s.procMgr.PublishMCPAttachment(mcpAttachmentEvidence(attemptID, mcpServer, kind, reasonCode, summary))
|
||||
}
|
||||
}
|
||||
|
||||
// mcpAttachmentResultPublisher identifies adapters that emit per-server
|
||||
// delivery and session-result evidence after their own MCP capability filter.
|
||||
// Generic adapters still receive result evidence from this API boundary.
|
||||
type mcpAttachmentResultPublisher interface {
|
||||
PublishesMCPAttachmentResults() bool
|
||||
}
|
||||
|
||||
func mcpAttachmentEvidence(
|
||||
attemptID string,
|
||||
mcpServer types.McpServer,
|
||||
kind streams.MCPAttachmentEvidenceKind,
|
||||
reasonCode string,
|
||||
summary string,
|
||||
) streams.MCPAttachmentEvidence {
|
||||
target := streams.SanitizeMCPStdioTarget(mcpServer.Command)
|
||||
if mcpServer.Type == mcpTransportHTTP || mcpServer.Type == mcpTransportSSE || mcpServer.Type == "streamable_http" {
|
||||
target = streams.SanitizeMCPNetworkTarget(mcpServer.URL)
|
||||
}
|
||||
source := streams.MCPServerSourceProfile
|
||||
if mcpServer.Name == kandevMcpServerName {
|
||||
source = streams.MCPServerSourceKandev
|
||||
}
|
||||
return streams.MCPAttachmentEvidence{
|
||||
AttemptID: attemptID,
|
||||
ServerName: mcpServer.Name,
|
||||
Kind: kind,
|
||||
OccurredAt: time.Now().UTC(),
|
||||
Source: source,
|
||||
Transport: mcpServer.Type,
|
||||
Target: target,
|
||||
ReasonCode: reasonCode,
|
||||
Summary: streams.SanitizeMCPErrorSummary(summary),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleWSNewSession(ctx context.Context, msg *ws.Message) *ws.Message {
|
||||
var req NewSessionRequest
|
||||
if err := msg.ParsePayload(&req); err != nil {
|
||||
@@ -376,7 +452,10 @@ func (s *Server) handleWSNewSession(ctx context.Context, msg *ws.Message) *ws.Me
|
||||
mcpServers = s.injectKandevMcpServers(mcpServers)
|
||||
}
|
||||
|
||||
ctx = s.startMCPAttachmentAttempt(ctx, mcpServers)
|
||||
attachmentContext, _ := streams.MCPAttachmentContextFromContext(ctx)
|
||||
sessionID, err := adapter.NewSession(ctx, mcpServers)
|
||||
s.publishMCPAttachmentResult(attachmentContext.Attempt.AttemptID, mcpServers, err)
|
||||
if err != nil {
|
||||
s.logger.Error("new session failed", zap.Error(err))
|
||||
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil)
|
||||
@@ -423,11 +502,15 @@ func (s *Server) handleWSLoadSession(ctx context.Context, msg *ws.Message) *ws.M
|
||||
mcpServers = s.injectKandevMcpServers(mcpServers)
|
||||
}
|
||||
|
||||
ctx = s.startMCPAttachmentAttempt(ctx, mcpServers)
|
||||
attachmentContext, _ := streams.MCPAttachmentContextFromContext(ctx)
|
||||
if err := adapter.LoadSession(ctx, req.SessionID, mcpServers); err != nil {
|
||||
s.publishMCPAttachmentResult(attachmentContext.Attempt.AttemptID, mcpServers, err)
|
||||
s.logger.Error("load session failed", zap.Error(err))
|
||||
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil)
|
||||
return resp
|
||||
}
|
||||
s.publishMCPAttachmentResult(attachmentContext.Attempt.AttemptID, mcpServers, nil)
|
||||
|
||||
resp, _ := ws.NewResponse(msg.ID, msg.Action, LoadSessionResponse{
|
||||
Success: true,
|
||||
@@ -651,7 +734,10 @@ func (s *Server) handleWSResetSession(ctx context.Context, msg *ws.Message) *ws.
|
||||
mcpServers = s.injectKandevMcpServers(mcpServers)
|
||||
}
|
||||
|
||||
ctx = s.startMCPAttachmentAttempt(ctx, mcpServers)
|
||||
attachmentContext, _ := streams.MCPAttachmentContextFromContext(ctx)
|
||||
sessionID, err := sr.ResetSession(ctx, mcpServers)
|
||||
s.publishMCPAttachmentResult(attachmentContext.Attempt.AttemptID, mcpServers, err)
|
||||
if err != nil {
|
||||
s.logger.Error("session reset failed", zap.Error(err))
|
||||
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil)
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/agentctl/server/config"
|
||||
"github.com/kandev/kandev/internal/agentctl/server/process"
|
||||
"github.com/kandev/kandev/internal/agentctl/types"
|
||||
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
mcpserver "github.com/kandev/kandev/internal/mcp/server"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
@@ -258,12 +259,68 @@ func TestHandleWSNewSession_UsesExtendedDeadline(t *testing.T) {
|
||||
if resp.Type != ws.MessageTypeResponse {
|
||||
t.Fatalf("response type = %q, want %q", resp.Type, ws.MessageTypeResponse)
|
||||
}
|
||||
var payload NewSessionResponse
|
||||
if err := resp.ParsePayload(&payload); err != nil {
|
||||
t.Fatalf("ParsePayload: %v", err)
|
||||
}
|
||||
if !payload.Success || payload.SessionID != "session-1" {
|
||||
t.Fatalf("response payload = %+v", payload)
|
||||
}
|
||||
const want = 2 * time.Minute
|
||||
if capture.remaining < want-time.Second || capture.remaining > want {
|
||||
t.Fatalf("session/new deadline = %v from now, want about %v", capture.remaining, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWSNewSessionStartsAndConfiguresMCPAttachmentAttempt(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
cfg := &config.InstanceConfig{
|
||||
Port: 0,
|
||||
WorkDir: t.TempDir(),
|
||||
InstanceID: "execution-1",
|
||||
TaskID: "task-1",
|
||||
SessionID: "session-1",
|
||||
AgentType: "auggie",
|
||||
}
|
||||
procMgr := process.NewManager(cfg, log)
|
||||
s := NewServer(cfg, procMgr, nil, nil, log)
|
||||
s.procMgr.SetAdapterForTest(&mcpCaptureAdapter{})
|
||||
|
||||
msg, err := ws.NewRequest("req-1", "agent.session.new", NewSessionRequest{McpServers: []types.McpServer{{
|
||||
Name: "profile-server", Type: "http", URL: "https://user:secret@mcp.example.test/path",
|
||||
}}})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest: %v", err)
|
||||
}
|
||||
if response := s.handleWSNewSession(context.Background(), msg); response.Type != ws.MessageTypeResponse {
|
||||
t.Fatalf("response type = %q, want response", response.Type)
|
||||
}
|
||||
|
||||
readUpdate := func() adapter.AgentEvent {
|
||||
select {
|
||||
case event := <-s.procMgr.GetUpdates():
|
||||
return event
|
||||
default:
|
||||
t.Fatal("expected MCP attachment update")
|
||||
return adapter.AgentEvent{}
|
||||
}
|
||||
}
|
||||
first := readUpdate()
|
||||
if first.Type != streams.EventTypeMCPAttachment || first.MCPAttachmentAttempt == nil {
|
||||
t.Fatalf("first event = %+v, want attachment attempt", first)
|
||||
}
|
||||
if got := first.MCPAttachmentAttempt; got.TaskID != "task-1" || got.SessionID != "session-1" || got.ExecutionID != "execution-1" {
|
||||
t.Fatalf("attempt identity = %+v", got)
|
||||
}
|
||||
second := readUpdate()
|
||||
if second.MCPAttachment == nil || second.MCPAttachment.Kind != streams.MCPAttachmentEvidenceConfigured {
|
||||
t.Fatalf("second event = %+v, want configured evidence", second)
|
||||
}
|
||||
if second.MCPAttachment.Target != "https://mcp.example.test" {
|
||||
t.Fatalf("configured target = %q, want sanitized host", second.MCPAttachment.Target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWSLoadSession_NoAdapter(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
ctx := context.Background()
|
||||
@@ -401,6 +458,10 @@ type mcpCaptureAdapter struct {
|
||||
loadSessionServers []types.McpServer
|
||||
}
|
||||
|
||||
type mcpResultCaptureAdapter struct{ mcpCaptureAdapter }
|
||||
|
||||
func (*mcpResultCaptureAdapter) PublishesMCPAttachmentResults() bool { return true }
|
||||
|
||||
type newSessionDeadlineCaptureAdapter struct {
|
||||
promptErrorAdapter
|
||||
remaining time.Duration
|
||||
@@ -426,6 +487,22 @@ func (a *mcpCaptureAdapter) LoadSession(_ context.Context, sessionID string, ser
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestPublishMCPAttachmentResultDoesNotDuplicateAdapterFilteredEvidence(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s.procMgr.SetAdapterForTest(&mcpResultCaptureAdapter{})
|
||||
|
||||
s.publishMCPAttachmentResult("attempt-1", []types.McpServer{
|
||||
{Name: "kandev", Type: "http", URL: "https://kandev.example/mcp"},
|
||||
{Name: "kandev", Type: "sse", URL: "https://kandev.example/sse"},
|
||||
}, nil)
|
||||
|
||||
select {
|
||||
case event := <-s.procMgr.GetUpdates():
|
||||
t.Fatalf("unexpected generic attachment result: %+v", event)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (a *promptErrorAdapter) PrepareEnvironment() (map[string]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -1413,6 +1413,37 @@ func (m *Manager) SendErrorEvent(errorMessage string, promptGeneration uint64) {
|
||||
}
|
||||
}
|
||||
|
||||
// PublishMCPAttachment forwards safe MCP attachment evidence through the
|
||||
// existing agent update stream. Diagnostics are best effort: an overloaded
|
||||
// consumer must never delay the agent process or create a second stream.
|
||||
func (m *Manager) PublishMCPAttachment(evidence streams.MCPAttachmentEvidence) {
|
||||
select {
|
||||
case m.updatesCh <- adapter.AgentEvent{
|
||||
Type: streams.EventTypeMCPAttachment,
|
||||
MCPAttachment: &evidence,
|
||||
}:
|
||||
default:
|
||||
m.logger.Warn("updates channel full, dropping MCP attachment evidence",
|
||||
zap.String("attempt_id", evidence.AttemptID),
|
||||
zap.String("server_name", evidence.ServerName),
|
||||
zap.String("kind", string(evidence.Kind)))
|
||||
}
|
||||
}
|
||||
|
||||
// PublishMCPAttachmentAttempt starts an attachment-evidence timeline through
|
||||
// the existing non-blocking agent update stream.
|
||||
func (m *Manager) PublishMCPAttachmentAttempt(attempt streams.MCPAttachmentAttempt) {
|
||||
select {
|
||||
case m.updatesCh <- adapter.AgentEvent{
|
||||
Type: streams.EventTypeMCPAttachment,
|
||||
MCPAttachmentAttempt: &attempt,
|
||||
}:
|
||||
default:
|
||||
m.logger.Warn("updates channel full, dropping MCP attachment attempt",
|
||||
zap.String("attempt_id", attempt.AttemptID))
|
||||
}
|
||||
}
|
||||
|
||||
// GetAdapter returns the protocol adapter
|
||||
func (m *Manager) GetAdapter() adapter.AgentAdapter {
|
||||
m.mu.RLock()
|
||||
|
||||
@@ -14,12 +14,64 @@ import (
|
||||
"github.com/kandev/kandev/internal/agentctl/server/adapter"
|
||||
"github.com/kandev/kandev/internal/agentctl/server/config"
|
||||
"github.com/kandev/kandev/internal/agentctl/types"
|
||||
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
||||
"github.com/kandev/kandev/pkg/agent"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPublishMCPAttachmentPublishesWithoutBlocking(t *testing.T) {
|
||||
m := &Manager{
|
||||
updatesCh: make(chan adapter.AgentEvent, 1),
|
||||
logger: newTestLogger(t),
|
||||
}
|
||||
evidence := streams.MCPAttachmentEvidence{
|
||||
AttemptID: "attempt-1",
|
||||
ServerName: "kandev",
|
||||
Kind: streams.MCPAttachmentEvidenceDelivered,
|
||||
}
|
||||
|
||||
m.PublishMCPAttachment(evidence)
|
||||
|
||||
select {
|
||||
case event := <-m.updatesCh:
|
||||
if event.Type != streams.EventTypeMCPAttachment {
|
||||
t.Fatalf("event type = %q, want MCP attachment", event.Type)
|
||||
}
|
||||
if event.MCPAttachment == nil || event.MCPAttachment.AttemptID != "attempt-1" {
|
||||
t.Fatalf("attachment event = %+v, want attempt evidence", event.MCPAttachment)
|
||||
}
|
||||
default:
|
||||
t.Fatal("expected MCP attachment update")
|
||||
}
|
||||
|
||||
// A saturated diagnostics channel must not stall agent lifecycle work.
|
||||
m.updatesCh <- adapter.AgentEvent{Type: streams.EventTypeMessageChunk}
|
||||
m.PublishMCPAttachment(evidence)
|
||||
if got := len(m.updatesCh); got != 1 {
|
||||
t.Fatalf("queued events = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishMCPAttachmentAttemptCarriesBackendOwnedIdentity(t *testing.T) {
|
||||
m := &Manager{updatesCh: make(chan adapter.AgentEvent, 1), logger: newTestLogger(t)}
|
||||
m.PublishMCPAttachmentAttempt(streams.MCPAttachmentAttempt{
|
||||
AttemptID: "attempt-1",
|
||||
TaskID: "task-1",
|
||||
SessionID: "session-1",
|
||||
ExecutionID: "execution-1",
|
||||
})
|
||||
|
||||
event := <-m.updatesCh
|
||||
if event.MCPAttachmentAttempt == nil {
|
||||
t.Fatal("expected MCP attachment attempt")
|
||||
}
|
||||
if got := event.MCPAttachmentAttempt; got.TaskID != "task-1" || got.ExecutionID != "execution-1" {
|
||||
t.Fatalf("attempt identity = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// stubAdapter is a minimal AgentAdapter for testing the startup sequence.
|
||||
type stubAdapter struct {
|
||||
connectCalled bool
|
||||
|
||||
@@ -73,6 +73,11 @@ const (
|
||||
// methods (from ACP initialize); the client picks one and replays the
|
||||
// authenticate → session/new round-trip.
|
||||
EventTypeAuthRequired = "auth_required"
|
||||
|
||||
// EventTypeMCPAttachment carries safe, session-scoped evidence about MCP
|
||||
// configuration delivery and observed use. It deliberately excludes raw
|
||||
// protocol frames, tool payloads, credentials, and full endpoints.
|
||||
EventTypeMCPAttachment = "mcp_attachment"
|
||||
)
|
||||
|
||||
// AgentEventDataPromptHandoff marks a generation-bearing foreground-idle event
|
||||
@@ -166,6 +171,14 @@ type AgentEvent struct {
|
||||
// Error contains error message when Type is "error".
|
||||
Error string `json:"error,omitempty"`
|
||||
|
||||
// MCPAttachment contains one safe observation from the MCP attachment
|
||||
// lifecycle when Type is EventTypeMCPAttachment.
|
||||
MCPAttachment *MCPAttachmentEvidence `json:"mcp_attachment,omitempty"`
|
||||
|
||||
// MCPAttachmentAttempt starts a new backend-owned evidence timeline. It is
|
||||
// present only on the first EventTypeMCPAttachment event for an attempt.
|
||||
MCPAttachmentAttempt *MCPAttachmentAttempt `json:"mcp_attachment_attempt,omitempty"`
|
||||
|
||||
// --- Permission request fields (for "permission_request" type) ---
|
||||
|
||||
// PendingID uniquely identifies this pending permission request.
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/runtime/routingerr"
|
||||
)
|
||||
|
||||
type mcpAttachmentContextKey struct{}
|
||||
|
||||
// MCPAttachmentContext is backend-owned identity attached to one new, load,
|
||||
// reset, or passthrough attempt. It never accepts identity from agent protocol
|
||||
// frames.
|
||||
type MCPAttachmentContext struct {
|
||||
Attempt MCPAttachmentAttempt
|
||||
}
|
||||
|
||||
// WithMCPAttachmentContext carries an attempt through an adapter call so its
|
||||
// protocol-level observations can be attributed without creating a side
|
||||
// channel.
|
||||
func WithMCPAttachmentContext(ctx context.Context, attempt MCPAttachmentAttempt) context.Context {
|
||||
return context.WithValue(ctx, mcpAttachmentContextKey{}, MCPAttachmentContext{Attempt: attempt})
|
||||
}
|
||||
|
||||
// MCPAttachmentContextFromContext returns backend-owned attachment identity.
|
||||
func MCPAttachmentContextFromContext(ctx context.Context) (MCPAttachmentContext, bool) {
|
||||
value, ok := ctx.Value(mcpAttachmentContextKey{}).(MCPAttachmentContext)
|
||||
return value, ok && value.Attempt.AttemptID != ""
|
||||
}
|
||||
|
||||
const (
|
||||
// MCPAttachmentSchemaVersion versions persisted session attachment metadata.
|
||||
MCPAttachmentSchemaVersion = 1
|
||||
|
||||
// MaxMCPAttachmentAttempts retains the current attachment attempt and its
|
||||
// two immediate predecessors for release-safe diagnostics.
|
||||
MaxMCPAttachmentAttempts = 3
|
||||
|
||||
// MaxMCPAttachmentEvidenceEvents bounds one attempt's diagnostic timeline.
|
||||
MaxMCPAttachmentEvidenceEvents = 64
|
||||
|
||||
// MaxMCPAttachmentErrorSummaryBytes caps persisted error summaries after
|
||||
// redaction so an agent error cannot inflate session metadata.
|
||||
MaxMCPAttachmentErrorSummaryBytes = 256
|
||||
)
|
||||
|
||||
// MCPAttachmentStatus is the strongest user-visible attachment evidence for a
|
||||
// server in the current attempt. Missing evidence is deliberately not failure.
|
||||
type MCPAttachmentStatus string
|
||||
|
||||
const (
|
||||
MCPAttachmentStatusUnknown MCPAttachmentStatus = "unknown"
|
||||
MCPAttachmentStatusDelivered MCPAttachmentStatus = "delivered"
|
||||
MCPAttachmentStatusConnected MCPAttachmentStatus = "connected"
|
||||
MCPAttachmentStatusActive MCPAttachmentStatus = "active"
|
||||
MCPAttachmentStatusFailed MCPAttachmentStatus = "failed"
|
||||
MCPAttachmentStatusFiltered MCPAttachmentStatus = "filtered"
|
||||
MCPAttachmentStatusUnavailable MCPAttachmentStatus = "unavailable"
|
||||
)
|
||||
|
||||
// MCPAttachmentEvidenceKind identifies one safe-to-persist attachment fact.
|
||||
type MCPAttachmentEvidenceKind string
|
||||
|
||||
const (
|
||||
MCPAttachmentEvidenceConfigured MCPAttachmentEvidenceKind = "configured"
|
||||
MCPAttachmentEvidenceFiltered MCPAttachmentEvidenceKind = "filtered"
|
||||
MCPAttachmentEvidenceUnavailable MCPAttachmentEvidenceKind = "unavailable"
|
||||
MCPAttachmentEvidenceDelivered MCPAttachmentEvidenceKind = "delivered"
|
||||
MCPAttachmentEvidenceSessionAccepted MCPAttachmentEvidenceKind = "session_accepted"
|
||||
MCPAttachmentEvidenceInitializeObserved MCPAttachmentEvidenceKind = "initialize_observed"
|
||||
MCPAttachmentEvidenceToolsListObserved MCPAttachmentEvidenceKind = "tools_list_observed"
|
||||
MCPAttachmentEvidenceToolCallObserved MCPAttachmentEvidenceKind = "tool_call_observed"
|
||||
MCPAttachmentEvidenceExplicitError MCPAttachmentEvidenceKind = "explicit_error"
|
||||
MCPAttachmentEvidenceConnectionClosed MCPAttachmentEvidenceKind = "connection_closed"
|
||||
MCPAttachmentEvidenceAttemptSuperseded MCPAttachmentEvidenceKind = "attempt_superseded"
|
||||
)
|
||||
|
||||
// MCPServerSource identifies how an MCP server entered an attachment attempt.
|
||||
type MCPServerSource string
|
||||
|
||||
const (
|
||||
MCPServerSourceKandev MCPServerSource = "kandev"
|
||||
MCPServerSourceProfile MCPServerSource = "profile"
|
||||
)
|
||||
|
||||
// MCPAttachmentTestResult records a user-requested endpoint reachability test.
|
||||
// It intentionally remains separate from agent attachment evidence.
|
||||
type MCPAttachmentTestResult struct {
|
||||
Succeeded bool `json:"succeeded"`
|
||||
TestedAt time.Time `json:"tested_at"`
|
||||
ReasonCode string `json:"reason_code,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
// MCPServerAttachment is the safe display projection for one MCP server.
|
||||
// It deliberately has no headers, environment, command arguments, tool input,
|
||||
// tool result, or full endpoint URL fields.
|
||||
type MCPServerAttachment struct {
|
||||
Name string `json:"name"`
|
||||
Source MCPServerSource `json:"source,omitempty"`
|
||||
Transport string `json:"transport,omitempty"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Status MCPAttachmentStatus `json:"status"`
|
||||
ReasonCode string `json:"reason_code,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
ConnectionID string `json:"connection_id,omitempty"`
|
||||
ToolCount int `json:"tool_count,omitempty"`
|
||||
ConfiguredAt *time.Time `json:"configured_at,omitempty"`
|
||||
DeliveredAt *time.Time `json:"delivered_at,omitempty"`
|
||||
ConnectedAt *time.Time `json:"connected_at,omitempty"`
|
||||
ToolsListedAt *time.Time `json:"tools_listed_at,omitempty"`
|
||||
UsedAt *time.Time `json:"used_at,omitempty"`
|
||||
FailedAt *time.Time `json:"failed_at,omitempty"`
|
||||
DisconnectedAt *time.Time `json:"disconnected_at,omitempty"`
|
||||
EndpointTest *MCPAttachmentTestResult `json:"endpoint_test,omitempty"`
|
||||
}
|
||||
|
||||
// MCPAttachmentEvidence is a safe normalized event. The producer may supply a
|
||||
// raw network/stdio target or error summary, but Apply persists only the
|
||||
// sanitized projection.
|
||||
type MCPAttachmentEvidence struct {
|
||||
AttemptID string `json:"attachment_attempt_id"`
|
||||
ServerName string `json:"server_name,omitempty"`
|
||||
Kind MCPAttachmentEvidenceKind `json:"kind"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
Source MCPServerSource `json:"source,omitempty"`
|
||||
Transport string `json:"transport,omitempty"`
|
||||
Target string `json:"target,omitempty"`
|
||||
ConnectionID string `json:"connection_id,omitempty"`
|
||||
ToolCount int `json:"tool_count,omitempty"`
|
||||
ReasonCode string `json:"reason_code,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
// MCPAttachmentAttempt is one ACP new/load/reset or passthrough process start.
|
||||
type MCPAttachmentAttempt struct {
|
||||
AttemptID string `json:"attachment_attempt_id"`
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
ExecutionID string `json:"execution_id,omitempty"`
|
||||
AgentID string `json:"agent_id,omitempty"`
|
||||
AgentProfileID string `json:"agent_profile_id,omitempty"`
|
||||
ACPSessionID string `json:"acp_session_id,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
SupersededAt *time.Time `json:"superseded_at,omitempty"`
|
||||
Servers []MCPServerAttachment `json:"servers,omitempty"`
|
||||
Evidence []MCPAttachmentEvidence `json:"evidence,omitempty"`
|
||||
SessionError string `json:"session_error,omitempty"`
|
||||
}
|
||||
|
||||
// MCPAttachmentHistory is the bounded persisted state for one Kandev session.
|
||||
type MCPAttachmentHistory struct {
|
||||
Version int `json:"version"`
|
||||
Current MCPAttachmentAttempt `json:"current"`
|
||||
Previous []MCPAttachmentAttempt `json:"previous,omitempty"`
|
||||
}
|
||||
|
||||
// StartAttempt makes attempt current and marks the previous current attempt as
|
||||
// historical, even when both use the same agent execution.
|
||||
func (h *MCPAttachmentHistory) StartAttempt(attempt MCPAttachmentAttempt) {
|
||||
if attempt.AttemptID == "" {
|
||||
return
|
||||
}
|
||||
now := attempt.StartedAt
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
attempt.StartedAt = now
|
||||
}
|
||||
attempt.UpdatedAt = now
|
||||
h.Version = MCPAttachmentSchemaVersion
|
||||
if h.Current.AttemptID != "" && h.Current.AttemptID != attempt.AttemptID {
|
||||
superseded := h.Current
|
||||
superseded.SupersededAt = &now
|
||||
superseded.UpdatedAt = now
|
||||
h.Previous = append([]MCPAttachmentAttempt{superseded}, h.Previous...)
|
||||
}
|
||||
h.Current = attempt
|
||||
if len(h.Previous) > MaxMCPAttachmentAttempts-1 {
|
||||
h.Previous = h.Previous[:MaxMCPAttachmentAttempts-1]
|
||||
}
|
||||
}
|
||||
|
||||
// Apply records current-attempt evidence and returns false for stale or empty
|
||||
// attempts. It is intentionally single-threaded; its lifecycle owner performs
|
||||
// synchronization before reducing stream events.
|
||||
func (h *MCPAttachmentHistory) Apply(evidence MCPAttachmentEvidence) bool {
|
||||
if h.Current.AttemptID == "" || evidence.AttemptID == "" || evidence.AttemptID != h.Current.AttemptID {
|
||||
return false
|
||||
}
|
||||
evidence = sanitizeMCPAttachmentEvidence(evidence)
|
||||
if evidence.OccurredAt.IsZero() {
|
||||
evidence.OccurredAt = time.Now().UTC()
|
||||
}
|
||||
h.Current.UpdatedAt = evidence.OccurredAt
|
||||
h.Current.Evidence = appendBoundedMCPAttachmentEvidence(h.Current.Evidence, evidence)
|
||||
if evidence.ServerName == "" {
|
||||
if evidence.Kind == MCPAttachmentEvidenceExplicitError {
|
||||
h.Current.SessionError = evidence.Summary
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
index := mcpAttachmentServerIndex(h.Current.Servers, evidence.ServerName)
|
||||
if index == -1 {
|
||||
h.Current.Servers = append(h.Current.Servers, MCPServerAttachment{Name: evidence.ServerName, Status: MCPAttachmentStatusUnknown})
|
||||
index = len(h.Current.Servers) - 1
|
||||
}
|
||||
h.Current.Servers[index].applyEvidence(evidence)
|
||||
return true
|
||||
}
|
||||
|
||||
// CurrentServer returns the current attempt's server display projection.
|
||||
func (h MCPAttachmentHistory) CurrentServer(name string) (MCPServerAttachment, bool) {
|
||||
index := mcpAttachmentServerIndex(h.Current.Servers, name)
|
||||
if index == -1 {
|
||||
return MCPServerAttachment{}, false
|
||||
}
|
||||
return h.Current.Servers[index], true
|
||||
}
|
||||
|
||||
// Valid reports whether a history has the current schema and current attempt.
|
||||
func (h MCPAttachmentHistory) Valid() bool {
|
||||
return h.Version == MCPAttachmentSchemaVersion && h.Current.AttemptID != ""
|
||||
}
|
||||
|
||||
func (s *MCPServerAttachment) applyEvidence(evidence MCPAttachmentEvidence) {
|
||||
s.applyEvidenceDetails(evidence)
|
||||
s.applyEvidenceStatus(evidence)
|
||||
}
|
||||
|
||||
func (s *MCPServerAttachment) applyEvidenceDetails(evidence MCPAttachmentEvidence) {
|
||||
if evidence.Source != "" {
|
||||
s.Source = evidence.Source
|
||||
}
|
||||
if evidence.Transport != "" {
|
||||
s.Transport = evidence.Transport
|
||||
}
|
||||
if evidence.Target != "" {
|
||||
s.Target = evidence.Target
|
||||
}
|
||||
if evidence.ConnectionID != "" && evidence.Kind != MCPAttachmentEvidenceConnectionClosed {
|
||||
s.ConnectionID = evidence.ConnectionID
|
||||
}
|
||||
if evidence.Kind == MCPAttachmentEvidenceToolsListObserved {
|
||||
s.ToolCount = evidence.ToolCount
|
||||
}
|
||||
if evidence.ReasonCode != "" {
|
||||
s.ReasonCode = evidence.ReasonCode
|
||||
}
|
||||
if evidence.Summary != "" {
|
||||
s.Summary = evidence.Summary
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (s *MCPServerAttachment) applyEvidenceStatus(evidence MCPAttachmentEvidence) {
|
||||
when := evidence.OccurredAt
|
||||
switch evidence.Kind {
|
||||
case MCPAttachmentEvidenceConfigured:
|
||||
s.ConfiguredAt = &when
|
||||
case MCPAttachmentEvidenceFiltered:
|
||||
s.Status = MCPAttachmentStatusFiltered
|
||||
case MCPAttachmentEvidenceUnavailable:
|
||||
s.Status = MCPAttachmentStatusUnavailable
|
||||
case MCPAttachmentEvidenceDelivered, MCPAttachmentEvidenceSessionAccepted:
|
||||
s.DeliveredAt = &when
|
||||
if s.Status == MCPAttachmentStatusUnknown || s.Status == MCPAttachmentStatusFiltered || s.Status == MCPAttachmentStatusUnavailable {
|
||||
s.Status = MCPAttachmentStatusDelivered
|
||||
}
|
||||
case MCPAttachmentEvidenceInitializeObserved:
|
||||
s.applyInitializeObserved(when)
|
||||
case MCPAttachmentEvidenceToolsListObserved:
|
||||
s.applyToolsListObserved(when)
|
||||
case MCPAttachmentEvidenceToolCallObserved:
|
||||
s.UsedAt = &when
|
||||
case MCPAttachmentEvidenceExplicitError:
|
||||
s.FailedAt = &when
|
||||
s.Status = MCPAttachmentStatusFailed
|
||||
case MCPAttachmentEvidenceConnectionClosed:
|
||||
s.applyConnectionClosed(when, evidence.ConnectionID)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MCPServerAttachment) applyInitializeObserved(when time.Time) {
|
||||
s.ConnectedAt = &when
|
||||
if s.Status != MCPAttachmentStatusActive && s.Status != MCPAttachmentStatusFailed {
|
||||
s.Status = MCPAttachmentStatusConnected
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MCPServerAttachment) applyToolsListObserved(when time.Time) {
|
||||
s.ToolsListedAt = &when
|
||||
if s.Status != MCPAttachmentStatusFailed {
|
||||
s.Status = MCPAttachmentStatusActive
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MCPServerAttachment) applyConnectionClosed(when time.Time, connectionID string) {
|
||||
if connectionID == "" || connectionID == s.ConnectionID {
|
||||
s.DisconnectedAt = &when
|
||||
if s.Status == MCPAttachmentStatusActive {
|
||||
s.Status = MCPAttachmentStatusConnected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mcpAttachmentServerIndex(servers []MCPServerAttachment, name string) int {
|
||||
for index := range servers {
|
||||
if servers[index].Name == name {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func appendBoundedMCPAttachmentEvidence(existing []MCPAttachmentEvidence, evidence MCPAttachmentEvidence) []MCPAttachmentEvidence {
|
||||
existing = append(existing, evidence)
|
||||
if len(existing) > MaxMCPAttachmentEvidenceEvents {
|
||||
return existing[len(existing)-MaxMCPAttachmentEvidenceEvents:]
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
func sanitizeMCPAttachmentEvidence(evidence MCPAttachmentEvidence) MCPAttachmentEvidence {
|
||||
if evidence.Transport == "stdio" {
|
||||
evidence.Target = SanitizeMCPStdioTarget(evidence.Target)
|
||||
} else if evidence.Target != "" {
|
||||
evidence.Target = SanitizeMCPNetworkTarget(evidence.Target)
|
||||
}
|
||||
evidence.Summary = SanitizeMCPErrorSummary(evidence.Summary)
|
||||
return evidence
|
||||
}
|
||||
|
||||
var (
|
||||
mcpURLPattern = regexp.MustCompile(`(?i)(?:https?|wss?)://[^\s"'<>]+`)
|
||||
// Labeled configuration often carries short secrets that generic token
|
||||
// redaction cannot recognize. Drop the label too: names such as API_KEY can
|
||||
// reveal more configuration detail than a release diagnostic needs.
|
||||
mcpLabeledConfigPattern = regexp.MustCompile(`(?i)\b(?:headers?|env|environment|args?|arguments|command)\s*[:=]\s*\S+`)
|
||||
mcpArgumentsPattern = regexp.MustCompile(`(?i)\b(?:args?|arguments|command)\s*[:=]\s*[^\r\n]*`)
|
||||
mcpAbsolutePathPattern = regexp.MustCompile(`(^|[\s"'(=])/(?:[^\s"'<>]+)`)
|
||||
)
|
||||
|
||||
// SanitizeMCPNetworkTarget retains only a network target's scheme and host.
|
||||
func SanitizeMCPNetworkTarget(target string) string {
|
||||
parsed, err := url.Parse(strings.TrimSpace(target))
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return "<invalid endpoint>"
|
||||
}
|
||||
return parsed.Scheme + "://" + parsed.Host
|
||||
}
|
||||
|
||||
// SanitizeMCPStdioTarget retains only the executable basename. Splitting first
|
||||
// protects callers that accidentally pass command arguments as part of target.
|
||||
func SanitizeMCPStdioTarget(command string) string {
|
||||
fields := strings.Fields(strings.TrimSpace(command))
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
return filepath.Base(fields[0])
|
||||
}
|
||||
|
||||
// SanitizeMCPErrorSummary removes credential-like values and full URLs, then
|
||||
// enforces the smaller MCP attachment persistence cap.
|
||||
func SanitizeMCPErrorSummary(summary string) string {
|
||||
summary = mcpArgumentsPattern.ReplaceAllString(summary, "<redacted configuration>")
|
||||
summary = mcpLabeledConfigPattern.ReplaceAllString(summary, "<redacted configuration>")
|
||||
summary = routingerr.Sanitize(summary)
|
||||
summary = mcpAbsolutePathPattern.ReplaceAllString(summary, "${1}<redacted path>")
|
||||
summary = mcpURLPattern.ReplaceAllStringFunc(summary, SanitizeMCPNetworkTarget)
|
||||
if len(summary) > MaxMCPAttachmentErrorSummaryBytes {
|
||||
return summary[:MaxMCPAttachmentErrorSummaryBytes]
|
||||
}
|
||||
return summary
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMCPAttachmentHistoryUsesStrongestEvidenceForServerStatus(t *testing.T) {
|
||||
history := MCPAttachmentHistory{}
|
||||
history.StartAttempt(MCPAttachmentAttempt{AttemptID: "attempt-1", StartedAt: time.Unix(1, 0).UTC()})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
kind MCPAttachmentEvidenceKind
|
||||
want MCPAttachmentStatus
|
||||
}{
|
||||
{name: "filtered", kind: MCPAttachmentEvidenceFiltered, want: MCPAttachmentStatusFiltered},
|
||||
{name: "delivered", kind: MCPAttachmentEvidenceDelivered, want: MCPAttachmentStatusDelivered},
|
||||
{name: "connected", kind: MCPAttachmentEvidenceInitializeObserved, want: MCPAttachmentStatusConnected},
|
||||
{name: "active", kind: MCPAttachmentEvidenceToolsListObserved, want: MCPAttachmentStatusActive},
|
||||
{name: "failed", kind: MCPAttachmentEvidenceExplicitError, want: MCPAttachmentStatusFailed},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
candidate := MCPAttachmentHistory{}
|
||||
candidate.StartAttempt(MCPAttachmentAttempt{AttemptID: tc.name})
|
||||
if !candidate.Apply(MCPAttachmentEvidence{AttemptID: tc.name, ServerName: "kandev", Kind: tc.kind}) {
|
||||
t.Fatal("Apply() rejected current evidence")
|
||||
}
|
||||
server, ok := candidate.CurrentServer("kandev")
|
||||
if !ok {
|
||||
t.Fatal("CurrentServer() did not retain server")
|
||||
}
|
||||
if server.Status != tc.want {
|
||||
t.Fatalf("status = %q, want %q", server.Status, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if !history.Apply(MCPAttachmentEvidence{AttemptID: "attempt-1", ServerName: "kandev", Kind: MCPAttachmentEvidenceToolsListObserved}) {
|
||||
t.Fatal("Apply() rejected tools-list evidence")
|
||||
}
|
||||
if !history.Apply(MCPAttachmentEvidence{AttemptID: "attempt-1", ServerName: "kandev", Kind: MCPAttachmentEvidenceExplicitError}) {
|
||||
t.Fatal("Apply() rejected explicit-error evidence")
|
||||
}
|
||||
server, ok := history.CurrentServer("kandev")
|
||||
if !ok || server.Status != MCPAttachmentStatusFailed {
|
||||
t.Fatalf("later explicit error did not win: %+v", server)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPAttachmentHistoryDoesNotRestoreFailedServerFromLateObservations(t *testing.T) {
|
||||
history := MCPAttachmentHistory{}
|
||||
history.StartAttempt(MCPAttachmentAttempt{AttemptID: "attempt-1"})
|
||||
|
||||
for _, evidence := range []MCPAttachmentEvidence{
|
||||
{AttemptID: "attempt-1", ServerName: "kandev", Kind: MCPAttachmentEvidenceExplicitError},
|
||||
{AttemptID: "attempt-1", ServerName: "kandev", Kind: MCPAttachmentEvidenceInitializeObserved},
|
||||
{AttemptID: "attempt-1", ServerName: "kandev", Kind: MCPAttachmentEvidenceToolsListObserved, ToolCount: 2},
|
||||
} {
|
||||
if !history.Apply(evidence) {
|
||||
t.Fatal("Apply() rejected current evidence")
|
||||
}
|
||||
}
|
||||
|
||||
server, ok := history.CurrentServer("kandev")
|
||||
if !ok || server.Status != MCPAttachmentStatusFailed {
|
||||
t.Fatalf("server = %+v, want failed", server)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPAttachmentHistoryUpdatesToolCountToZero(t *testing.T) {
|
||||
history := MCPAttachmentHistory{}
|
||||
history.StartAttempt(MCPAttachmentAttempt{AttemptID: "attempt-1"})
|
||||
for _, toolCount := range []int{2, 0} {
|
||||
if !history.Apply(MCPAttachmentEvidence{
|
||||
AttemptID: "attempt-1", ServerName: "kandev", Kind: MCPAttachmentEvidenceToolsListObserved, ToolCount: toolCount,
|
||||
}) {
|
||||
t.Fatal("Apply() rejected current evidence")
|
||||
}
|
||||
}
|
||||
|
||||
server, ok := history.CurrentServer("kandev")
|
||||
if !ok || server.ToolCount != 0 {
|
||||
t.Fatalf("server = %+v, want tool_count 0", server)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPAttachmentHistoryConnectionClosedOnlyClearsMatchingConnection(t *testing.T) {
|
||||
history := MCPAttachmentHistory{}
|
||||
history.StartAttempt(MCPAttachmentAttempt{AttemptID: "attempt-1"})
|
||||
history.Apply(MCPAttachmentEvidence{AttemptID: "attempt-1", ServerName: "kandev", Kind: MCPAttachmentEvidenceToolsListObserved, ConnectionID: "new"})
|
||||
history.Apply(MCPAttachmentEvidence{AttemptID: "attempt-1", ServerName: "kandev", Kind: MCPAttachmentEvidenceConnectionClosed, ConnectionID: "old"})
|
||||
|
||||
server, _ := history.CurrentServer("kandev")
|
||||
if server.Status != MCPAttachmentStatusActive || server.ConnectionID != "new" || server.DisconnectedAt != nil {
|
||||
t.Fatalf("old connection closure changed current server: %+v", server)
|
||||
}
|
||||
|
||||
history.Apply(MCPAttachmentEvidence{AttemptID: "attempt-1", ServerName: "kandev", Kind: MCPAttachmentEvidenceConnectionClosed, ConnectionID: "new"})
|
||||
server, _ = history.CurrentServer("kandev")
|
||||
if server.Status != MCPAttachmentStatusConnected || server.DisconnectedAt == nil {
|
||||
t.Fatalf("matching connection closure did not downgrade server: %+v", server)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPAttachmentHistorySupersedesEarlierAttemptInSameExecution(t *testing.T) {
|
||||
history := MCPAttachmentHistory{}
|
||||
history.StartAttempt(MCPAttachmentAttempt{AttemptID: "attempt-1", ExecutionID: "execution-1"})
|
||||
if !history.Apply(MCPAttachmentEvidence{AttemptID: "attempt-1", ServerName: "kandev", Kind: MCPAttachmentEvidenceToolsListObserved}) {
|
||||
t.Fatal("Apply() rejected first attempt")
|
||||
}
|
||||
|
||||
history.StartAttempt(MCPAttachmentAttempt{AttemptID: "attempt-2", ExecutionID: "execution-1"})
|
||||
if history.Current.AttemptID != "attempt-2" {
|
||||
t.Fatalf("current attempt = %q, want attempt-2", history.Current.AttemptID)
|
||||
}
|
||||
if _, ok := history.CurrentServer("kandev"); ok {
|
||||
t.Fatal("new attempt inherited active server evidence")
|
||||
}
|
||||
if len(history.Previous) != 1 || history.Previous[0].AttemptID != "attempt-1" {
|
||||
t.Fatalf("previous attempts = %+v, want attempt-1", history.Previous)
|
||||
}
|
||||
if history.Previous[0].SupersededAt == nil {
|
||||
t.Fatal("previous attempt was not marked superseded")
|
||||
}
|
||||
if history.Apply(MCPAttachmentEvidence{AttemptID: "attempt-1", ServerName: "kandev", Kind: MCPAttachmentEvidenceToolsListObserved}) {
|
||||
t.Fatal("Apply() accepted evidence for superseded attempt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPAttachmentHistoryBoundsAttemptsAndEvidence(t *testing.T) {
|
||||
history := MCPAttachmentHistory{}
|
||||
for i := 0; i < MaxMCPAttachmentAttempts+1; i++ {
|
||||
attemptID := string(rune('a' + i))
|
||||
history.StartAttempt(MCPAttachmentAttempt{AttemptID: attemptID})
|
||||
}
|
||||
if len(history.Previous) != MaxMCPAttachmentAttempts-1 {
|
||||
t.Fatalf("previous count = %d, want %d", len(history.Previous), MaxMCPAttachmentAttempts-1)
|
||||
}
|
||||
if history.Previous[0].AttemptID != "c" || history.Previous[1].AttemptID != "b" {
|
||||
t.Fatalf("previous retention = %+v, want c then b", history.Previous)
|
||||
}
|
||||
|
||||
for i := 0; i < MaxMCPAttachmentEvidenceEvents+1; i++ {
|
||||
history.Apply(MCPAttachmentEvidence{AttemptID: history.Current.AttemptID, ServerName: "kandev", Kind: MCPAttachmentEvidenceDelivered})
|
||||
}
|
||||
if len(history.Current.Evidence) != MaxMCPAttachmentEvidenceEvents {
|
||||
t.Fatalf("evidence count = %d, want %d", len(history.Current.Evidence), MaxMCPAttachmentEvidenceEvents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPAttachmentSanitizersExcludeSensitiveTargetDetails(t *testing.T) {
|
||||
if got, want := SanitizeMCPNetworkTarget("https://alice:secret@example.test:8443/mcp?token=abc#details"), "https://example.test:8443"; got != want {
|
||||
t.Fatalf("SanitizeMCPNetworkTarget() = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := SanitizeMCPStdioTarget("/opt/tools/private-server --token=super-secret"), "private-server"; got != want {
|
||||
t.Fatalf("SanitizeMCPStdioTarget() = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
summary := SanitizeMCPErrorSummary("connection to https://alice:secret@example.test/mcp?token=abc failed: Bearer token-value-that-must-not-leak")
|
||||
for _, forbidden := range []string{"alice", "secret", "/mcp", "token=", "token-value-that-must-not-leak"} {
|
||||
if strings.Contains(summary, forbidden) {
|
||||
t.Fatalf("SanitizeMCPErrorSummary() leaked %q in %q", forbidden, summary)
|
||||
}
|
||||
}
|
||||
if len(SanitizeMCPErrorSummary(strings.Repeat("x", MaxMCPAttachmentErrorSummaryBytes+1))) > MaxMCPAttachmentErrorSummaryBytes {
|
||||
t.Fatal("SanitizeMCPErrorSummary() exceeded maximum bytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeMCPErrorSummaryRedactsLabeledConfiguration(t *testing.T) {
|
||||
summary := SanitizeMCPErrorSummary("failed: env=API_KEY=short-secret args=--token short-token")
|
||||
for _, forbidden := range []string{"API_KEY", "short-secret", "--token", "short-token"} {
|
||||
if strings.Contains(summary, forbidden) {
|
||||
t.Fatalf("SanitizeMCPErrorSummary() leaked %q in %q", forbidden, summary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeMCPErrorSummaryRedactsAbsolutePaths(t *testing.T) {
|
||||
summary := SanitizeMCPErrorSummary("cannot read /workspace/.env while connecting to https://endpoint.example.test/mcp")
|
||||
for _, forbidden := range []string{"/workspace/.env", "/mcp"} {
|
||||
if strings.Contains(summary, forbidden) {
|
||||
t.Fatalf("SanitizeMCPErrorSummary() leaked %q in %q", forbidden, summary)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(summary, "https://endpoint.example.test") {
|
||||
t.Fatalf("SanitizeMCPErrorSummary() removed safe endpoint host: %q", summary)
|
||||
}
|
||||
}
|
||||
@@ -1034,6 +1034,7 @@ func (b bootStateBuilder) addTaskDetailSessionsState(
|
||||
worktrees := make(map[string]any)
|
||||
worktreesBySession := make(map[string]any)
|
||||
sessionModelsByID := make(map[string]any)
|
||||
sessionMCPStatusByID := make(map[string]any)
|
||||
for _, session := range sessions {
|
||||
if session == nil {
|
||||
continue
|
||||
@@ -1068,6 +1069,11 @@ func (b bootStateBuilder) addTaskDetailSessionsState(
|
||||
snapshot, sessionACPConfigBaseline(session),
|
||||
)
|
||||
}
|
||||
if history, ok := lifecycle.LoadMCPAttachmentHistory(
|
||||
session.Metadata[taskmodels.SessionMetaKeyMCPAttachmentState],
|
||||
); ok {
|
||||
sessionMCPStatusByID[session.ID] = history
|
||||
}
|
||||
}
|
||||
state["taskSessions"] = map[string]any{"items": sessionItems}
|
||||
state["taskSessionsByTask"] = map[string]any{
|
||||
@@ -1082,6 +1088,9 @@ func (b bootStateBuilder) addTaskDetailSessionsState(
|
||||
if len(sessionModelsByID) > 0 {
|
||||
state["sessionModels"] = map[string]any{"bySessionId": sessionModelsByID}
|
||||
}
|
||||
if len(sessionMCPStatusByID) > 0 {
|
||||
state["sessionMcpStatus"] = map[string]any{"bySessionId": sessionMCPStatusByID}
|
||||
}
|
||||
}
|
||||
|
||||
func (b bootStateBuilder) taskDetailTurnsState(ctx context.Context, sessionID string) map[string]any {
|
||||
|
||||
@@ -235,6 +235,7 @@ const (
|
||||
AgentCapabilitiesUpdated = "agent_capabilities.updated" // Agent capabilities received
|
||||
SessionModelsUpdated = "session_models.updated" // Session models received
|
||||
SessionInfoUpdated = "session_info.updated" // ACP session info received
|
||||
SessionMCPStatusUpdated = "session_mcp_status.updated" // MCP attachment evidence changed
|
||||
)
|
||||
|
||||
// Event types for session todos (ACP plan entries)
|
||||
@@ -432,6 +433,16 @@ func BuildSessionModelsWildcardSubject() string {
|
||||
return SessionModelsUpdated + ".*"
|
||||
}
|
||||
|
||||
// BuildSessionMCPStatusSubject creates a session-specific MCP status subject.
|
||||
func BuildSessionMCPStatusSubject(sessionID string) string {
|
||||
return SessionMCPStatusUpdated + "." + sessionID
|
||||
}
|
||||
|
||||
// BuildSessionMCPStatusWildcardSubject creates a wildcard subscription for MCP status events.
|
||||
func BuildSessionMCPStatusWildcardSubject() string {
|
||||
return SessionMCPStatusUpdated + ".*"
|
||||
}
|
||||
|
||||
// BuildSessionInfoSubject creates a session info subject for a specific session
|
||||
func BuildSessionInfoSubject(sessionID string) string {
|
||||
return SessionInfoUpdated + "." + sessionID
|
||||
|
||||
@@ -51,6 +51,7 @@ func RegisterSessionStreamNotifications(ctx context.Context, eventBus bus.EventB
|
||||
b.subscribe(eventBus, events.BuildSessionModeWildcardSubject(), ws.ActionSessionModeChanged)
|
||||
b.subscribe(eventBus, events.BuildAgentCapabilitiesWildcardSubject(), ws.ActionSessionAgentCapabilities)
|
||||
b.subscribe(eventBus, events.BuildSessionModelsWildcardSubject(), ws.ActionSessionModelsUpdated)
|
||||
b.subscribe(eventBus, events.BuildSessionMCPStatusWildcardSubject(), ws.ActionSessionMCPStatusUpdated)
|
||||
b.subscribe(eventBus, events.BuildSessionInfoWildcardSubject(), ws.ActionSessionInfoUpdated)
|
||||
b.subscribe(eventBus, events.BuildSessionTodosWildcardSubject(), ws.ActionSessionTodosUpdated)
|
||||
b.subscribe(eventBus, events.BuildSessionPromptUsageWildcardSubject(), ws.ActionSessionPromptUsage)
|
||||
|
||||
@@ -4,6 +4,7 @@ package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
@@ -93,6 +95,10 @@ type Server struct {
|
||||
mcpLogger *zap.Logger // optional file logger for MCP debug traces
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
attachmentMu sync.RWMutex
|
||||
attachmentAttempt streams.MCPAttachmentAttempt
|
||||
attachmentAttempts map[string]streams.MCPAttachmentAttempt
|
||||
attachmentReporter func(streams.MCPAttachmentEvidence)
|
||||
}
|
||||
|
||||
// New creates a new MCP server for agentctl.
|
||||
@@ -152,6 +158,7 @@ func newServer(backend BackendClient, sessionID, taskID string, log *logger.Logg
|
||||
disableAskQuestion: disableAskQuestion,
|
||||
mode: mcpMode,
|
||||
logger: log.WithFields(zap.String("component", "mcp-server")),
|
||||
attachmentAttempts: make(map[string]streams.MCPAttachmentAttempt),
|
||||
}
|
||||
|
||||
// Set up optional file logger for MCP debug traces
|
||||
@@ -168,16 +175,127 @@ func newServer(backend BackendClient, sessionID, taskID string, log *logger.Logg
|
||||
}
|
||||
}
|
||||
|
||||
hooks := &server.Hooks{}
|
||||
s.mcpServer = server.NewMCPServer(
|
||||
"kandev-mcp",
|
||||
"1.0.0",
|
||||
server.WithToolCapabilities(true),
|
||||
server.WithHooks(hooks),
|
||||
)
|
||||
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, "")
|
||||
})
|
||||
hooks.AddAfterListTools(func(ctx context.Context, _ any, _ *mcp.ListToolsRequest, result *mcp.ListToolsResult) {
|
||||
s.observeMCPConnection(mcpConnectionID(ctx), streams.MCPAttachmentEvidenceToolsListObserved, len(result.Tools), "")
|
||||
})
|
||||
hooks.AddAfterCallTool(func(ctx context.Context, _ any, _ *mcp.CallToolRequest, _ *mcp.CallToolResult) {
|
||||
s.observeMCPConnection(mcpConnectionID(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())
|
||||
})
|
||||
hooks.AddOnUnregisterSession(func(_ context.Context, session server.ClientSession) {
|
||||
s.unregisterMCPConnection(session.SessionID())
|
||||
})
|
||||
s.registerTools()
|
||||
s.running = true
|
||||
return s
|
||||
}
|
||||
|
||||
// SetAttachmentReporter routes safe MCP observations to the instance's
|
||||
// existing agent update stream. It accepts a concrete callback to keep the MCP
|
||||
// package independent of process-manager implementation details.
|
||||
func (s *Server) SetAttachmentReporter(reporter func(streams.MCPAttachmentEvidence)) {
|
||||
s.attachmentMu.Lock()
|
||||
defer s.attachmentMu.Unlock()
|
||||
s.attachmentReporter = reporter
|
||||
}
|
||||
|
||||
// SetAttachmentAttempt selects the backend-owned attempt to which subsequent
|
||||
// MCP endpoint observations belong. It is called only by the local agentctl
|
||||
// API before handing configuration to an agent adapter.
|
||||
func (s *Server) SetAttachmentAttempt(attempt streams.MCPAttachmentAttempt) {
|
||||
s.attachmentMu.Lock()
|
||||
defer s.attachmentMu.Unlock()
|
||||
s.attachmentAttempt = attempt
|
||||
}
|
||||
|
||||
func (s *Server) observeMCPConnection(connectionID string, kind streams.MCPAttachmentEvidenceKind, toolCount int, summary string) {
|
||||
s.attachmentMu.RLock()
|
||||
attempt, ok := s.attachmentAttempts[connectionID]
|
||||
reporter := s.attachmentReporter
|
||||
s.attachmentMu.RUnlock()
|
||||
if !ok || reporter == nil || attempt.AttemptID == "" {
|
||||
return
|
||||
}
|
||||
s.reportMCPConnection(reporter, attempt, connectionID, kind, toolCount, summary)
|
||||
}
|
||||
|
||||
func (s *Server) registerMCPConnection(connectionID string) {
|
||||
s.attachmentMu.Lock()
|
||||
attempt := s.attachmentAttempt
|
||||
reporter := s.attachmentReporter
|
||||
if connectionID != "" && attempt.AttemptID != "" {
|
||||
s.attachmentAttempts[connectionID] = attempt
|
||||
}
|
||||
s.attachmentMu.Unlock()
|
||||
if reporter == nil || attempt.AttemptID == "" {
|
||||
return
|
||||
}
|
||||
s.reportMCPConnection(reporter, attempt, connectionID, streams.MCPAttachmentEvidenceSessionAccepted, 0, "")
|
||||
}
|
||||
|
||||
func (s *Server) unregisterMCPConnection(connectionID string) {
|
||||
s.attachmentMu.Lock()
|
||||
attempt, ok := s.attachmentAttempts[connectionID]
|
||||
reporter := s.attachmentReporter
|
||||
delete(s.attachmentAttempts, connectionID)
|
||||
s.attachmentMu.Unlock()
|
||||
if !ok || reporter == nil || attempt.AttemptID == "" {
|
||||
return
|
||||
}
|
||||
s.reportMCPConnection(reporter, attempt, connectionID, streams.MCPAttachmentEvidenceConnectionClosed, 0, "")
|
||||
}
|
||||
|
||||
func (s *Server) reportMCPConnection(
|
||||
reporter func(streams.MCPAttachmentEvidence),
|
||||
attempt streams.MCPAttachmentAttempt,
|
||||
connectionID string,
|
||||
kind streams.MCPAttachmentEvidenceKind,
|
||||
toolCount int,
|
||||
summary string,
|
||||
) {
|
||||
reporter(streams.MCPAttachmentEvidence{
|
||||
AttemptID: attempt.AttemptID,
|
||||
ServerName: "kandev",
|
||||
Kind: kind,
|
||||
OccurredAt: time.Now().UTC(),
|
||||
Source: streams.MCPServerSourceKandev,
|
||||
ConnectionID: opaqueMCPConnectionID(connectionID),
|
||||
ToolCount: toolCount,
|
||||
Summary: streams.SanitizeMCPErrorSummary(summary),
|
||||
})
|
||||
}
|
||||
|
||||
func mcpConnectionID(ctx context.Context) string {
|
||||
session := server.ClientSessionFromContext(ctx)
|
||||
if session == nil {
|
||||
return ""
|
||||
}
|
||||
return session.SessionID()
|
||||
}
|
||||
|
||||
func opaqueMCPConnectionID(connectionID string) string {
|
||||
if connectionID == "" {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte(connectionID))
|
||||
return fmt.Sprintf("mcp-%x", sum[:8])
|
||||
}
|
||||
|
||||
// RegisterRoutes adds MCP routes to the gin router at the root.
|
||||
// Used by agentctl which serves the MCP transport at /sse, /message, /mcp.
|
||||
func (s *Server) RegisterRoutes(router gin.IRouter) {
|
||||
|
||||
@@ -3,12 +3,70 @@ package mcp
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMCPAttachmentObserverEmitsSafeConnectionEvidence(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
backend := NewChannelBackendClient(log)
|
||||
defer backend.Close()
|
||||
s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTask)
|
||||
events := make(chan streams.MCPAttachmentEvidence, 4)
|
||||
s.SetAttachmentReporter(func(evidence streams.MCPAttachmentEvidence) { events <- evidence })
|
||||
s.SetAttachmentAttempt(streams.MCPAttachmentAttempt{AttemptID: "attempt-1"})
|
||||
|
||||
s.registerMCPConnection("agent session containing secret")
|
||||
<-events
|
||||
s.observeMCPConnection("agent session containing secret", streams.MCPAttachmentEvidenceInitializeObserved, 0, "")
|
||||
s.observeMCPConnection("agent session containing secret", streams.MCPAttachmentEvidenceToolsListObserved, 7, "")
|
||||
|
||||
first := <-events
|
||||
second := <-events
|
||||
if first.AttemptID != "attempt-1" || first.ServerName != "kandev" || first.ConnectionID == "agent session containing secret" {
|
||||
t.Fatalf("first evidence = %+v", first)
|
||||
}
|
||||
if second.Kind != streams.MCPAttachmentEvidenceToolsListObserved || second.ToolCount != 7 {
|
||||
t.Fatalf("second evidence = %+v", second)
|
||||
}
|
||||
if first.OccurredAt.IsZero() || time.Since(first.OccurredAt) > time.Second {
|
||||
t.Fatalf("timestamp = %v", first.OccurredAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPAttachmentObserverKeepsConnectionAttemptAcrossRollover(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
backend := NewChannelBackendClient(log)
|
||||
defer backend.Close()
|
||||
s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTask)
|
||||
events := make(chan streams.MCPAttachmentEvidence, 8)
|
||||
s.SetAttachmentReporter(func(evidence streams.MCPAttachmentEvidence) { events <- evidence })
|
||||
|
||||
s.SetAttachmentAttempt(streams.MCPAttachmentAttempt{AttemptID: "attempt-old"})
|
||||
s.registerMCPConnection("old")
|
||||
if event := <-events; event.AttemptID != "attempt-old" {
|
||||
t.Fatalf("old register attempt = %q", event.AttemptID)
|
||||
}
|
||||
s.SetAttachmentAttempt(streams.MCPAttachmentAttempt{AttemptID: "attempt-new"})
|
||||
s.registerMCPConnection("new")
|
||||
if event := <-events; event.AttemptID != "attempt-new" {
|
||||
t.Fatalf("new register attempt = %q", event.AttemptID)
|
||||
}
|
||||
|
||||
s.observeMCPConnection("old", streams.MCPAttachmentEvidenceToolsListObserved, 1, "")
|
||||
if event := <-events; event.AttemptID != "attempt-old" {
|
||||
t.Fatalf("old delayed event attempt = %q", event.AttemptID)
|
||||
}
|
||||
s.unregisterMCPConnection("old")
|
||||
if event := <-events; event.AttemptID != "attempt-old" || event.Kind != streams.MCPAttachmentEvidenceConnectionClosed {
|
||||
t.Fatalf("old close event = %+v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestLogger(t *testing.T) *logger.Logger {
|
||||
t.Helper()
|
||||
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
|
||||
|
||||
@@ -91,6 +91,9 @@ func (s *Service) handleAgentStreamEvent(ctx context.Context, payload *lifecycle
|
||||
case "session_models":
|
||||
s.handleSessionModelsEvent(ctx, payload)
|
||||
|
||||
case streams.EventTypeMCPAttachment:
|
||||
s.handleSessionMCPAttachmentEvent(ctx, payload)
|
||||
|
||||
case streams.EventTypeSessionInfo:
|
||||
s.handleSessionInfoEvent(ctx, payload)
|
||||
|
||||
@@ -2388,6 +2391,62 @@ func (s *Service) handleSessionModelsEvent(ctx context.Context, payload *lifecyc
|
||||
_ = s.eventBus.Publish(ctx, subject, bus.NewEvent(events.SessionModelsUpdated, "orchestrator", eventPayload))
|
||||
}
|
||||
|
||||
// handleSessionMCPAttachmentEvent reduces safe attachment observations into
|
||||
// bounded session metadata and broadcasts the resulting report. A failed
|
||||
// diagnostic write is intentionally isolated from task and agent state.
|
||||
func (s *Service) handleSessionMCPAttachmentEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) {
|
||||
if payload == nil || payload.Data == nil || payload.SessionID == "" || s.repo == nil {
|
||||
return
|
||||
}
|
||||
session, err := s.repo.GetTaskSession(ctx, payload.SessionID)
|
||||
if err != nil || session == nil {
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to load session for MCP attachment evidence", zap.String("session_id", payload.SessionID), zap.Error(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
if staleMCPAttachmentAttempt(payload) {
|
||||
s.logger.Warn("dropping stale MCP attachment attempt in orchestrator",
|
||||
zap.String("attempt_execution_id", payload.Data.MCPAttachmentAttempt.ExecutionID),
|
||||
zap.String("event_execution_id", payload.ExecutionID))
|
||||
return
|
||||
}
|
||||
history, _ := lifecycle.LoadMCPAttachmentHistory(session.Metadata[models.SessionMetaKeyMCPAttachmentState])
|
||||
changed := reduceMCPAttachmentHistory(&history, payload.Data)
|
||||
if !changed || !history.Valid() {
|
||||
return
|
||||
}
|
||||
writeCtx := context.WithoutCancel(ctx)
|
||||
if err := s.repo.SetSessionMetadataKey(writeCtx, payload.SessionID, models.SessionMetaKeyMCPAttachmentState, history); err != nil {
|
||||
s.logger.Warn("failed to persist MCP attachment status", zap.String("session_id", payload.SessionID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
eventPayload := lifecycle.SessionMCPStatusEventPayload{
|
||||
TaskID: payload.TaskID, SessionID: payload.SessionID, History: history,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if s.eventBus != nil {
|
||||
_ = s.eventBus.Publish(writeCtx, events.BuildSessionMCPStatusSubject(payload.SessionID), bus.NewEvent(events.SessionMCPStatusUpdated, "orchestrator", eventPayload))
|
||||
}
|
||||
}
|
||||
|
||||
func staleMCPAttachmentAttempt(payload *lifecycle.AgentStreamEventPayload) bool {
|
||||
attempt := payload.Data.MCPAttachmentAttempt
|
||||
return attempt != nil && attempt.ExecutionID != "" && attempt.ExecutionID != payload.ExecutionID
|
||||
}
|
||||
|
||||
func reduceMCPAttachmentHistory(history *streams.MCPAttachmentHistory, data *lifecycle.AgentStreamEventData) bool {
|
||||
changed := false
|
||||
if attempt := data.MCPAttachmentAttempt; attempt != nil {
|
||||
history.StartAttempt(*attempt)
|
||||
changed = true
|
||||
}
|
||||
if evidence := data.MCPAttachment; evidence != nil {
|
||||
changed = history.Apply(*evidence) || changed
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func (s *Service) sessionACPConfigBaselineForEvent(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
|
||||
@@ -145,6 +145,10 @@ const SessionMetaKeyACPConfigBaseline = "acp_config_baseline"
|
||||
// reconnection. It is display metadata and is not replayed to the provider.
|
||||
const SessionMetaKeyACPModelState = "acp_model_state"
|
||||
|
||||
// SessionMetaKeyMCPAttachmentState records bounded, safe evidence about MCP
|
||||
// attachment for this task session and its immediate prior attempts.
|
||||
const SessionMetaKeyMCPAttachmentState = "mcp_attachment_state"
|
||||
|
||||
// SessionMetaKeyGitCredentialSnapshot records the non-secret Git credential
|
||||
// routing contract that successfully launched or resumed a session.
|
||||
const SessionMetaKeyGitCredentialSnapshot = "git_credential_snapshot"
|
||||
|
||||
@@ -216,6 +216,7 @@ const (
|
||||
ActionSessionModeChanged = "session.mode_changed"
|
||||
ActionSessionAgentCapabilities = "session.agent_capabilities"
|
||||
ActionSessionModelsUpdated = "session.models_updated"
|
||||
ActionSessionMCPStatusUpdated = "session.mcp_status_updated"
|
||||
ActionSessionInfoUpdated = "session.info_updated"
|
||||
ActionSessionSetMode = "session.set_mode"
|
||||
ActionSessionTodosUpdated = "session.todos_updated"
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ContextZone } from "./context-items/context-zone";
|
||||
import type { ContextItem } from "@/lib/types/context";
|
||||
import type { ContextFile } from "@/lib/state/context-files-store";
|
||||
import type { ImagePasteIssue } from "./clipboard-attachments";
|
||||
import type { MCPAttachmentHistory } from "@/lib/state/slices/session-runtime/types";
|
||||
|
||||
export type ChatInputEditorAreaProps = {
|
||||
inputRef: React.RefObject<import("./tiptap-input").TipTapInputHandle | null>;
|
||||
@@ -24,6 +25,7 @@ export type ChatInputEditorAreaProps = {
|
||||
planModeEnabled: boolean;
|
||||
planModeAvailable: boolean;
|
||||
mcpServers: string[];
|
||||
mcpAttachmentHistory?: MCPAttachmentHistory;
|
||||
submitKey: "enter" | "cmd_enter";
|
||||
setIsInputFocused: (focused: boolean) => void;
|
||||
sessionId: string | null;
|
||||
@@ -177,6 +179,7 @@ export function ChatInputEditorArea(p: ChatInputEditorAreaProps) {
|
||||
planModeEnabled={planModeEnabled}
|
||||
planModeAvailable={planModeAvailable}
|
||||
mcpServers={mcpServers}
|
||||
mcpAttachmentHistory={p.mcpAttachmentHistory}
|
||||
onPlanModeChange={onPlanModeChange}
|
||||
sessionId={sessionId}
|
||||
taskId={taskId}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { ContextFile } from "@/lib/state/context-files-store";
|
||||
import type { ClarificationRequestMetadata, Message } from "@/lib/types/http";
|
||||
import type { DiffComment } from "@/lib/diff/types";
|
||||
import type { TaskMentionData } from "@/hooks/use-inline-mention";
|
||||
import type { MCPAttachmentHistory } from "@/lib/state/slices/session-runtime/types";
|
||||
import type { EntityReference } from "@/lib/types/entity-reference";
|
||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
import { useChatInputContainer } from "./use-chat-input-container";
|
||||
@@ -68,6 +69,7 @@ type ChatInputContainerProps = {
|
||||
planModeEnabled: boolean;
|
||||
planModeAvailable?: boolean;
|
||||
mcpServers?: string[];
|
||||
mcpAttachmentHistory?: MCPAttachmentHistory;
|
||||
onPlanModeChange: (enabled: boolean) => void;
|
||||
isAgentBusy: boolean;
|
||||
isStarting: boolean;
|
||||
@@ -295,6 +297,7 @@ function buildEditorAreaProps(
|
||||
planModeEnabled: p.planModeEnabled,
|
||||
planModeAvailable: p.planModeAvailable ?? true,
|
||||
mcpServers: p.mcpServers ?? [],
|
||||
mcpAttachmentHistory: p.mcpAttachmentHistory,
|
||||
submitKey: p.submitKey,
|
||||
setIsInputFocused: s.setIsInputFocused,
|
||||
sessionId: p.sessionId,
|
||||
|
||||
@@ -87,7 +87,12 @@ function buildCollapsibleItems(props: DesktopToolbarProps): ToolbarItemConfig[]
|
||||
{
|
||||
id: "mcp",
|
||||
section: "left",
|
||||
render: () => <McpIndicator mcpServers={props.mcpServers} />,
|
||||
render: () => (
|
||||
<McpIndicator
|
||||
mcpServers={props.mcpServers}
|
||||
attachmentHistory={props.mcpAttachmentHistory}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "mode",
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "./chat-input-toolbar-primitives";
|
||||
import type { ContextFile } from "@/lib/state/context-files-store";
|
||||
import type { SHORTCUTS } from "@/lib/keyboard/constants";
|
||||
import type { MCPAttachmentHistory } from "@/lib/state/slices/session-runtime/types";
|
||||
|
||||
type MobileToolbarProps = {
|
||||
planModeEnabled: boolean;
|
||||
@@ -29,6 +30,7 @@ type MobileToolbarProps = {
|
||||
hideAgentControls: boolean;
|
||||
hideSessionsDropdown: boolean;
|
||||
mcpServers: string[];
|
||||
mcpAttachmentHistory?: MCPAttachmentHistory;
|
||||
sessionId: string | null;
|
||||
taskId: string | null;
|
||||
taskTitle?: string;
|
||||
@@ -94,7 +96,10 @@ function MobileLeftActions(props: MobileToolbarProps) {
|
||||
{!props.hideAgentControls && (
|
||||
<>
|
||||
<div data-testid="toolbar-item-mcp">
|
||||
<McpIndicator mcpServers={props.mcpServers} />
|
||||
<McpIndicator
|
||||
mcpServers={props.mcpServers}
|
||||
attachmentHistory={props.mcpAttachmentHistory}
|
||||
/>
|
||||
</div>
|
||||
<div data-testid="toolbar-item-mode">
|
||||
<ModeSelector sessionId={props.sessionId} triggerClassName="max-w-[46vw]" />
|
||||
|
||||
@@ -13,12 +13,15 @@ import {
|
||||
import { GridSpinner } from "@/components/grid-spinner";
|
||||
import { KeyboardShortcutTooltip } from "@/components/keyboard-shortcut-tooltip";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from "@kandev/ui/drawer";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { getShortcut } from "@/lib/keyboard/shortcut-overrides";
|
||||
import { SHORTCUTS } from "@/lib/keyboard/constants";
|
||||
import { formatShortcut } from "@/lib/keyboard/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MCPAttachmentHistory } from "@/lib/state/slices/session-runtime/types";
|
||||
|
||||
type SubmitButtonProps = {
|
||||
isAgentBusy: boolean;
|
||||
@@ -202,25 +205,114 @@ export function PlanToggleButton({
|
||||
);
|
||||
}
|
||||
|
||||
export function McpIndicator({ mcpServers }: { mcpServers: string[] }) {
|
||||
const mcpStatusColor: Record<string, string> = {
|
||||
active: "bg-emerald-500",
|
||||
connected: "bg-amber-500",
|
||||
delivered: "bg-amber-500",
|
||||
failed: "bg-destructive",
|
||||
filtered: "bg-muted-foreground/50",
|
||||
unavailable: "bg-muted-foreground/50",
|
||||
unknown: "bg-muted-foreground/50",
|
||||
};
|
||||
|
||||
const mcpStatusLabel: Record<string, string> = {
|
||||
active: "Active",
|
||||
connected: "Connected",
|
||||
delivered: "Delivered — connection unverified",
|
||||
failed: "Failed",
|
||||
filtered: "Filtered",
|
||||
unavailable: "Unavailable",
|
||||
unknown: "Unknown",
|
||||
};
|
||||
|
||||
export function McpIndicator({
|
||||
mcpServers,
|
||||
attachmentHistory,
|
||||
}: {
|
||||
mcpServers: string[];
|
||||
attachmentHistory?: MCPAttachmentHistory;
|
||||
}) {
|
||||
const usesTouchDrawer = useTouchDrawer();
|
||||
const hasMcp = mcpServers.length > 0;
|
||||
const tooltipText = hasMcp
|
||||
? `MCP Servers: ${mcpServers.join(", ")}`
|
||||
: "Agent does not support MCP";
|
||||
const Icon = hasMcp ? IconPlugConnected : IconPlugConnectedX;
|
||||
const observedServers = attachmentHistory?.current.servers;
|
||||
const servers =
|
||||
observedServers && observedServers.length > 0
|
||||
? observedServers
|
||||
: mcpServers.map((name) => ({ name, status: "unknown" as const, summary: undefined }));
|
||||
const statusList = hasMcp ? (
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">MCP servers</div>
|
||||
{servers.map((server) => (
|
||||
<div key={server.name} className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 shrink-0 rounded-full",
|
||||
mcpStatusColor[server.status] ?? mcpStatusColor.unknown,
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate">{server.name}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{mcpStatusLabel[server.status] ?? "Unknown"}
|
||||
</span>
|
||||
{server.summary && <span className="text-muted-foreground">{server.summary}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
"Agent does not support MCP"
|
||||
);
|
||||
const trigger = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Show MCP connection status"
|
||||
className={cn(
|
||||
"h-7 w-7 cursor-pointer rounded-md hover:bg-muted/40",
|
||||
hasMcp ? "text-foreground" : "text-muted-foreground/40",
|
||||
)}
|
||||
data-testid="mcp-status-trigger"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (usesTouchDrawer) {
|
||||
return (
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Show MCP connection status"
|
||||
className={cn(
|
||||
"h-11 w-11 cursor-pointer rounded-md active:scale-95",
|
||||
hasMcp ? "text-foreground" : "text-muted-foreground/40",
|
||||
)}
|
||||
data-testid="mcp-status-trigger"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent data-testid="mcp-status-drawer" className="max-h-[80dvh]">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>MCP connection status</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="min-h-0 overflow-y-auto px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] text-sm">
|
||||
{statusList}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"h-7 w-7 flex items-center justify-center rounded-md",
|
||||
hasMcp ? "text-foreground" : "text-muted-foreground/40",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltipText}</TooltipContent>
|
||||
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
|
||||
<TooltipContent data-testid="mcp-status-popover">{statusList}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,11 +9,13 @@ import { SubmitButton } from "./chat-input-toolbar-primitives";
|
||||
import { shouldUseCompactTaskChrome } from "@/hooks/use-compact-task-chrome";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import type { ContextFile } from "@/lib/state/context-files-store";
|
||||
import type { MCPAttachmentHistory } from "@/lib/state/slices/session-runtime/types";
|
||||
|
||||
export type ChatInputToolbarProps = {
|
||||
planModeEnabled: boolean;
|
||||
planModeAvailable?: boolean;
|
||||
mcpServers?: string[];
|
||||
mcpAttachmentHistory?: MCPAttachmentHistory;
|
||||
onPlanModeChange: (enabled: boolean) => void;
|
||||
sessionId: string | null;
|
||||
taskId: string | null;
|
||||
@@ -150,6 +152,7 @@ export const ChatInputToolbar = memo(function ChatInputToolbar(rawProps: ChatInp
|
||||
(props.hideSessionsDropdown ?? false) || responsiveBreakpoint.isMobile
|
||||
}
|
||||
mcpServers={props.mcpServers}
|
||||
mcpAttachmentHistory={props.mcpAttachmentHistory}
|
||||
sessionId={props.sessionId}
|
||||
taskId={props.taskId}
|
||||
taskTitle={props.taskTitle}
|
||||
|
||||
@@ -499,7 +499,11 @@ export function useChatPanelState({
|
||||
const sessionState = useSessionState(sessionId, { taskIdHint });
|
||||
const { resolvedSessionId, taskId } = sessionState;
|
||||
const planMode = usePlanMode(resolvedSessionId, taskId);
|
||||
const { supportsMcp, mcpServers } = useSessionMcp(sessionState.session?.agent_profile_id);
|
||||
const {
|
||||
supportsMcp,
|
||||
mcpServers,
|
||||
attachmentHistory: mcpAttachmentHistory,
|
||||
} = useSessionMcp(sessionState.session?.agent_profile_id, sessionState.session?.id);
|
||||
const planModeAvailable = supportsMcp;
|
||||
|
||||
// When MCP is available: full plan mode toggle (layout + chat input state)
|
||||
@@ -591,6 +595,7 @@ export function useChatPanelState({
|
||||
planContextEnabled,
|
||||
planModeAvailable,
|
||||
mcpServers,
|
||||
mcpAttachmentHistory,
|
||||
prompts,
|
||||
todoItems,
|
||||
};
|
||||
|
||||
@@ -64,6 +64,7 @@ export function useComposerProps(args: ComposerPropsArgs) {
|
||||
planModeEnabled,
|
||||
planModeAvailable: panelState.planModeAvailable,
|
||||
mcpServers: panelState.mcpServers,
|
||||
mcpAttachmentHistory: panelState.mcpAttachmentHistory,
|
||||
onPlanModeChange: panelState.handlePlanModeChange,
|
||||
isAgentBusy,
|
||||
isStarting: panelState.isStarting,
|
||||
|
||||
@@ -69,6 +69,7 @@ export function PassthroughComposerPanel({
|
||||
planModeEnabled={panelState.planModeEnabled}
|
||||
planModeAvailable={panelState.planModeAvailable}
|
||||
mcpServers={panelState.mcpServers}
|
||||
mcpAttachmentHistory={panelState.mcpAttachmentHistory}
|
||||
onPlanModeChange={panelState.handlePlanModeChange}
|
||||
isAgentBusy={false}
|
||||
isStarting={panelState.isStarting}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { type Page } from "@playwright/test";
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
async function openChat(
|
||||
testPage: Page,
|
||||
apiClient: ApiClient,
|
||||
seedData: SeedData,
|
||||
): Promise<SessionPage> {
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"MCP status test",
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await session.waitForChatIdle({ timeout: 30_000 });
|
||||
return session;
|
||||
}
|
||||
|
||||
test("desktop MCP status is available from the neutral composer trigger", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
prCapture,
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== "chromium", "desktop-only status disclosure");
|
||||
await openChat(testPage, apiClient, seedData);
|
||||
|
||||
const trigger = testPage.getByTestId("mcp-status-trigger");
|
||||
await expect(trigger).toBeVisible();
|
||||
await trigger.hover();
|
||||
await expect(testPage.getByTestId("mcp-status-popover")).toBeVisible();
|
||||
const popover = testPage.locator('[data-testid="mcp-status-popover"]:visible');
|
||||
await expect(popover.getByText("kandev", { exact: true }).first()).toBeVisible();
|
||||
await expect(
|
||||
popover
|
||||
.getByText(
|
||||
/^(Unknown|Delivered — connection unverified|Connected|Active|Failed|Filtered|Unavailable)$/,
|
||||
)
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
await prCapture.screenshot("desktop-mcp-status", {
|
||||
caption: "Desktop MCP connection status disclosure",
|
||||
});
|
||||
prCapture.flush();
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
test("mobile MCP status opens in a contained Drawer", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
prCapture,
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== "mobile-chrome", "mobile-only status Drawer");
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Mobile MCP status test",
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await session.waitForChatIdle({ timeout: 30_000 });
|
||||
|
||||
const trigger = testPage.getByTestId("mcp-status-trigger");
|
||||
await expect(trigger).toBeVisible();
|
||||
await trigger.tap();
|
||||
const drawer = testPage.getByTestId("mcp-status-drawer");
|
||||
await expect(drawer).toBeVisible();
|
||||
await expect(drawer.getByText("MCP servers", { exact: true })).toBeVisible();
|
||||
await expect(drawer.getByText("kandev", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
drawer.getByText(
|
||||
/^(Unknown|Delivered — connection unverified|Connected|Active|Failed|Filtered|Unavailable)$/,
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
testPage.evaluate(
|
||||
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
await prCapture.screenshot("mobile-mcp-status", {
|
||||
caption: "Mobile MCP connection status Drawer",
|
||||
});
|
||||
prCapture.flush();
|
||||
});
|
||||
@@ -129,7 +129,7 @@ test.describe("Mobile Status drawer", () => {
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
await testPage.getByRole("button", { name: "Status" }).click();
|
||||
await testPage.getByRole("button", { name: "Status", exact: true }).click();
|
||||
await expect(testPage.locator("#hello-status-left")).toContainText(`mobile-drawer ${task.id}`);
|
||||
await testPage.keyboard.press("Escape");
|
||||
|
||||
|
||||
@@ -11,8 +11,11 @@ const DEFAULT_KANDEV: string[] = ["kandev"];
|
||||
* Resolves MCP support and configured MCP server names for the current session's agent.
|
||||
* Returns whether the agent supports MCP and the list of active MCP server names.
|
||||
*/
|
||||
export function useSessionMcp(agentProfileId: string | null | undefined) {
|
||||
export function useSessionMcp(agentProfileId: string | null | undefined, sessionId?: string) {
|
||||
const settingsAgents = useAppStore((state) => state.settingsAgents.items);
|
||||
const attachmentHistory = useAppStore((state) =>
|
||||
sessionId ? state.sessionMcpStatus.bySessionId[sessionId] : undefined,
|
||||
);
|
||||
// Track which profileId the fetched servers belong to, so stale results are ignored
|
||||
const [fetchResult, setFetchResult] = useState<{
|
||||
profileId: string;
|
||||
@@ -49,10 +52,12 @@ export function useSessionMcp(agentProfileId: string | null | undefined) {
|
||||
}, [agentProfileId, supportsMcp]);
|
||||
|
||||
const mcpServers = useMemo(() => {
|
||||
const observedServers = attachmentHistory?.current.servers;
|
||||
if (observedServers?.length) return observedServers.map((server) => server.name);
|
||||
if (!supportsMcp) return EMPTY_SERVERS;
|
||||
if (fetchResult && fetchResult.profileId === agentProfileId) return fetchResult.servers;
|
||||
return DEFAULT_KANDEV;
|
||||
}, [supportsMcp, fetchResult, agentProfileId]);
|
||||
}, [attachmentHistory, supportsMcp, fetchResult, agentProfileId]);
|
||||
|
||||
return { supportsMcp, mcpServers };
|
||||
return { supportsMcp, mcpServers, attachmentHistory };
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ export const defaultState = {
|
||||
sessionTodos: defaultSessionRuntimeState.sessionTodos,
|
||||
agentCapabilities: defaultSessionRuntimeState.agentCapabilities,
|
||||
sessionModels: defaultSessionRuntimeState.sessionModels,
|
||||
sessionMcpStatus: defaultSessionRuntimeState.sessionMcpStatus,
|
||||
promptUsage: defaultSessionRuntimeState.promptUsage,
|
||||
sessionPollMode: defaultSessionRuntimeState.sessionPollMode,
|
||||
githubStatus: defaultGitHubState.githubStatus,
|
||||
@@ -298,6 +299,7 @@ export function mergeInitialState(initialState?: HydrationState): DefaultState {
|
||||
sessionTodos: { ...defaultState.sessionTodos, ...initialState.sessionTodos },
|
||||
agentCapabilities: { ...defaultState.agentCapabilities, ...initialState.agentCapabilities },
|
||||
sessionModels: { ...defaultState.sessionModels, ...initialState.sessionModels },
|
||||
sessionMcpStatus: { ...defaultState.sessionMcpStatus, ...initialState.sessionMcpStatus },
|
||||
promptUsage: { ...defaultState.promptUsage, ...initialState.promptUsage },
|
||||
sessionPollMode: { ...defaultState.sessionPollMode, ...initialState.sessionPollMode },
|
||||
...mergeGitHubState(initialState),
|
||||
|
||||
@@ -211,6 +211,7 @@ function purgePerSessionRuntime(state: SessionRuntimeSliceState, sessionId: stri
|
||||
delete state.sessionMode.bySessionId[sessionId];
|
||||
delete state.agentCapabilities.bySessionId[sessionId];
|
||||
delete state.sessionModels.bySessionId[sessionId];
|
||||
delete state.sessionMcpStatus.bySessionId[sessionId];
|
||||
delete state.promptUsage.bySessionId[sessionId];
|
||||
delete state.sessionTodos.bySessionId[sessionId];
|
||||
delete state.prepareProgress.bySessionId[sessionId];
|
||||
@@ -275,6 +276,7 @@ export const defaultSessionRuntimeState: SessionRuntimeSliceState = {
|
||||
sessionMode: { bySessionId: {} },
|
||||
agentCapabilities: { bySessionId: {} },
|
||||
sessionModels: { bySessionId: {} },
|
||||
sessionMcpStatus: { bySessionId: {} },
|
||||
promptUsage: { bySessionId: {} },
|
||||
sessionTodos: { bySessionId: {} },
|
||||
userShells: { byEnvironmentId: {}, loading: {}, loaded: {} },
|
||||
@@ -590,6 +592,10 @@ export const createSessionRuntimeSlice: StateCreator<
|
||||
set((draft) => {
|
||||
draft.sessionModels.bySessionId[sessionId] = data;
|
||||
}),
|
||||
setSessionMCPStatus: (sessionId, history) =>
|
||||
set((draft) => {
|
||||
draft.sessionMcpStatus.bySessionId[sessionId] = history;
|
||||
}),
|
||||
setPromptUsage: (sessionId, usage) =>
|
||||
set((draft) => {
|
||||
draft.promptUsage.bySessionId[sessionId] = usage;
|
||||
|
||||
@@ -250,6 +250,45 @@ export type SessionModelsState = {
|
||||
>;
|
||||
};
|
||||
|
||||
export type MCPAttachmentStatus =
|
||||
| "unknown"
|
||||
| "delivered"
|
||||
| "connected"
|
||||
| "active"
|
||||
| "failed"
|
||||
| "filtered"
|
||||
| "unavailable";
|
||||
|
||||
export type MCPAttachmentServer = {
|
||||
name: string;
|
||||
source?: "kandev" | "profile";
|
||||
transport?: string;
|
||||
target?: string;
|
||||
status: MCPAttachmentStatus;
|
||||
reason_code?: string;
|
||||
summary?: string;
|
||||
connection_id?: string;
|
||||
tool_count?: number;
|
||||
};
|
||||
|
||||
export type MCPAttachmentHistory = {
|
||||
version: number;
|
||||
current: {
|
||||
attachment_attempt_id: string;
|
||||
started_at: string;
|
||||
updated_at?: string;
|
||||
servers?: MCPAttachmentServer[];
|
||||
};
|
||||
previous?: Array<{
|
||||
attachment_attempt_id: string;
|
||||
started_at: string;
|
||||
updated_at?: string;
|
||||
servers?: MCPAttachmentServer[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export type SessionMCPStatusState = { bySessionId: Record<string, MCPAttachmentHistory> };
|
||||
|
||||
export type PromptUsageState = {
|
||||
bySessionId: Record<string, PromptUsageEntry>;
|
||||
};
|
||||
@@ -352,6 +391,7 @@ export type SessionRuntimeSliceState = {
|
||||
sessionMode: SessionModeState;
|
||||
agentCapabilities: AgentCapabilitiesState;
|
||||
sessionModels: SessionModelsState;
|
||||
sessionMcpStatus: SessionMCPStatusState;
|
||||
promptUsage: PromptUsageState;
|
||||
sessionTodos: SessionTodosState;
|
||||
userShells: UserShellsState;
|
||||
@@ -414,6 +454,7 @@ export type SessionRuntimeSliceActions = {
|
||||
configBaseline?: Record<string, string>;
|
||||
},
|
||||
) => void;
|
||||
setSessionMCPStatus: (sessionId: string, history: MCPAttachmentHistory) => void;
|
||||
// Prompt usage actions
|
||||
setPromptUsage: (sessionId: string, usage: PromptUsageEntry) => void;
|
||||
// Session todos actions
|
||||
|
||||
@@ -47,6 +47,7 @@ export function buildStateOverrides(m: DefaultState) {
|
||||
sessionTodos: m.sessionTodos,
|
||||
agentCapabilities: m.agentCapabilities,
|
||||
sessionModels: m.sessionModels,
|
||||
sessionMcpStatus: m.sessionMcpStatus,
|
||||
promptUsage: m.promptUsage,
|
||||
sessionPollMode: m.sessionPollMode,
|
||||
githubStatus: m.githubStatus,
|
||||
|
||||
@@ -157,6 +157,7 @@ export type AppState = KanbanSlice & {
|
||||
sessionTodos: (typeof defaultSessionRuntimeState)["sessionTodos"];
|
||||
agentCapabilities: (typeof defaultSessionRuntimeState)["agentCapabilities"];
|
||||
sessionModels: (typeof defaultSessionRuntimeState)["sessionModels"];
|
||||
sessionMcpStatus: (typeof defaultSessionRuntimeState)["sessionMcpStatus"];
|
||||
promptUsage: (typeof defaultSessionRuntimeState)["promptUsage"];
|
||||
sessionPollMode: (typeof defaultSessionRuntimeState)["sessionPollMode"];
|
||||
|
||||
@@ -302,6 +303,10 @@ export type AppState = KanbanSlice & {
|
||||
upsertProcessStatus: (status: ProcessStatusEntry) => void;
|
||||
clearProcessOutput: (processId: string) => void;
|
||||
setActiveProcess: (sessionId: string, processId: string) => void;
|
||||
setSessionMCPStatus: (
|
||||
sessionId: string,
|
||||
history: import("./slices/session-runtime/types").MCPAttachmentHistory,
|
||||
) => void;
|
||||
setPreviewOpen: (sessionId: string, open: boolean) => void;
|
||||
togglePreviewOpen: (sessionId: string) => void;
|
||||
setPreviewView: (sessionId: string, view: PreviewViewMode) => void;
|
||||
|
||||
@@ -6,6 +6,8 @@ export type { BackendMessage } from "./backend-message";
|
||||
import type { BackendMessage } from "./backend-message";
|
||||
import type { OfficeBackendMessageMap } from "./office-events";
|
||||
export type { OfficeEventType, OfficeEventPayload } from "./office-events";
|
||||
import type { RunEventAppendedPayload } from "./run-events";
|
||||
export type { RunEventAppendedPayload } from "./run-events";
|
||||
|
||||
import type {
|
||||
AvailableAgent,
|
||||
@@ -33,6 +35,7 @@ import type {
|
||||
AgentCapabilitiesPayload,
|
||||
SessionInfoPayload,
|
||||
SessionModelsPayload,
|
||||
SessionMCPStatusPayload,
|
||||
SessionPromptUsagePayload,
|
||||
SessionTodosPayload,
|
||||
} from "./session-runtime-payloads";
|
||||
@@ -490,6 +493,7 @@ export {
|
||||
type SessionModelInfoPayload,
|
||||
type ConfigOptionPayload,
|
||||
type SessionModelsPayload,
|
||||
type SessionMCPStatusPayload,
|
||||
type SessionInfoPayload,
|
||||
type SessionTodosPayload,
|
||||
} from "./session-runtime-payloads";
|
||||
@@ -602,6 +606,10 @@ export type BackendMessageMap = OfficeBackendMessageMap &
|
||||
AgentCapabilitiesPayload
|
||||
>;
|
||||
"session.models_updated": BackendMessage<"session.models_updated", SessionModelsPayload>;
|
||||
"session.mcp_status_updated": BackendMessage<
|
||||
"session.mcp_status_updated",
|
||||
SessionMCPStatusPayload
|
||||
>;
|
||||
"session.info_updated": BackendMessage<"session.info_updated", SessionInfoPayload>;
|
||||
"session.todos_updated": BackendMessage<"session.todos_updated", SessionTodosPayload>;
|
||||
"session.prompt_usage": BackendMessage<"session.prompt_usage", SessionPromptUsagePayload>;
|
||||
@@ -657,17 +665,5 @@ export type BackendMessageMap = OfficeBackendMessageMap &
|
||||
"run.event.appended": BackendMessage<"run.event.appended", RunEventAppendedPayload>;
|
||||
};
|
||||
|
||||
// The WS gateway forwards run events verbatim: id at top level, row under `event`.
|
||||
export type RunEventAppendedPayload = {
|
||||
run_id: string;
|
||||
event: {
|
||||
seq: number;
|
||||
event_type: string;
|
||||
level: string;
|
||||
payload: string;
|
||||
created_at: string;
|
||||
};
|
||||
};
|
||||
|
||||
// Workspace file types (extracted to reduce file size)
|
||||
export * from "./workspace-files";
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/** The WS gateway forwards run events verbatim: id at top level, row under `event`. */
|
||||
export type RunEventAppendedPayload = {
|
||||
run_id: string;
|
||||
event: {
|
||||
seq: number;
|
||||
event_type: string;
|
||||
level: string;
|
||||
payload: string;
|
||||
created_at: string;
|
||||
};
|
||||
};
|
||||
@@ -50,6 +50,38 @@ export type SessionModelsPayload = {
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export type MCPAttachmentServerPayload = {
|
||||
name: string;
|
||||
source?: "kandev" | "profile";
|
||||
transport?: string;
|
||||
target?: string;
|
||||
status: "unknown" | "delivered" | "connected" | "active" | "failed" | "filtered" | "unavailable";
|
||||
reason_code?: string;
|
||||
summary?: string;
|
||||
connection_id?: string;
|
||||
tool_count?: number;
|
||||
};
|
||||
|
||||
export type MCPAttachmentAttemptPayload = {
|
||||
attachment_attempt_id: string;
|
||||
started_at: string;
|
||||
updated_at?: string;
|
||||
servers?: MCPAttachmentServerPayload[];
|
||||
};
|
||||
|
||||
export type MCPAttachmentHistoryPayload = {
|
||||
version: number;
|
||||
current: MCPAttachmentAttemptPayload;
|
||||
previous?: MCPAttachmentAttemptPayload[];
|
||||
};
|
||||
|
||||
export type SessionMCPStatusPayload = {
|
||||
task_id: string;
|
||||
session_id: string;
|
||||
history: MCPAttachmentHistoryPayload;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export type SessionInfoPayload = {
|
||||
task_id: string;
|
||||
session_id: string;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { StoreApi } from "zustand";
|
||||
import type { AppState } from "@/lib/state/store";
|
||||
import type { BackendMessageMap } from "@/lib/types/backend";
|
||||
import { registerSessionMCPStatusHandlers } from "./session-mcp-status";
|
||||
|
||||
function makeStore() {
|
||||
const state = {
|
||||
setSessionMCPStatus: vi.fn(),
|
||||
} as unknown as AppState;
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
setState: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
getInitialState: vi.fn(),
|
||||
} as unknown as StoreApi<AppState>;
|
||||
}
|
||||
|
||||
function makeMessage(
|
||||
payload: BackendMessageMap["session.mcp_status_updated"]["payload"],
|
||||
): BackendMessageMap["session.mcp_status_updated"] {
|
||||
return {
|
||||
id: "message-1",
|
||||
type: "notification",
|
||||
action: "session.mcp_status_updated",
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
describe("session.mcp_status_updated handler", () => {
|
||||
it("stores an attachment report for its exact session", () => {
|
||||
const store = makeStore();
|
||||
const handler = registerSessionMCPStatusHandlers(store)["session.mcp_status_updated"]!;
|
||||
const history = {
|
||||
version: 1,
|
||||
current: {
|
||||
attachment_attempt_id: "attempt-1",
|
||||
started_at: "2026-07-30T00:00:00Z",
|
||||
servers: [{ name: "kandev", status: "active" as const }],
|
||||
},
|
||||
};
|
||||
|
||||
handler(
|
||||
makeMessage({
|
||||
task_id: "task-1",
|
||||
session_id: "session-1",
|
||||
history,
|
||||
timestamp: "2026-07-30T00:00:00Z",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(store.getState().setSessionMCPStatus).toHaveBeenCalledWith("session-1", history);
|
||||
});
|
||||
|
||||
it("ignores malformed reports without an attempt identity", () => {
|
||||
const store = makeStore();
|
||||
const handler = registerSessionMCPStatusHandlers(store)["session.mcp_status_updated"]!;
|
||||
|
||||
handler(
|
||||
makeMessage({
|
||||
task_id: "task-1",
|
||||
session_id: "session-1",
|
||||
history: { version: 1, current: { attachment_attempt_id: "", started_at: "" } },
|
||||
timestamp: "2026-07-30T00:00:00Z",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(store.getState().setSessionMCPStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { StoreApi } from "zustand";
|
||||
import type { AppState } from "@/lib/state/store";
|
||||
import type { SessionMCPStatusPayload } from "@/lib/types/backend";
|
||||
import type { WsHandlers } from "@/lib/ws/handlers/types";
|
||||
|
||||
export function registerSessionMCPStatusHandlers(store: StoreApi<AppState>): WsHandlers {
|
||||
return {
|
||||
"session.mcp_status_updated": (message) => {
|
||||
const payload = message.payload as SessionMCPStatusPayload | undefined;
|
||||
if (!payload?.session_id || !payload.history?.current?.attachment_attempt_id) return;
|
||||
store.getState().setSessionMCPStatus(payload.session_id, payload.history);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { registerSessionModeHandlers } from "@/lib/ws/handlers/session-mode";
|
||||
import { registerSessionPollModeHandlers } from "@/lib/ws/handlers/session-poll-mode";
|
||||
import { registerAgentCapabilitiesHandlers } from "@/lib/ws/handlers/agent-capabilities";
|
||||
import { registerSessionModelsHandlers } from "@/lib/ws/handlers/session-models";
|
||||
import { registerSessionMCPStatusHandlers } from "@/lib/ws/handlers/session-mcp-status";
|
||||
import { registerSessionInfoHandlers } from "@/lib/ws/handlers/session-info";
|
||||
import { registerSessionTodosHandlers } from "@/lib/ws/handlers/session-todos";
|
||||
import { registerPromptUsageHandlers } from "@/lib/ws/handlers/prompt-usage";
|
||||
@@ -55,6 +56,7 @@ export function registerWsHandlers(store: StoreApi<AppState>) {
|
||||
...registerSessionPollModeHandlers(store),
|
||||
...registerAgentCapabilitiesHandlers(store),
|
||||
...registerSessionModelsHandlers(store),
|
||||
...registerSessionMCPStatusHandlers(store),
|
||||
...registerSessionInfoHandlers(store),
|
||||
...registerSessionTodosHandlers(store),
|
||||
...registerPromptUsageHandlers(store),
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# ADR-2026-07-30-session-owned-mcp-observability: Keep MCP Attachment Evidence Session Owned
|
||||
|
||||
**Status:** accepted
|
||||
**Date:** 2026-07-30
|
||||
**Area:** backend, frontend, protocol, security
|
||||
|
||||
## Context
|
||||
|
||||
Kandev configures MCP at several boundaries: agent profiles resolve servers,
|
||||
executor policy can filter them, ACP adapters deliver supported transports,
|
||||
passthrough strategies materialize CLI-specific configuration, and agentctl
|
||||
hosts Kandev's task-aware MCP endpoint. The current toolbar collapses those
|
||||
steps into a list derived from the profile, so “configured” is presented as if
|
||||
it meant “attached.”
|
||||
|
||||
ACP `initialize` advertises transport capabilities and `session/new` accepts
|
||||
`mcpServers`, but the protocol does not return a standard list of MCP servers
|
||||
that connected successfully. A connection failure can also happen before the
|
||||
target server observes any request. Raw ACP and stderr capture would improve
|
||||
diagnosis but can contain prompts, files, tool arguments, URLs, headers,
|
||||
environment values, and credentials, making unconditional release logging an
|
||||
unacceptable privacy boundary.
|
||||
|
||||
Concurrent agents make global status invalid. A task can own several Kandev
|
||||
sessions, and a single session can restart into several execution generations
|
||||
or reset its provider session inside one execution. An old successful
|
||||
connection must not make a new attachment attempt appear healthy.
|
||||
|
||||
## Decision
|
||||
|
||||
MCP attachment observability is owned by the Kandev session and execution:
|
||||
|
||||
- Every report is keyed by backend-owned `task_id`, `session_id`,
|
||||
`execution_id`, and `attachment_attempt_id`, enriched with agent/profile
|
||||
identity and provider ACP session identity when available. Every
|
||||
`session/new`, `session/load`, `session/reset`, or passthrough process start
|
||||
creates a new attempt before delivery.
|
||||
- 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.
|
||||
- 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
|
||||
observed evidence and never treats missing evidence as a connection failure.
|
||||
- Kandev's in-agentctl MCP server uses transport/session hooks to observe
|
||||
initialize, `tools/list`, calls, and disconnects. Third-party direct MCP
|
||||
servers remain delivery-unverified unless an explicit agent error or future
|
||||
observable proxy/provider contract exists.
|
||||
- Release mode persists a bounded structured timeline for the current and two
|
||||
previous attachment attempts. It contains stable reason codes and sanitized
|
||||
summaries, not raw protocol frames or persistent stderr.
|
||||
- Sanitized network targets retain only scheme and host with optional port.
|
||||
Stdio targets retain only the executable basename. Headers, environment
|
||||
values, arguments, credentials, prompts, files, tool inputs/results, full
|
||||
URLs, and raw agent output are excluded.
|
||||
- Recent agent stderr remains an explicit, on-demand, bounded, ephemeral
|
||||
diagnostic. It is warned as potentially sensitive and is never included in
|
||||
the default copied report.
|
||||
- A user-initiated endpoint test runs only against a server already present in
|
||||
the session's effective configuration and from the same executor. Its result
|
||||
is presented as reachability evidence, not proof that the agent attached.
|
||||
- The toolbar trigger stays visually neutral. Per-server colors and diagnostic
|
||||
actions appear in a desktop hover/focus popover that can be pinned, or an
|
||||
inset mobile drawer opened by a 44px touch target.
|
||||
- `acpdbg` gains an opt-in sentinel MCP probe so agent integrations can be
|
||||
tested against the real ACP handshake. Advertised capabilities, delivery,
|
||||
initialize, tool listing, and tool use remain separate findings.
|
||||
|
||||
The observable behavior is specified in
|
||||
[`docs/specs/mcp-session-observability/spec.md`](../specs/mcp-session-observability/spec.md).
|
||||
|
||||
## Consequences
|
||||
|
||||
Users can distinguish “Kandev sent this configuration” from “the agent loaded
|
||||
these tools” for each concurrent session, including after restarts. Release
|
||||
diagnostics remain useful without enabling high-risk raw frame logging.
|
||||
|
||||
The report is deliberately asymmetric: Kandev can automatically prove more
|
||||
about its own in-session endpoint than about third-party servers. Some rows
|
||||
will remain unverified even when the agent is working correctly. The UI and
|
||||
public documentation must explain that limitation.
|
||||
|
||||
Lifecycle, agentctl, orchestrator persistence, boot hydration, WebSocket state,
|
||||
and the chat toolbar must share one versioned report contract. Each new
|
||||
attachment attempt supersedes current display evidence before it can deliver
|
||||
configuration, including resets that reuse an execution. Passthrough agents
|
||||
must emit honest resolution/materialization evidence; a missing passthrough
|
||||
strategy is Filtered or Unavailable, not Active.
|
||||
|
||||
Endpoint tests and stderr reads become new session-keyed diagnostic entry
|
||||
points. They must use the existing authorization guards, accept only stored
|
||||
server names, remain bounded, and avoid turning arbitrary user input into
|
||||
network or process execution.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Keep deriving status from profile configuration.** Rejected because it
|
||||
creates false positives and cannot distinguish concurrent executions.
|
||||
- **Parse a connected-server list from ACP.** Rejected because ACP defines no
|
||||
such portable response; provider-specific metadata can be supplemental but
|
||||
cannot be the product contract.
|
||||
- **Mark a server failed when no connection appears after a timeout.** Rejected
|
||||
because third-party connections may be unobservable or lazy, and absence is
|
||||
not failure evidence.
|
||||
- **Proxy every MCP server through Kandev.** Deferred because it changes
|
||||
transport ownership, credentials, performance, and failure domains far
|
||||
beyond an observability feature.
|
||||
- **Enable raw ACP and stderr logging in release mode.** Rejected because the
|
||||
diagnostic value does not justify persistent capture of user and credential
|
||||
data.
|
||||
- **Show status colors directly on every toolbar icon.** Rejected because many
|
||||
concurrent sessions would create visual noise; the neutral trigger with
|
||||
details on disclosure preserves density.
|
||||
- **Key status only by task or ACP session ID.** Rejected because tasks can run
|
||||
several agents and ACP IDs can rotate or collide across providers. Kandev's
|
||||
session, execution, and attachment-attempt identities form the stable
|
||||
ownership boundary.
|
||||
@@ -88,3 +88,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
|
||||
| 2026-07-28-coarse-running-busy-signal | [Restore Coarse Running Prompt Admission](2026-07-28-coarse-running-busy-signal.md) | accepted | backend, frontend, protocol | 2026-07-28 |
|
||||
| 2026-07-29-agent-stall-user-controlled-recovery | [Keep Agent Stall Recovery User Controlled](2026-07-29-agent-stall-user-controlled-recovery.md) | accepted | backend, frontend, protocol | 2026-07-29 |
|
||||
| 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-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 |
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
---
|
||||
spec: docs/specs/mcp-session-observability/spec.md
|
||||
decision: docs/decisions/2026-07-30-session-owned-mcp-observability.md
|
||||
created: 2026-07-30
|
||||
status: draft
|
||||
---
|
||||
|
||||
# Implementation Plan: Session MCP Attachment Observability
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the toolbar's profile-derived MCP list with a versioned,
|
||||
session-owned attachment report. Agentctl will emit honest configuration and
|
||||
local-server observations through its existing agent event stream; lifecycle
|
||||
and orchestrator will attach immutable Kandev ownership, persist a bounded
|
||||
history, broadcast live updates, and hydrate reloads. The frontend will disclose
|
||||
per-server state from that report in a neutral desktop popover or mobile drawer.
|
||||
|
||||
Release diagnostics remain structured and sanitized. Raw ACP frames stay
|
||||
development-only, while endpoint tests and bounded agent output are explicit
|
||||
user actions. `acpdbg` receives a sentinel MCP probe for integration work.
|
||||
|
||||
## Confirmed root causes and protocol limits
|
||||
|
||||
- `useSessionMcp` reads profile configuration and defaults to `["kandev"]`,
|
||||
including after a profile-config fetch failure. `McpIndicator` therefore
|
||||
labels intended configuration as active runtime state.
|
||||
- Structured ACP sessions inject Kandev's HTTP and SSE variants, then filter
|
||||
by advertised or assumed transport support before sending `session/new` or
|
||||
`session/load`. The filter currently returns only the surviving servers, so
|
||||
its omission reasons are lost.
|
||||
- Passthrough materialization is a separate path. If a passthrough agent has no
|
||||
`MCPStrategy`, `applyPassthroughMCP` silently returns without exposing any
|
||||
servers even when discovery says the agent supports MCP.
|
||||
- ACP advertises MCP transport capabilities and accepts `mcpServers`, but does
|
||||
not return a portable connected-server list. A successful ACP session request
|
||||
proves delivery acceptance, not connection.
|
||||
- Agentctl hosts Kandev's task MCP endpoint, and the existing `mcp-go` version
|
||||
exposes hooks for session registration, initialize, `tools/list`, tool calls,
|
||||
errors, and unregister. Those hooks can prove local endpoint behavior without
|
||||
raw frame logging.
|
||||
- Third-party profile MCP servers connect directly to the agent, so Kandev
|
||||
cannot automatically observe their initialize or `tools/list`.
|
||||
- Agent stderr is already retained in a bounded in-memory buffer and reachable
|
||||
through the internal `agent.stderr` stream request. It is not currently
|
||||
exposed as an authorized, on-demand user diagnostic.
|
||||
- `acpdbg` sends an empty `mcpServers` array and its runner keeps the original
|
||||
empty `RunConfig.Workdir` after allocating a temporary child directory.
|
||||
Consequently `session/new` can receive an empty `cwd`, and the tool cannot
|
||||
test whether a real agent attaches to an injected MCP server.
|
||||
|
||||
---
|
||||
|
||||
## Status contract
|
||||
|
||||
### Attachment history
|
||||
|
||||
Add a versioned contract shared across the agentctl stream, lifecycle events,
|
||||
persistence, boot hydration, and frontend state:
|
||||
|
||||
- `MCPAttachmentHistory`
|
||||
- schema version;
|
||||
- current `attachment_attempt_id`;
|
||||
- current attempt plus at most two previous attempts.
|
||||
- `MCPAttachmentAttempt`
|
||||
- backend-owned `task_id`, `session_id`, `execution_id`, and
|
||||
`attachment_attempt_id`;
|
||||
- `agent_id`, `agent_profile_id`, and optional `acp_session_id`;
|
||||
- start, update, supersession, and session-acceptance times;
|
||||
- bounded evidence timeline;
|
||||
- report-level sanitized agent session error when it cannot be mapped to one
|
||||
server.
|
||||
- `MCPServerAttachment`
|
||||
- name, source (`kandev` or `profile`), and transport;
|
||||
- sanitized target description;
|
||||
- strongest display status;
|
||||
- stable filter/failure reason code and bounded summary;
|
||||
- latest connection ID, tool count, and evidence timestamps where observable;
|
||||
- latest endpoint-test result, stored separately from attachment evidence.
|
||||
|
||||
Create a new attachment attempt before every ACP `session/new`,
|
||||
`session/load`, or `session/reset` delivery and before every passthrough process
|
||||
start. Publishing that attempt immediately supersedes the old current attempt,
|
||||
so a failed or slow reset cannot retain Active status.
|
||||
|
||||
The display reducer follows the spec:
|
||||
|
||||
- `tools_list_observed` → Active;
|
||||
- `initialize_observed` → Connected;
|
||||
- delivered or accepted without observable connection → Delivered /
|
||||
connection unverified;
|
||||
- explicit server-specific error → Failed;
|
||||
- policy/capability/strategy omission → Filtered or Unavailable.
|
||||
|
||||
Tool use enriches Active state and does not define another toolbar color.
|
||||
Disconnect remains visible in detail; a later connection ID can restore the
|
||||
current connection evidence.
|
||||
|
||||
### Sanitization and bounds
|
||||
|
||||
- Keep at most three attempts and 64 evidence events per attempt.
|
||||
- Store MCP names, stable reason codes, timestamps, counts, and ownership IDs.
|
||||
- Reduce network targets with URL parsing to `scheme://host[:port]`, dropping
|
||||
user info, path, query, and fragment.
|
||||
- Reduce stdio targets to `filepath.Base(command)`.
|
||||
- Pass error summaries through the existing routing/error sanitizer, cap them
|
||||
at 256 bytes, and never fall back to raw structured objects.
|
||||
- Never persist headers, environment values, command arguments, prompts,
|
||||
content, tool arguments/results, raw ACP frames, or agent stderr.
|
||||
- On-demand stderr is capped at the smaller of 50 lines or 16 KiB, remains
|
||||
ephemeral, and is excluded from Copy sanitized diagnostics.
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
### Agentctl evidence production
|
||||
|
||||
- Add typed MCP evidence fields and constants under
|
||||
`apps/backend/internal/agentctl/types/streams/`.
|
||||
- Give `process.Manager` a non-blocking typed update publisher so API delivery
|
||||
code and MCP hooks use the same ordered `/agent/stream` channel as adapter
|
||||
events. Do not create a second event stream.
|
||||
- Change ACP transport filtering to return both the selected server list and a
|
||||
decision per input server. Record agent-advertised versus assumed transport
|
||||
support and stable reason codes without storing secrets.
|
||||
- In `handleWSNewSession`, `handleWSLoadSession`, and reset handling:
|
||||
- start and publish a new attempt before delivery;
|
||||
- publish configured/filter/delivered evidence;
|
||||
- publish session acceptance with the returned ACP session ID;
|
||||
- publish a sanitized report-level or server-specific explicit error when
|
||||
available.
|
||||
- Install `mcp-go` hooks in the in-session Kandev MCP server. Attribute
|
||||
initialize, `tools/list`, call, error, register, and unregister events to the
|
||||
instance's task/session/execution/attempt ownership and the hook session's
|
||||
opaque connection ID.
|
||||
- Ensure the server factory passes `InstanceConfig.InstanceID`, the status
|
||||
publisher, and the active attempt source into the MCP server. Tool handlers
|
||||
continue injecting task/session identity from server context.
|
||||
- Update passthrough materialization to publish resolved, filtered,
|
||||
materialized/delivered, start-accepted, and explicit failure evidence. A nil
|
||||
strategy produces an honest unavailable reason instead of a silent no-op.
|
||||
|
||||
### Lifecycle, persistence, and live delivery
|
||||
|
||||
- Normalize the agentctl MCP event in lifecycle without treating it as prompt
|
||||
activity or chat content. Add the lifecycle payload and domain event subject.
|
||||
- Stamp every accepted event with the live `AgentExecution` ownership. Reject
|
||||
or ignore evidence whose execution or attachment attempt is no longer
|
||||
current.
|
||||
- Add `SessionMetaKeyMCPAttachmentHistory` and typed JSON rehydration helpers.
|
||||
- In the orchestrator handler, reduce each event into the bounded history and
|
||||
write only that metadata key atomically. Persistence failures are logged and
|
||||
do not alter session state.
|
||||
- Publish `session.mcp_status_updated` on the session-scoped event bus subject
|
||||
and register it with the WebSocket session broadcaster.
|
||||
- Add task-detail boot state `mcpAttachments.bySessionId`, using the persisted
|
||||
history so reload does not wait for a live event.
|
||||
- Add a frontend session-runtime slice, hydration merge, WebSocket handler, and
|
||||
tests. Removing a session clears its attachment report.
|
||||
|
||||
### Release diagnostic operations
|
||||
|
||||
- Add session-scoped WebSocket requests:
|
||||
- `session.mcp_test_endpoint` with `session_id` and `server_name`;
|
||||
- `session.mcp_recent_agent_output` with `session_id`.
|
||||
- Keep the existing raw-gateway rejection of `mcp.*` actions. These new
|
||||
`session.*` operations pass through ordinary dispatcher registration and the
|
||||
gateway's session authorization backstop, plus handler-level authorization.
|
||||
- Resolve the requested server name from the current persisted/effective
|
||||
attachment configuration. Reject unknown names; never accept a caller URL,
|
||||
command, headers, args, or environment.
|
||||
- Execute the bounded initialize + `tools/list` test through the owning live
|
||||
agentctl instance so network and stdio checks run inside the session's
|
||||
executor. Close the temporary client/process on success, error, cancellation,
|
||||
and timeout.
|
||||
- Store only the sanitized test result in the attachment report, explicitly
|
||||
classified as endpoint reachability rather than agent attachment.
|
||||
- Reuse `GetAgentStderr` for the output request, cap it to 50 lines/16 KiB in
|
||||
the backend response, do not persist it, and return a clear unavailable
|
||||
response when the execution is not live.
|
||||
- Reuse the existing reset-context recovery path in the UI. Do not add an
|
||||
automatic retry, process restart, or state transition based on missing
|
||||
evidence.
|
||||
|
||||
---
|
||||
|
||||
## Frontend
|
||||
|
||||
### Store-derived status
|
||||
|
||||
- Replace `useSessionMcp(agentProfileId)` with a session-ID-based hook that
|
||||
reads `mcpAttachments.bySessionId`. It may show a neutral loading or
|
||||
unavailable state while no report exists, but must not synthesize Kandev as
|
||||
Active from profile configuration.
|
||||
- Keep configuration context in the status report itself. The component never
|
||||
fetches profile MCP JSON directly.
|
||||
- Define a pure display reducer for row status, colors, checklist text,
|
||||
timestamp formatting, and copy-safe diagnostics. Unit-test unknown/newer
|
||||
reason codes with a conservative fallback.
|
||||
|
||||
### Responsive status disclosure
|
||||
|
||||
- Replace `McpIndicator` with a semantic button and shared
|
||||
`McpStatusContent`.
|
||||
- **Desktop:** use a controlled Popover-style disclosure. Pointer hover and
|
||||
keyboard focus open it transiently; click pins it for actions; outside
|
||||
interaction, Escape, or a second trigger click closes it. Keep focus order
|
||||
and `aria-expanded` correct.
|
||||
- **Touch/coarse pointer:** use `useTouchDrawer` and the shared inset Drawer
|
||||
treatment. A `.tap()` on a 44px trigger opens the bottom drawer; no
|
||||
hover-only behavior remains.
|
||||
- Keep the plug icon neutral in every state. Put green/amber/red/gray only in
|
||||
the disclosed server rows.
|
||||
- Show the current Kandev session, execution, and attempt in diagnostics, but
|
||||
keep opaque IDs secondary to server name and plain-language status.
|
||||
- Provide the checklist and actions from the spec. Fetch recent output only
|
||||
after explicit activation and warn before rendering it. Copy diagnostics is
|
||||
built from the sanitized report and test result only.
|
||||
- Link to the existing MCP settings surface. Show the existing Reset context
|
||||
flow only when its current eligibility rules allow it and retain its
|
||||
confirmation copy.
|
||||
- Use the same component in structured and passthrough chat toolbars; status
|
||||
truth comes from the active session report.
|
||||
|
||||
### Mobile design contract
|
||||
|
||||
- **Desktop outcome:** a neutral toolbar button opens a compact hover/focus
|
||||
popover; clicking pins a scroll-bounded list with per-server status and
|
||||
diagnostics.
|
||||
- **Mobile entry point:** a 44px neutral plug button in the existing
|
||||
horizontally scrollable composer toolbar.
|
||||
- **Mobile outcome:** a safe-area-aware inset bottom drawer containing the same
|
||||
server list, checklist, and actions. Rows wrap long names and summaries, the
|
||||
drawer owns its internal scroll, and the document has no horizontal
|
||||
overflow.
|
||||
- **Nearest shipped exemplar:** `useTouchDrawer` plus the Drawer/Popover split
|
||||
in GitHub task credential and PR status surfaces.
|
||||
- **Shared logic:** one store selector, display reducer, status content
|
||||
component, diagnostic request layer, and copy serializer across desktop and
|
||||
touch.
|
||||
|
||||
---
|
||||
|
||||
## Developer probe
|
||||
|
||||
- Fix `acpdbg.NewRunner` to retain the resolved temporary working directory in
|
||||
`r.cfg.Workdir`, so ACP `cwd` matches the spawned child.
|
||||
- Add an `mcp-probe` command that starts a temporary streamable-HTTP sentinel,
|
||||
injects it into `session/new`, waits within the existing command timeout, and
|
||||
summarizes:
|
||||
- advertised MCP transports;
|
||||
- server delivered;
|
||||
- initialize observed;
|
||||
- `tools/list` observed and count;
|
||||
- optional sentinel tool use when an explicit prompt is supplied.
|
||||
- Give sentinel connections opaque IDs and write their lifecycle markers as
|
||||
JSONL metadata alongside the raw ACP frames.
|
||||
- Keep `--exec`, registry command resolution, inherited/stripped environment,
|
||||
workdir, stderr capture, and timeout behavior aligned with ordinary probes.
|
||||
- Update `cmd/acpdbg/README.md` and the `acp-debug` skill with exact syntax,
|
||||
output fields, interpretation, and the warning that no observation is not a
|
||||
portable agent failure.
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
- **Wire contract and reducer:** event kinds, status precedence, attempt
|
||||
supersession, bounds, target sanitization, and typed JSON rehydration.
|
||||
- **ACP delivery:** HTTP/SSE capability selection, filtered reasons, delivery
|
||||
events, session acceptance, explicit errors, and reset attempt rotation.
|
||||
- **Kandev endpoint observation:** separate connections and attempts emit
|
||||
initialize, `tools/list`, tool-call, error, and unregister evidence with no
|
||||
tool payloads or agent-provided ownership IDs.
|
||||
- **Passthrough:** strategies emit delivered evidence; nil strategy and
|
||||
materialization failures are unavailable/failed rather than active.
|
||||
- **Persistence:** orchestrator rejects stale attempts, retains three attempts
|
||||
and 64 events each, persists sanitized data, broadcasts live state, and
|
||||
hydrates task detail after reload.
|
||||
- **Diagnostics:** arbitrary targets are rejected, authorization runs before
|
||||
dependencies, network and stdio tests are bounded and cleaned up, successful
|
||||
tests do not promote attachment state, stderr is capped and never persisted.
|
||||
- **Frontend state:** boot hydration, WS replacement, active-session
|
||||
separation, session removal, status reduction, diagnostic copy redaction,
|
||||
desktop disclosure state, keyboard operation, and touch Drawer selection.
|
||||
- **acpdbg:** resolved temp cwd reaches `session/new`; fake ACP clients that
|
||||
connect, ignore, list, call, or error produce distinct summaries.
|
||||
|
||||
## E2E tests
|
||||
|
||||
- `apps/web/e2e/tests/chat/mcp-status.spec.ts`
|
||||
- create two sessions in one task with different attachment reports;
|
||||
- hover and keyboard-focus the neutral desktop trigger;
|
||||
- pin it by click and verify Active, Connected, Delivered/unverified,
|
||||
Failed, and Filtered rows;
|
||||
- switch session tabs and verify evidence never leaks between agents;
|
||||
- reload and verify persisted status hydrates;
|
||||
- run a mocked endpoint test, reveal bounded output, and confirm copied
|
||||
diagnostics omit that output and sensitive target parts.
|
||||
- `apps/web/e2e/tests/chat/mobile-mcp-status.spec.ts`
|
||||
- tap the 44px trigger under `mobile-chrome`;
|
||||
- verify the inset Drawer contains the same rows and actions;
|
||||
- exercise one diagnostic action;
|
||||
- assert safe-area layout, internal scrolling, and no horizontal overflow.
|
||||
|
||||
E2E setup may seed bounded attachment metadata and diagnostic responses through
|
||||
E2E-only helpers, but assertions must operate through the rendered UI. Backend
|
||||
integration tests remain responsible for proving real MCP hook events.
|
||||
|
||||
---
|
||||
|
||||
## Public documentation
|
||||
|
||||
- Update `docs/public/agents-and-profiles.md` troubleshooting to distinguish
|
||||
configured, delivered, connected, and tools loaded.
|
||||
- Update `docs/public/automation-and-mcp.md` with the toolbar diagnostic flow,
|
||||
third-party observability limit, endpoint-test interpretation, and release
|
||||
privacy boundary.
|
||||
- Update `docs/public/add-agent-cli.md` with the sentinel `acpdbg` workflow and
|
||||
the requirement that new passthrough agents report an unavailable strategy
|
||||
honestly.
|
||||
- Update `docs/public/feature-status.md` if its profile MCP observability row
|
||||
still describes only configuration.
|
||||
- Update `apps/backend/cmd/acpdbg/README.md` and
|
||||
`.agents/skills/acp-debug/SKILL.md` with the developer command contract.
|
||||
|
||||
---
|
||||
|
||||
## Implementation waves and parallel candidates
|
||||
|
||||
Implementation remains in the user-started primary session unless the user
|
||||
later authorizes native implementation agents.
|
||||
|
||||
Wave 1:
|
||||
|
||||
- [x] [Task 01: Establish the attachment evidence contract](task-01-attachment-evidence-contract.md)
|
||||
|
||||
Wave 2:
|
||||
|
||||
- [ ] [Task 02: Observe per-attempt MCP attachment](task-02-observe-mcp-attachment.md)
|
||||
- [ ] [Task 06: Extend acpdbg sentinel probing](task-06-acpdbg-sentinel-probe.md)
|
||||
|
||||
Tasks 02 and 06 are parallel-safe if explicitly delegated later. Task 02 owns
|
||||
agentctl API/adapter/process/MCP server and lifecycle passthrough files. Task 06
|
||||
owns `internal/agent/acpdbg`, `cmd/acpdbg`, and its developer documentation.
|
||||
Both consume Task 01's terminology but do not edit the same implementation
|
||||
files.
|
||||
|
||||
Wave 3:
|
||||
|
||||
- [ ] [Task 03: Persist session attachment reports](task-03-persist-attachment-reports.md)
|
||||
|
||||
Wave 4:
|
||||
|
||||
- [ ] [Task 04: Add release diagnostic operations](task-04-release-diagnostic-operations.md)
|
||||
|
||||
Wave 5:
|
||||
|
||||
- [ ] [Task 05: Build the responsive MCP status surface](task-05-responsive-mcp-status-surface.md)
|
||||
|
||||
Wave 6:
|
||||
|
||||
- [ ] [Task 07: Prove responsive status flows](task-07-responsive-status-e2e.md)
|
||||
|
||||
Wave 7:
|
||||
|
||||
- [ ] [Task 08: Document release troubleshooting](task-08-release-troubleshooting-docs.md)
|
||||
|
||||
## Required final verification
|
||||
|
||||
After every task's targeted RED/GREEN evidence is recorded:
|
||||
|
||||
1. `make -C apps/backend test`
|
||||
2. `cd apps && pnpm install --frozen-lockfile`
|
||||
3. `cd apps/web && pnpm run typecheck`
|
||||
4. `cd apps && pnpm --filter @kandev/web lint`
|
||||
5. `cd apps && pnpm --filter @kandev/web e2e:run tests/chat/mcp-status.spec.ts -- --project=chromium`
|
||||
6. `cd apps && pnpm --filter @kandev/web e2e:run tests/chat/mobile-mcp-status.spec.ts -- --project=mobile-chrome`
|
||||
7. `node --test scripts/validate-public-docs.test.mjs`
|
||||
8. `node scripts/validate-public-docs.mjs`
|
||||
|
||||
## Risks and non-goals
|
||||
|
||||
- Direct third-party MCP connectivity remains unobservable without a proxy or
|
||||
provider extension. The UI must preserve Delivered/unverified as a normal,
|
||||
honest state.
|
||||
- Some agents attach lazily, so no timeout converts missing initialize into
|
||||
failure.
|
||||
- MCP hooks can be concurrent. The reducer must be idempotent, preserve
|
||||
per-connection ordering where meaningful, and reject stale attempts.
|
||||
- Status reporting must not block the agent update channel or MCP request path;
|
||||
a dropped live event can be logged, while later snapshots and persistence
|
||||
repair the current display.
|
||||
- Endpoint tests can start stdio processes and make network requests, but only
|
||||
after explicit user action, only from stored effective configuration, and
|
||||
under strict timeout/cleanup.
|
||||
- This plan does not proxy third-party MCP traffic, add a provider-specific ACP
|
||||
connected-server extension, enable release raw-frame logging, or
|
||||
automatically reset/restart sessions.
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
id: "01-attachment-evidence-contract"
|
||||
title: "Establish the attachment evidence contract"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/mcp-session-observability/spec.md"
|
||||
---
|
||||
|
||||
# Task 01: Establish the attachment evidence contract
|
||||
|
||||
## Acceptance
|
||||
|
||||
- One versioned type system represents attachment history, attempts, servers,
|
||||
connection IDs, evidence events, test results, and stable reason codes.
|
||||
- A new attempt supersedes prior display evidence even when it reuses the same
|
||||
execution.
|
||||
- The pure reducer implements the spec's Active, Connected,
|
||||
Delivered/unverified, Failed, and Filtered/Unavailable precedence.
|
||||
- Histories retain at most three attempts and 64 evidence events per attempt.
|
||||
- URL, stdio target, and error sanitizers cannot retain credentials, URL
|
||||
paths/query/fragment, headers, environment, arguments, or raw payloads.
|
||||
- Typed and JSON-rehydrated metadata produce the same report.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd apps/backend && go test ./internal/agentctl/types/streams ./internal/agent/runtime/lifecycle`
|
||||
|
||||
Write table-driven RED tests for status precedence, reset-within-execution
|
||||
supersession, bounds, URL user-info/query stripping, stdio basename reduction,
|
||||
and malformed metadata before implementing the contract.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/agentctl/types/streams/agent.go`
|
||||
- `apps/backend/internal/agentctl/types/streams/mcp_attachment.go`
|
||||
- `apps/backend/internal/agentctl/types/streams/mcp_attachment_test.go`
|
||||
- `apps/backend/internal/agent/runtime/lifecycle/event_types.go`
|
||||
- `apps/backend/internal/agent/runtime/lifecycle/event_types_test.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential foundation. Every later task consumes these event names, reason
|
||||
codes, bounds, or persisted shapes.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec sections `Evidence model`, `Session, execution, and attempt ownership`,
|
||||
and `Release-safe attachment report`
|
||||
- ADR ownership and privacy decisions
|
||||
- Existing `SessionModelsSnapshot` typed/JSON rehydration pattern
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the RED assertions, final wire/persistence shapes, sanitizer examples,
|
||||
exact test result, files changed, blockers, and risks. Mark this task `done`
|
||||
and update its plan checkbox in the same conversation.
|
||||
|
||||
## Execution record
|
||||
|
||||
- RED: the new contract tests failed because the attachment types and snapshot
|
||||
loader did not exist; the labeled configuration privacy regression then
|
||||
failed by exposing `API_KEY`.
|
||||
- GREEN: versioned attempt/history/server/evidence types, bounded reduction,
|
||||
URL/stdio/error sanitizers, and typed/JSON metadata loading now satisfy the
|
||||
focused tests.
|
||||
- Verification: `cd apps/backend && go test ./internal/agentctl/types/streams
|
||||
./internal/agent/runtime/lifecycle` — passed.
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
id: "02-observe-mcp-attachment"
|
||||
title: "Observe per-attempt MCP attachment"
|
||||
status: in_progress
|
||||
wave: 2
|
||||
depends_on: ["01-attachment-evidence-contract"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/mcp-session-observability/spec.md"
|
||||
---
|
||||
|
||||
# Task 02: Observe per-attempt MCP attachment
|
||||
|
||||
## Acceptance
|
||||
|
||||
- ACP new/load/reset creates an attempt before delivery and emits configured,
|
||||
filtered, delivered, accepted, and explicit error evidence through the
|
||||
existing agent update stream.
|
||||
- Capability filtering returns stable per-server decisions and preserves the
|
||||
current HTTP-first/SSE-fallback behavior.
|
||||
- Kandev's in-session MCP endpoint emits register, initialize, `tools/list`,
|
||||
tool-call, error, and unregister evidence with opaque connection IDs.
|
||||
- Hook events use instance-owned task/session/execution/attempt identity and
|
||||
contain no tool arguments/results or agent-supplied ownership IDs.
|
||||
- Passthrough starts emit honest materialization evidence. A missing strategy
|
||||
is Unavailable instead of a silent success.
|
||||
- Status publication is non-blocking and does not create another agent stream.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd apps/backend && go test ./internal/agentctl/server/adapter/transport/acp ./internal/agentctl/server/process ./internal/agentctl/server/api ./internal/mcp/server ./internal/agent/runtime/lifecycle ./cmd/agentctl`
|
||||
|
||||
Start with RED adapter tests for filter decisions and attempt rotation, MCP
|
||||
server hook tests with two connections, and passthrough tests for nil strategy
|
||||
and materialization failure.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session.go`
|
||||
- `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session_test.go`
|
||||
- `apps/backend/internal/agentctl/server/adapter/transport/acp/mcp_test.go`
|
||||
- `apps/backend/internal/agentctl/server/process/manager.go`
|
||||
- `apps/backend/internal/agentctl/server/process/manager_test.go`
|
||||
- `apps/backend/internal/agentctl/server/api/agent.go`
|
||||
- `apps/backend/internal/agentctl/server/api/agent_test.go`
|
||||
- `apps/backend/internal/mcp/server/server.go`
|
||||
- `apps/backend/internal/mcp/server/server_test.go`
|
||||
- `apps/backend/cmd/agentctl/main.go`
|
||||
- `apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go`
|
||||
- `apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_test.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Task 01 defines the evidence event and reason-code contract.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Parallel-safe with Task 06 only after Task 01. This task exclusively owns the
|
||||
live agentctl and passthrough observation paths.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Current ACP capability filter and new/load/reset handlers
|
||||
- `mcp-go` session and request hooks
|
||||
- `process.Manager.updatesCh`
|
||||
- Existing passthrough strategy/materialization paths
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the RED assertions, attempt-creation boundary, emitted hook evidence,
|
||||
passthrough behavior, payload redaction audit, exact test results, files
|
||||
changed, blockers, and risks. Mark this task `done` and update its plan
|
||||
checkbox.
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
id: "03-persist-attachment-reports"
|
||||
title: "Persist session attachment reports"
|
||||
status: pending
|
||||
wave: 3
|
||||
depends_on: ["02-observe-mcp-attachment"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/mcp-session-observability/spec.md"
|
||||
---
|
||||
|
||||
# Task 03: Persist session attachment reports
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Lifecycle stamps agentctl evidence with the live execution and drops stale
|
||||
execution/attempt events without affecting prompt activity.
|
||||
- Orchestrator reduces events into the bounded history under one session
|
||||
metadata key and publishes `session.mcp_status_updated`.
|
||||
- The gateway broadcasts status only to authorized session/workspace clients.
|
||||
- Task-detail boot state hydrates the same current/history report after reload.
|
||||
- Frontend session-runtime state accepts boot and WebSocket reports, keeps
|
||||
concurrent sessions separate, and clears status when a session is removed.
|
||||
- Persistence failure is logged without changing task/session/agent state.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd apps/backend && go test ./internal/agent/runtime/lifecycle ./internal/orchestrator ./internal/gateway/websocket ./internal/backendapp ./internal/task/...`
|
||||
- `cd apps && pnpm --filter @kandev/web exec vitest run lib/ws/handlers/session-mcp-status.test.ts lib/state/slices/session-runtime/session-runtime-slice.test.ts`
|
||||
|
||||
Write RED tests for stale-attempt rejection, bounded persistence, WS routing,
|
||||
boot hydration, concurrent-session isolation, live replacement, and session
|
||||
cleanup before adding production handlers.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/agent/runtime/lifecycle/manager_events.go`
|
||||
- `apps/backend/internal/agent/runtime/lifecycle/event_types.go`
|
||||
- `apps/backend/internal/agent/runtime/lifecycle/events.go`
|
||||
- `apps/backend/internal/events/types.go`
|
||||
- `apps/backend/internal/orchestrator/event_handlers_streaming.go`
|
||||
- `apps/backend/internal/orchestrator/event_handlers_streaming_test.go`
|
||||
- `apps/backend/internal/task/models/models.go`
|
||||
- `apps/backend/internal/gateway/websocket/session_notifications.go`
|
||||
- `apps/backend/pkg/websocket/actions.go`
|
||||
- `apps/backend/internal/backendapp/boot_state.go`
|
||||
- `apps/backend/internal/backendapp/helpers.go`
|
||||
- `apps/backend/internal/backendapp/helpers_test.go`
|
||||
- `apps/web/lib/types/backend.ts`
|
||||
- `apps/web/lib/state/slices/session-runtime/types.ts`
|
||||
- `apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts`
|
||||
- `apps/web/lib/state/default-state.ts`
|
||||
- `apps/web/lib/state/hydration/merge-strategies.ts`
|
||||
- `apps/web/lib/ws/handlers/session-mcp-status.ts`
|
||||
- `apps/web/lib/ws/handlers/session-mcp-status.test.ts`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Task 02 produces ordered evidence events.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. Task 04 requires the current persisted/effective report, and Task
|
||||
05 requires the boot/WS store contract.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Existing `session.models_updated` event, metadata, boot-state, and frontend
|
||||
slice pattern
|
||||
- Existing session-scoped WebSocket broadcaster
|
||||
- Task 01 history reducer and bounds
|
||||
|
||||
## Output contract
|
||||
|
||||
Report stale-event handling, metadata and WS keys, boot-state shape, frontend
|
||||
store behavior, RED/GREEN evidence, exact tests, files changed, blockers, and
|
||||
risks. Mark this task `done` and update its plan checkbox.
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
id: "04-release-diagnostic-operations"
|
||||
title: "Add release diagnostic operations"
|
||||
status: pending
|
||||
wave: 4
|
||||
depends_on: ["03-persist-attachment-reports"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/mcp-session-observability/spec.md"
|
||||
---
|
||||
|
||||
# Task 04: Add release diagnostic operations
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Authorized users can test one server from the owning executor by
|
||||
`session_id` and stored `server_name`; arbitrary URLs, commands, headers,
|
||||
args, and environment are rejected.
|
||||
- The test performs bounded initialize + `tools/list`, cleans up network and
|
||||
stdio resources on every exit, and stores only a sanitized reachability
|
||||
result.
|
||||
- Test success never promotes attachment evidence to Active; test failure is
|
||||
labelled separately from an earlier agent attachment failure.
|
||||
- Authorized users can request at most 50 recent stderr lines/16 KiB from a
|
||||
live execution.
|
||||
- Agent output is never persisted or included in the attachment report.
|
||||
- Session authorization executes before lifecycle, repository, executor, or
|
||||
agentctl dependencies.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd apps/backend && go test ./internal/agentctl/server/api ./internal/agent/runtime/agentctl ./internal/agent/runtime/lifecycle ./internal/orchestrator ./internal/orchestrator/handlers ./internal/gateway/websocket`
|
||||
|
||||
Begin with RED tests for arbitrary-target rejection, guard-before-dependencies,
|
||||
timeout cleanup, stdio child cleanup, test-result classification, output caps,
|
||||
and no metadata write containing stderr.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/agentctl/server/api/agent.go`
|
||||
- `apps/backend/internal/agentctl/server/api/agent_test.go`
|
||||
- `apps/backend/internal/agent/runtime/agentctl/agent.go`
|
||||
- `apps/backend/internal/agent/runtime/agentctl/agent_test.go`
|
||||
- `apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go`
|
||||
- `apps/backend/internal/agent/runtime/lifecycle/manager_interaction_test.go`
|
||||
- `apps/backend/internal/orchestrator/handlers/handlers.go`
|
||||
- `apps/backend/internal/orchestrator/handlers/handlers_test.go`
|
||||
- `apps/backend/internal/orchestrator/task_operations.go`
|
||||
- `apps/backend/internal/orchestrator/task_operations_test.go`
|
||||
- `apps/backend/pkg/websocket/actions.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Task 03 provides authorized session lookup, current effective report, and
|
||||
persistence of sanitized test results.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. Task 05 consumes the request/response contract.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Existing `agent.stderr` stream request and `GetAgentStderr`
|
||||
- Existing session-keyed authorization invariants
|
||||
- Current MCP server config types
|
||||
- Task 01 sanitizer and endpoint-test result types
|
||||
|
||||
## Output contract
|
||||
|
||||
Report request/response shapes, authorization proof, network/stdio cleanup,
|
||||
stderr bounds, persistence/redaction audit, RED/GREEN results, files changed,
|
||||
blockers, and risks. Mark this task `done` and update its plan checkbox.
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
id: "05-responsive-mcp-status-surface"
|
||||
title: "Build the responsive MCP status surface"
|
||||
status: pending
|
||||
wave: 5
|
||||
depends_on: ["03-persist-attachment-reports", "04-release-diagnostic-operations"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/mcp-session-observability/spec.md"
|
||||
---
|
||||
|
||||
# Task 05: Build the responsive MCP status surface
|
||||
|
||||
## Acceptance
|
||||
|
||||
- The toolbar reads the active Kandev session report and never synthesizes
|
||||
Active status from profile configuration or a fetch fallback.
|
||||
- The plug trigger remains neutral in all states.
|
||||
- Desktop hover and keyboard focus open a compact status popover; click pins
|
||||
it; second click, outside interaction, and Escape close it accessibly.
|
||||
- Touch/coarse-pointer input opens an inset bottom Drawer from a minimum 44px
|
||||
trigger and exposes every desktop status and diagnostic action.
|
||||
- Rows render the exact green/amber/red/gray semantics from the spec and show
|
||||
explicit uncertainty for third-party servers.
|
||||
- Test endpoint, recent output, copy diagnostics, settings, and eligible reset
|
||||
actions behave as specified. Recent output requires explicit activation and
|
||||
is absent from copied diagnostics.
|
||||
- Long server names and summaries wrap without document overflow; the
|
||||
popover/drawer owns bounded internal scrolling.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd apps && pnpm --filter @kandev/web exec vitest run hooks/domains/session/use-session-mcp.test.ts components/task/chat/mcp-status components/task/chat/chat-input-toolbar.test.tsx`
|
||||
- `cd apps/web && pnpm run typecheck`
|
||||
|
||||
Write RED component tests for store truth, neutral icon, focus opening, pinning,
|
||||
status colors/labels, diagnostic-copy exclusion, touch Drawer selection, and
|
||||
44px geometry before implementation. Use keyboard focus in jsdom; reserve real
|
||||
pointer hover and touch for Task 07.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/hooks/domains/session/use-session-mcp.ts`
|
||||
- `apps/web/hooks/domains/session/use-session-mcp.test.ts`
|
||||
- `apps/web/components/task/chat/chat-input-toolbar-primitives.tsx`
|
||||
- `apps/web/components/task/chat/chat-input-toolbar-desktop.tsx`
|
||||
- `apps/web/components/task/chat/chat-input-toolbar-mobile.tsx`
|
||||
- `apps/web/components/task/chat/chat-input-toolbar.test.tsx`
|
||||
- `apps/web/components/task/chat/mcp-status/mcp-status-trigger.tsx`
|
||||
- `apps/web/components/task/chat/mcp-status/mcp-status-content.tsx`
|
||||
- `apps/web/components/task/chat/mcp-status/mcp-status-row.tsx`
|
||||
- `apps/web/components/task/chat/mcp-status/mcp-status-view-model.ts`
|
||||
- `apps/web/components/task/chat/mcp-status/mcp-status-view-model.test.ts`
|
||||
- `apps/web/lib/api/domains/session-api.ts`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Task 03 provides store hydration and live reports.
|
||||
- Task 04 provides test and recent-output operations.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. Task 07 validates this surface in a production build.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Mobile-parity Drawer/Popover contract
|
||||
- `useTouchDrawer`
|
||||
- Existing chat desktop/mobile toolbar composition
|
||||
- Existing reset-context confirmation and MCP settings route
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the shared view model, desktop state machine, mobile Drawer geometry,
|
||||
accessibility behavior, diagnostic privacy behavior, RED/GREEN results, files
|
||||
changed, blockers, and risks. Mark this task `done` and update its plan
|
||||
checkbox.
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
id: "06-acpdbg-sentinel-probe"
|
||||
title: "Extend acpdbg sentinel probing"
|
||||
status: pending
|
||||
wave: 2
|
||||
depends_on: ["01-attachment-evidence-contract"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/mcp-session-observability/spec.md"
|
||||
---
|
||||
|
||||
# Task 06: Extend acpdbg sentinel probing
|
||||
|
||||
## Acceptance
|
||||
|
||||
- The runner's resolved temporary workdir is the child cwd and the `cwd` sent
|
||||
in ACP new/load requests.
|
||||
- `acpdbg mcp-probe` injects a temporary sentinel MCP server into a real
|
||||
`session/new` request.
|
||||
- The summary distinguishes advertised transport capability, delivery,
|
||||
initialize, `tools/list`, and optional sentinel tool use.
|
||||
- Sentinel connection events are timestamped in JSONL metadata with opaque
|
||||
connection IDs.
|
||||
- A bounded no-connection result is reported as unobserved, not as a portable
|
||||
agent failure.
|
||||
- Registry agent commands, `--exec`, environment handling, timeout, stderr,
|
||||
and cleanup keep their existing behavior.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd apps/backend && go test ./internal/agent/acpdbg ./cmd/acpdbg`
|
||||
- `make -C apps/backend build-acpdbg`
|
||||
|
||||
Start with RED fake-agent tests for connect/list/call, ignore, explicit error,
|
||||
timeout, temp cwd propagation, and sentinel cleanup. Do not require installed
|
||||
third-party agents in automated tests.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/agent/acpdbg/runner.go`
|
||||
- `apps/backend/internal/agent/acpdbg/runner_test.go`
|
||||
- `apps/backend/internal/agent/acpdbg/ops.go`
|
||||
- `apps/backend/internal/agent/acpdbg/ops_test.go`
|
||||
- `apps/backend/internal/agent/acpdbg/mcp_sentinel.go`
|
||||
- `apps/backend/internal/agent/acpdbg/mcp_sentinel_test.go`
|
||||
- `apps/backend/cmd/acpdbg/main.go`
|
||||
- `apps/backend/cmd/acpdbg/main_test.go`
|
||||
- `apps/backend/cmd/acpdbg/README.md`
|
||||
- `.agents/skills/acp-debug/SKILL.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Task 01 supplies the evidence terminology and interpretation.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Parallel-safe with Task 02 after Task 01. This task does not edit live agentctl,
|
||||
lifecycle passthrough, orchestrator, or frontend files.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Existing `Probe` and raw JSONL recorder
|
||||
- ACP `session/new` MCP server shape
|
||||
- `mcp-go` streamable HTTP client/server support
|
||||
- The default-workdir bug confirmed in `NewRunner`
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the RED fake-agent cases, exact CLI syntax and summary, cwd fix,
|
||||
sentinel lifecycle/cleanup, sample sanitized interpretation, tests, files
|
||||
changed, blockers, and risks. Mark this task `done` and update its plan
|
||||
checkbox.
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
id: "07-responsive-status-e2e"
|
||||
title: "Prove responsive status flows"
|
||||
status: pending
|
||||
wave: 6
|
||||
depends_on: ["05-responsive-mcp-status-surface"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/mcp-session-observability/spec.md"
|
||||
---
|
||||
|
||||
# Task 07: Prove responsive status flows
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Desktop E2E proves hover, focus, click-to-pin, all display states, two
|
||||
concurrent session reports, tab switching, reload hydration, diagnostic
|
||||
actions, and sanitized copy.
|
||||
- Mobile E2E uses `.tap()`, proves the 44px trigger and inset Drawer, exercises
|
||||
a diagnostic action, and detects horizontal overflow.
|
||||
- E2E setup uses isolated backend fixtures and asserts through the UI.
|
||||
- Backend hook integration tests remain the evidence for real MCP protocol
|
||||
observation; browser tests do not pretend seeded state proves wire behavior.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd apps/web && pnpm e2e:run tests/chat/mcp-status.spec.ts`
|
||||
- `cd apps/web && pnpm e2e:run tests/chat/mobile-mcp-status.spec.ts -- --project=mobile-chrome`
|
||||
|
||||
Follow RED/GREEN/REFACTOR: write each Playwright scenario against the production
|
||||
build, observe the missing/incorrect UI failure, then add only the necessary
|
||||
fixture support. Scope visible Radix portals and use bounding boxes for the
|
||||
44px and overflow contracts.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/e2e/tests/chat/mcp-status.spec.ts`
|
||||
- `apps/web/e2e/tests/chat/mobile-mcp-status.spec.ts`
|
||||
- `apps/web/e2e/helpers/api-client.ts`
|
||||
- `apps/web/e2e/pages/session-page.ts`
|
||||
- `apps/backend/internal/backendapp/e2e_reset.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Task 05 completes the user-facing status surface and diagnostic requests.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential in the primary conversation because it validates the integrated
|
||||
feature and can reveal contract gaps in earlier tasks.
|
||||
|
||||
## Inputs
|
||||
|
||||
- E2E skill production-build runner and mobile `.tap()` guidance
|
||||
- Existing session creation/tab page objects
|
||||
- Existing E2E-only session/message seeding patterns
|
||||
|
||||
## Output contract
|
||||
|
||||
Report each RED failure, fixture changes, desktop and mobile assertions,
|
||||
production-build commands, exact final results, artifact paths for any failure,
|
||||
files changed, blockers, and risks. Mark this task `done` and update its plan
|
||||
checkbox.
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
id: "08-release-troubleshooting-docs"
|
||||
title: "Document release troubleshooting"
|
||||
status: pending
|
||||
wave: 7
|
||||
depends_on: ["05-responsive-mcp-status-surface", "06-acpdbg-sentinel-probe", "07-responsive-status-e2e"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/mcp-session-observability/spec.md"
|
||||
---
|
||||
|
||||
# Task 08: Document release troubleshooting
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Public docs explain configured, filtered, delivered, connected, tools loaded,
|
||||
used, and failed without implying third-party connectivity is always
|
||||
observable.
|
||||
- Release users can find the toolbar popover/drawer, endpoint test, recent
|
||||
output warning, sanitized copy, settings link, and reset-context consequence.
|
||||
- Privacy copy states exactly what persists and what raw data stays
|
||||
development-only or ephemeral.
|
||||
- Agent integration docs show the sentinel `acpdbg` command and require honest
|
||||
passthrough strategy reporting.
|
||||
- Documentation uses the final shipped labels and screenshots only if stable
|
||||
assets are intentionally added.
|
||||
|
||||
## Verification
|
||||
|
||||
- `rtk rg -n "Delivered|connection unverified|mcp-probe|raw ACP" docs/public .agents/skills/acp-debug apps/backend/cmd/acpdbg/README.md`
|
||||
- `node --test scripts/validate-public-docs.test.mjs`
|
||||
- `node scripts/validate-public-docs.mjs`
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `docs/public/agents-and-profiles.md`
|
||||
- `docs/public/automation-and-mcp.md`
|
||||
- `docs/public/add-agent-cli.md`
|
||||
- `docs/public/feature-status.md`
|
||||
- `apps/backend/cmd/acpdbg/README.md`
|
||||
- `.agents/skills/acp-debug/SKILL.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Task 05 fixes user-visible labels and actions.
|
||||
- Task 06 fixes developer CLI syntax and interpretation.
|
||||
- Task 07 verifies the final responsive flow.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential finalization. Writing public instructions before final labels and
|
||||
verified behavior would create drift.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Docs-maintainer boundaries and validation commands
|
||||
- Final component labels and diagnostic behavior
|
||||
- Final `acpdbg mcp-probe` CLI help and README
|
||||
|
||||
## Output contract
|
||||
|
||||
Report public docs changed, final terminology, privacy/uncertainty wording,
|
||||
validation results, files changed, blockers, and risks. Mark this task `done`
|
||||
and update its plan checkbox.
|
||||
@@ -120,6 +120,8 @@ An MCP strategy must:
|
||||
- clean temporary or project files on normal and failed exits;
|
||||
- avoid logging tokens, headers, or generated configuration.
|
||||
|
||||
When investigating a structured agent's MCP behavior, run `acpdbg mcp-probe <agent>`. It injects a temporary HTTP sentinel in `session/new` and reports delivery, initialize, and `tools/list` separately. A sentinel that is not contacted during the bounded probe window is **unobserved**, not proof that the agent cannot use MCP. New passthrough strategies must report an unavailable or explicit materialization failure rather than silently omitting MCP configuration.
|
||||
|
||||
### Add assets and registration
|
||||
|
||||
Optional logos live at:
|
||||
|
||||
@@ -172,6 +172,7 @@ Only custom TUI agents can be deleted from the agent list. Built-in definitions
|
||||
- **Launch fails after editing flags:** inspect the command preview, remove stale arguments, and correct unmatched quotes or trailing escapes.
|
||||
- **Environment value is absent:** confirm the secret still exists, the key is not reserved, and an executor/runtime variable is not already taking precedence.
|
||||
- **MCP server is absent:** confirm agent MCP support, valid JSON, transport mode, executor policy, and the session warning logs.
|
||||
- **MCP tools are missing from one agent session:** open the neutral plug button in that session's chat toolbar. **Delivered** means Kandev included the server in the agent launch but cannot yet observe a connection; **Connected** means the built-in server saw MCP initialize; **Active** means it served `tools/list`. A gray filtered/unavailable row explains an intentional omission, while red is reserved for an explicit sanitized error. Third-party profile servers normally remain Delivered because they connect directly to the agent and Kandev cannot inspect them. The report is scoped to that session and its current execution, so compare the affected agent's own toolbar rather than another agent on the task.
|
||||
- **Automation cannot select a profile:** passthrough agent profiles and Local executor profiles are intentionally omitted from the automation selectors.
|
||||
|
||||
Related: [Executors](executors.md), [Automation and MCP](automation-and-mcp.md), and the contributor guide [Adding a new agent CLI](add-agent-cli.md).
|
||||
|
||||
@@ -237,6 +237,12 @@ An agent profile can add `stdio`, `http`, `sse`, or `streamable_http` servers wh
|
||||
|
||||
Stdio normally starts per session and cannot be shared. Network servers can be shared or per-session. The executor's MCP policy can deny transports/server names, rewrite URLs, or inject environment. See [Agents and profiles](agents-and-profiles.md) for configuration, secret handling, and failure behavior.
|
||||
|
||||
### Diagnose one running session
|
||||
|
||||
Use the neutral plug button beside the chat composer to inspect the current session's MCP attachment report. It distinguishes configuration delivered to the agent from a connection observed by Kandev: the built-in task server becomes **Connected** after MCP initialize and **Active** after it serves `tools/list`. A third-party profile server usually remains **Delivered · connection unverified** because it connects directly to the agent rather than through Kandev. Missing observation is not a failure; red appears only for an explicit sanitized error.
|
||||
|
||||
On desktop, hover or focus the button for the compact status list. On touch devices, tap its 44px target to open the same list in a bottom drawer. The report is per Kandev session and execution, so simultaneous agents in one task never share a status row. It stores only bounded, sanitized attachment facts: no MCP headers, environment values, tool arguments/results, raw ACP frames, or agent output.
|
||||
|
||||
## External MCP
|
||||
|
||||
Open **Settings > External MCP** (`/settings/external-mcp`) for client-specific snippets for Claude Code, Cursor, Codex, Auggie, OpenCode, and GitHub Copilot CLI.
|
||||
@@ -283,6 +289,7 @@ The backend's `/mcp`, `/mcp/sse`, and `/mcp/message` routes currently have no Ka
|
||||
- **Webhook rejected or data is incomplete:** check the exact automation ID, `X-Webhook-Secret` header, and enabled automation/trigger. Bodies over 1 MiB are not rejected; the suffix is silently discarded, so inspect the retained trigger data.
|
||||
- **Missing template data:** inspect run trigger data and the dot path; unresolved placeholders are intentionally removed.
|
||||
- **Task MCP tool missing:** confirm this is a Kandev task session, the agent supports the injection strategy, and the operation belongs to task rather than external mode.
|
||||
- **One agent did not load MCP tools:** inspect that session's toolbar report. A delivered/unverified row is evidence that ACP or passthrough configuration reached the agent, not proof that the agent contacted the server. For deeper developer investigation, run `acpdbg mcp-probe` against the agent and inspect its JSONL.
|
||||
- **External client cannot stream:** verify the base backend URL and configure the reverse proxy for both the selected MCP transport and long-lived requests.
|
||||
|
||||
Related: [Tasks and workflows](tasks-and-workflows.md), [Coordination](coordination.md), [Agents and profiles](agents-and-profiles.md), and [Integrations](integrations.md).
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ Specs for kandev product features, grouped by umbrella. Each spec describes a us
|
||||
|
||||
The bar: an agent given only a spec (no source code) should be able to either reimplement the feature or test the existing system for conformance. See `.agents/skills/spec/SKILL.md` for the workflow and template.
|
||||
|
||||
**Status:** `draft` (being written) · `building` (in active development) · `shipped` (implemented, spec matches code) · `archived` (deprecated).
|
||||
**Status:** `draft` (being written) · `approved` (accepted design, ready to build) · `building` (in active development) · `shipped` (implemented, spec matches code) · `archived` (deprecated).
|
||||
|
||||
**`needs-upgrade`** in a spec's frontmatter flags template sections that the original sources did not cover and should be filled in from code (Data model, API surface, State machine, Permissions, Failure modes, Persistence guarantees). All office specs have been brought to the implementability bar; this flag is only used for newly-drafted specs that need a code-driven fill-in pass.
|
||||
|
||||
@@ -161,6 +161,7 @@ System pages (Radarr/Sonarr-style) for status, disk usage, database maintenance,
|
||||
|---|---|
|
||||
| [agent-resume-runtime-recovery](agent-resume-runtime-recovery/spec.md) | shipped |
|
||||
| [agent-stall-recovery](agent-stall-recovery/spec.md) | draft |
|
||||
| [mcp-session-observability](mcp-session-observability/spec.md) | approved |
|
||||
| [auth](auth/spec.md) | building |
|
||||
| [create-local-repository](create-local-repository/spec.md) | shipped |
|
||||
| [workflow-cycle-guardrails](workflow-cycle-guardrails/spec.md) | building |
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
---
|
||||
status: approved
|
||||
created: 2026-07-30
|
||||
owner: Kandev
|
||||
---
|
||||
|
||||
# Session MCP Attachment Observability
|
||||
|
||||
Decision:
|
||||
[ADR-2026-07-30-session-owned-mcp-observability](../../decisions/2026-07-30-session-owned-mcp-observability.md)
|
||||
|
||||
## Why
|
||||
|
||||
The chat toolbar currently derives its MCP list from agent-profile
|
||||
configuration. That answers what Kandev intended to expose, but not what the
|
||||
specific agent execution received, connected to, or loaded. A user can
|
||||
therefore see `kandev` in the toolbar while the agent has no Kandev tools.
|
||||
|
||||
This is especially difficult to diagnose in release mode. Raw ACP frame
|
||||
logging is intentionally disabled because frames can contain prompts, files,
|
||||
tool arguments, credentials, and other sensitive data. Backend logs alone also
|
||||
cannot prove a failed outbound connection: if the agent never reaches an MCP
|
||||
server, that server observes no request.
|
||||
|
||||
## Evidence model
|
||||
|
||||
Kandev reports the strongest evidence it has for each MCP server without
|
||||
turning absence into failure:
|
||||
|
||||
| Evidence | Meaning |
|
||||
|---|---|
|
||||
| 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. |
|
||||
| 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. |
|
||||
|
||||
ACP does not standardize a response that lists connected MCP servers, and
|
||||
third-party profile MCP servers normally connect directly to the agent. Those
|
||||
servers therefore remain **Delivered · connection unverified** unless the
|
||||
agent exposes a specific error or Kandev later gains an observable proxy or
|
||||
provider status contract.
|
||||
|
||||
`Connected` and `Tools loaded` are observable automatically for Kandev's
|
||||
built-in task server because agentctl hosts that endpoint. They are not inferred
|
||||
from profile configuration, agent capability flags, a successful ACP
|
||||
`session/new`, or a separate endpoint test.
|
||||
|
||||
## Session, execution, and attempt ownership
|
||||
|
||||
- Every attachment report belongs to one Kandev task session and one agent
|
||||
execution generation. Within that execution, every `session/new`,
|
||||
`session/load`, `session/reset`, or passthrough process start creates a new
|
||||
backend-owned attachment attempt ID.
|
||||
- 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.
|
||||
- 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
|
||||
green. Resetting or loading inside one execution creates a new attachment
|
||||
attempt with the same protection.
|
||||
- If an agent internally runs subagents over one shared MCP connection, Kandev
|
||||
reports that shared connection. It does not claim per-subagent attribution
|
||||
unless the agent opens separately observable connections and provides a
|
||||
trustworthy identity contract.
|
||||
|
||||
## Release-safe attachment report
|
||||
|
||||
Release mode always retains a small structured report for the current
|
||||
attachment attempt and the two immediately previous attempts of a session.
|
||||
Each attempt timeline is bounded and can contain these events:
|
||||
|
||||
- server resolved;
|
||||
- server filtered, with a stable reason code;
|
||||
- server delivered;
|
||||
- agent session accepted;
|
||||
- MCP initialize observed;
|
||||
- tools list observed, including tool count;
|
||||
- tool call observed, without tool arguments or result;
|
||||
- explicit attachment error;
|
||||
- connection closed;
|
||||
- attachment attempt superseded.
|
||||
|
||||
The durable report excludes prompts, files, tool arguments and results, header
|
||||
values, environment values, credentials, full sensitive URLs, and raw ACP
|
||||
frames. A network target is reduced to scheme and host with optional port; it
|
||||
contains no user info, path, query, or fragment. A stdio target contains only
|
||||
the executable basename, never arguments or environment. Error details use a
|
||||
stable reason code plus a bounded sanitized summary.
|
||||
|
||||
Raw ACP JSONL logging remains a development-only diagnostic and is not enabled
|
||||
by this feature.
|
||||
|
||||
## User experience
|
||||
|
||||
The MCP toolbar icon remains neutral by default. It does not become a row of
|
||||
colored indicators.
|
||||
|
||||
On precise-pointer desktop:
|
||||
|
||||
- hover or keyboard focus opens a compact MCP status popover;
|
||||
- clicking the trigger pins the popover so diagnostic actions can be used;
|
||||
- each server row shows its own status color and plain-language label.
|
||||
|
||||
On touch and coarse-pointer devices:
|
||||
|
||||
- tapping a minimum 44px toolbar target opens an inset, safe-area-aware bottom
|
||||
drawer;
|
||||
- the drawer uses the same server rows, evidence checklist, and actions as the
|
||||
desktop popover;
|
||||
- no capability is hidden behind hover.
|
||||
|
||||
The list uses these display states:
|
||||
|
||||
- **Active** in green when `tools/list` succeeded for the current execution;
|
||||
- **Connected** in amber when initialize succeeded but tools have not been
|
||||
listed;
|
||||
- **Delivered · connection unverified** in amber when configuration reached
|
||||
the agent but Kandev has no connection observation;
|
||||
- **Failed** in red only for an explicit server-specific attachment error;
|
||||
- **Filtered** or **Unavailable** in gray with the reason.
|
||||
|
||||
An unscoped ACP session error appears as a report-level diagnostic and does not
|
||||
incorrectly mark every delivered server as failed. Tool use is shown as detail
|
||||
under an Active server rather than as another toolbar color.
|
||||
|
||||
The popover or drawer provides:
|
||||
|
||||
- a per-server attachment checklist with timestamps for the current execution;
|
||||
- **Test endpoint**, which performs a bounded initialize and `tools/list` from
|
||||
the same executor using the stored effective server configuration;
|
||||
- **View recent agent output**, fetched on demand and limited to a bounded
|
||||
excerpt with a warning that agent output may contain sensitive data;
|
||||
- **Copy sanitized diagnostics**, which never includes the on-demand agent
|
||||
output;
|
||||
- **Open MCP settings**;
|
||||
- the existing reset/restart recovery only where supported, with its existing
|
||||
confirmation and clear context-loss consequences.
|
||||
|
||||
An endpoint test reports reachability separately from attachment evidence. A
|
||||
successful test does not turn an unverified agent attachment into Active, and a
|
||||
failed test is labelled as a test failure rather than proof of what happened
|
||||
earlier inside the agent.
|
||||
|
||||
## Developer probe
|
||||
|
||||
`acpdbg` can inject a built-in sentinel MCP server into a real ACP
|
||||
`session/new` request and report whether the agent initialized it, listed its
|
||||
tools, and optionally called its sentinel tool. The probe uses the same
|
||||
registered agent command, environment handling, and effective working
|
||||
directory as the ordinary ACP probe. Its JSONL retains raw frames only because
|
||||
the developer explicitly invoked the debug tool.
|
||||
|
||||
The probe also reports the agent's advertised MCP transport capabilities, but
|
||||
capabilities are not treated as connection evidence.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- If `session/new`, `session/load`, or passthrough materialization fails before
|
||||
delivery, Kandev records the explicit sanitized failure and keeps the server
|
||||
out of Active state.
|
||||
- If the agent accepts the session but never reaches Kandev's MCP endpoint, the
|
||||
report remains Delivered or connection unverified. It does not invent a
|
||||
timeout failure.
|
||||
- If an MCP connection closes, the current connection is marked disconnected;
|
||||
a later connection receives a new connection ID and can restore current
|
||||
evidence.
|
||||
- If persistence fails, live status may still update, the failure is logged,
|
||||
and the agent session continues unchanged.
|
||||
- If the user tests a server that is no longer part of the session's effective
|
||||
configuration, Kandev rejects the request rather than testing an arbitrary
|
||||
URL or command.
|
||||
- If recent agent output is unavailable because the execution ended or the
|
||||
stream disconnected, the UI explains that limitation without changing MCP
|
||||
attachment status.
|
||||
|
||||
## Scenarios
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **GIVEN** a precise-pointer user, **WHEN** they hover or focus the neutral MCP
|
||||
trigger, **THEN** a compact status list appears and can be pinned by click.
|
||||
- **GIVEN** a phone or coarse-pointer user, **WHEN** they tap the MCP trigger,
|
||||
**THEN** the same status and diagnostic actions appear in a bottom drawer
|
||||
without horizontal overflow.
|
||||
- **GIVEN** Auggie or another ACP agent is under investigation, **WHEN** a
|
||||
developer runs the sentinel MCP probe, **THEN** the JSONL and summary
|
||||
distinguish advertised capability, configuration delivery, initialize,
|
||||
tools list, and tool use.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- A new ACP extension that requires every agent vendor to return connected MCP
|
||||
server status.
|
||||
- Claiming automatic connection status for direct third-party MCP servers that
|
||||
Kandev cannot observe.
|
||||
- Enabling raw ACP frame logs or persistent raw stderr in release mode.
|
||||
- Persisting prompts, tool arguments, credentials, header values, environment
|
||||
values, or full endpoint URLs in attachment diagnostics.
|
||||
- Attributing a shared MCP connection to opaque internal subagents.
|
||||
- Automatically restarting an agent, resetting context, or changing session
|
||||
state because attachment evidence is absent.
|
||||
Reference in New Issue
Block a user