feat(mcp): remote /mcp writes are agent writes named by clientInfo; refused claims say lease_held (BUG-2772, BUG-3496) (#1903)

* feat(mcp): remote /mcp writes are agent writes, named by the client's declared clientInfo (BUG-2772)

A write over remote /mcp reached the handlers with no X-Pad-Agent and
was recorded as the human. It is now an agent write, named by the
client's own MCP clientInfo.name: a modern request's _meta clientInfo,
else the clientInfo its session sent at initialize (remembered by the
Mcp-Session-Id pad issued; mcp-go's session state does not survive
pad's stateless transport), else unnamed; never the human, never a
guessed name. Each initialize logs the declared client (name, version,
protocol, transport; no tokens).

A remote call's default lease holder is that name plus a short session
id (claude-code#3f9a12), inside defaultLeaseHolder so claim and
next/ready agree; two connections of one client no longer share a
lease. Folds TASK-3495. ToolSurfaceVersion 0.77.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(mcp): a refused claim reaches both transports as lease_held with its holder (BUG-3496)

* test(mcp): register WriteLeaseHeldError in the stdio marker vocabulary table (BUG-3496)

* fix(mcp): a remembered client name is bound to the account that declared it (BUG-2772 codex r1)

* fix(mcp): the remote lease holder carries eight hex digits of the session id (BUG-2772 lead ruling)

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
xarmian
2026-10-08 15:15:26 -04:00
committed by GitHub
parent ad6cfc2c5e
commit 58be45b35e
20 changed files with 840 additions and 19 deletions
+2 -2
View File
@@ -399,7 +399,7 @@ directory for `claude-code`, and an `[mcp_servers.pad]` table in
project-scoped, it's install-on-request only — `--all` and `pad mcp status` cover
the per-user clients (including Codex) and skip it.
**Tool catalog (v0.76)** — ten resource × action tools plus `pad_set_workspace`. Undeclared input keys are rejected with a structured error rather than silently dropped, and field values are typed against the collection schema on the server:
**Tool catalog (v0.77)** — ten resource × action tools plus `pad_set_workspace`. Undeclared input keys are rejected with a structured error rather than silently dropped, and field values are typed against the collection schema on the server:
| Tool | Actions |
|---|---|
@@ -423,7 +423,7 @@ Plus resources at `pad://workspaces`, `pad://workspace/{ws}/dashboard`,
and `pad://_meta/version`.
**Stability contract.** The catalog is versioned
(`tool_surface_version: "0.76"`) and advertised in the initialize handshake and at
(`tool_surface_version: "0.77"`) and advertised in the initialize handshake and at
`pad://_meta/version`, so external agents can pin against it; the per-version
changelog lives in [`internal/mcp/version.go`](internal/mcp/version.go). Errors
come back as structured envelopes (`{error: {code, message, hint, ...}}`) from a
+44
View File
@@ -250,3 +250,47 @@ func TestStdioCommentDeletedClassifies(t *testing.T) {
t.Fatalf("hint = %q, want the shared CommentDeletedHint", env.Error.Hint)
}
}
// BUG-3496, end to end on stdio: a claim on an item another holder leases
// reaches the caller as lease_held with the holder and expiry, instead of
// server_error. The fake answers with the bytes writeLeaseHeldError writes.
func TestStdioLeaseHeldClassifies(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte(`{"error":{"code":"lease_held","message":"item is leased to claude-code#3f9a12","details":{"ref":"TASK-1","holder":"claude-code#3f9a12","acquired_at":"2026-10-08T10:00:00Z","expires_at":"2026-10-08T10:30:00Z"}}}`))
}))
defer srv.Close()
t.Setenv(padHelperEnv, "1")
t.Setenv("HOME", t.TempDir())
bin, err := os.Executable()
if err != nil {
t.Fatalf("os.Executable: %v", err)
}
d := &mcp.ExecDispatcher{Binary: bin}
res, err := d.Dispatch(context.Background(), []string{"item", "claim"},
[]string{"--url", srv.URL, "--workspace", "ws", "--format", "json", "--", "TASK-1"})
if err != nil {
t.Fatalf("Dispatch: %v", err)
}
env, ok := res.StructuredContent.(mcp.ErrorEnvelope)
if !ok {
t.Fatalf("PRECONDITION: the call should have failed; got %T", res.StructuredContent)
}
if env.Error.Code != mcp.ErrLeaseHeld {
t.Fatalf("code = %q (message %q, hint %q), want %q",
env.Error.Code, env.Error.Message, env.Error.Hint, mcp.ErrLeaseHeld)
}
if env.Error.Hint != mcp.LeaseHeldHint {
t.Fatalf("hint = %q, want the shared LeaseHeldHint", env.Error.Hint)
}
var details struct {
Holder string `json:"holder"`
ExpiresAt string `json:"expires_at"`
}
if err := json.Unmarshal(env.Error.Details, &details); err != nil ||
details.Holder != "claude-code#3f9a12" || details.ExpiresAt != "2026-10-08T10:30:00Z" {
t.Fatalf("details = %s, want the holder and expires_at (err %v)", env.Error.Details, err)
}
}
+6
View File
@@ -18,10 +18,16 @@ import (
// and experimental capabilities. Not mounted yet; TASK-3321 U2 serves it at
// its own URL behind a setting.
func newChatGPTMCPServer(doc *cmdhelp.Document, dispatcher mcpserver.Dispatcher) (*mcpserver.Server, error) {
// The same declared-client registry the dispatcher reads (BUG-2772).
var clients *mcpserver.ClientRegistry
if hd, ok := dispatcher.(*mcpserver.HTTPHandlerDispatcher); ok {
clients = hd.Clients
}
srv := mcpserver.NewServer(mcpserver.Options{
Version: fullVersion(),
Instructions: mcpserver.ChatGPTInstructions,
Experimental: mcpserver.ChatGPTExperimentalCapabilities(),
Clients: clients,
})
if _, err := mcpserver.RegisterChatGPTCatalog(srv.MCP(), mcpserver.ChatGPTCatalogOptions{
Catalog: mcpserver.CatalogOptions{
+5 -1
View File
@@ -76,7 +76,10 @@ func wireMCP(cmd *cobra.Command, srv *server.Server, s *store.Store, ep config.M
Homepage: padHomepage,
MaxDepth: -1,
})
mcpSrv := mcpserver.NewServer(mcpserver.Options{Version: fullVersion()})
// One registry of declared MCP clients (BUG-2772): both remote servers
// record into it at initialize, and the dispatcher reads it per call.
mcpClients := mcpserver.NewClientRegistry()
mcpSrv := mcpserver.NewServer(mcpserver.Options{Version: fullVersion(), Clients: mcpClients})
// CurrentUserFromContext returns (*User, bool); the dispatcher's
// UserResolver signature is just (ctx) *User. The bool is "found",
// which equals "non-nil pointer" for the path the MCP middleware
@@ -84,6 +87,7 @@ func wireMCP(cmd *cobra.Command, srv *server.Server, s *store.Store, ep config.M
// flatten with a closure.
dispatcher := &mcpserver.HTTPHandlerDispatcher{
Handler: srv,
Clients: mcpClients,
UserResolver: func(ctx context.Context) *models.User {
u, _ := server.CurrentUserFromContext(ctx)
return u
+5
View File
@@ -85,6 +85,11 @@ func writeRootStructuredError(w io.Writer, err error) {
// BUG-3252: an edit, reply or reaction addressed to a tombstone.
if apiErr, ok := cli.IsCommentDeleted(err); ok {
cli.WriteCommentDeletedError(w, apiErr)
return
}
// BUG-3496: a claim on an item another holder leases.
if apiErr, ok := cli.IsLeaseHeld(err); ok {
cli.WriteLeaseHeldError(w, apiErr)
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14 -3
View File
File diff suppressed because one or more lines are too long
+35
View File
@@ -1880,6 +1880,41 @@ func IsCommentHasReplies(err error) (*APIError, bool) {
return nil, false
}
// LeaseHeldCode is the structured code for a claim on an item another holder
// leases (BUG-3496). Keep it, and the hint, in lockstep with internal/mcp's
// allowedStructuredErrorCodes and LeaseHeldHint.
const LeaseHeldCode = "lease_held"
// LeaseHeldHint is the recovery guidance for LeaseHeldCode.
const LeaseHeldHint = "Another holder (details.holder) has claimed this item until details.expires_at. " +
"Pick other work, or wait until the lease expires; claiming again before then is refused the same way."
// IsLeaseHeld reports whether err is the lease refusal.
func IsLeaseHeld(err error) (*APIError, bool) {
var apiErr *APIError
if errors.As(err, &apiErr) && apiErr.Code == LeaseHeldCode {
return apiErr, true
}
return nil, false
}
// WriteLeaseHeldError writes the structured marker line for the lease
// refusal, so the stdio MCP transport reports lease_held with its holder and
// expiry instead of inferring a code from the prose.
func WriteLeaseHeldError(w io.Writer, apiErr *APIError) {
body := map[string]any{
"code": LeaseHeldCode,
"message": apiErr.Message,
"hint": LeaseHeldHint,
}
if len(apiErr.Details) > 0 {
body["details"] = apiErr.Details
}
if data, err := json.Marshal(map[string]any{"error": body}); err == nil {
fmt.Fprintln(w, StructuredErrorMarker+string(data))
}
}
// CommentDeletedCode is the structured code for a write addressed to a
// comment tombstone (BUG-3252): an edit, a reply or a reaction. Keep it, and
// the hint, in lockstep with internal/mcp's allowedStructuredErrorCodes and
@@ -0,0 +1,381 @@
package mcp
// BUG-2772: a write over remote /mcp is an AGENT write, named by the client's
// declared clientInfo (IDEA-2791 Tier B): the per-request _meta clientInfo of
// a modern request, else the clientInfo a legacy session sent at initialize
// (remembered by the Mcp-Session-Id pad issued), else unnamed, and never the
// human. Driven end to end: the real Streamable HTTP transport, a tool whose
// handler calls the dispatcher as every catalog action does (its own ctx,
// with WithDispatchInput), the real server and store.
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/google/uuid"
"github.com/PerpetualSoftware/pad/internal/cli"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/PerpetualSoftware/pad/internal/models"
padserver "github.com/PerpetualSoftware/pad/internal/server"
"github.com/PerpetualSoftware/pad/internal/store"
"github.com/PerpetualSoftware/pad/internal/store/storetest"
)
type bug2772Sessions struct{}
func (bug2772Sessions) Generate() string { return "pad-mcp-" + uuid.NewString() }
func (bug2772Sessions) Validate(string) (bool, error) { return false, nil }
func (bug2772Sessions) Terminate(string) (bool, error) { return false, nil }
type bug2772World struct {
t *testing.T
s *store.Store
srv *padserver.Server
ws *models.Workspace
url string
owner *models.User
}
func newBUG2772World(t *testing.T) *bug2772World {
t.Helper()
s := storetest.NewSQLite(t)
srv := padserver.New(s)
t.Cleanup(srv.Stop)
owner, err := s.CreateUser(models.UserCreate{Email: "mcp-2772@example.com", Name: "Owner", Password: "correct-horse-battery-staple"})
if err != nil {
t.Fatal(err)
}
ws, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "MCP 2772", Slug: "mcp-2772", OwnerID: owner.ID})
if err != nil {
t.Fatal(err)
}
if err := s.AddWorkspaceMember(ws.ID, owner.ID, "owner"); err != nil {
t.Fatal(err)
}
if err := s.SeedCollectionsFromTemplate(ws.ID, "startup"); err != nil {
t.Fatal(err)
}
other, err := s.CreateUser(models.UserCreate{Email: "mcp-2772-other@example.com", Name: "Other", Password: "correct-horse-battery-staple"})
if err != nil {
t.Fatal(err)
}
if err := s.AddWorkspaceMember(ws.ID, other.ID, "editor"); err != nil {
t.Fatal(err)
}
clients := NewClientRegistry()
d := &HTTPHandlerDispatcher{Handler: srv, Clients: clients, UserResolver: func(ctx context.Context) *models.User {
u, _ := padserver.CurrentUserFromContext(ctx)
return u
}}
mcpSrv := NewServer(Options{Version: "test", Clients: clients})
mcpSrv.MCP().AddTool(mcp.NewTool("create_task", mcp.WithString("title")), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
input := map[string]any{"workspace": ws.Slug, "collection": "tasks", "title": req.GetString("title", "")}
res, err := d.Dispatch(WithDispatchInput(ctx, input), []string{"item", "create"}, nil)
if err != nil {
return nil, err
}
return res, nil
})
mcpSrv.MCP().AddTool(mcp.NewTool("claim_task", mcp.WithString("ref")), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
input := map[string]any{"workspace": ws.Slug, "ref": req.GetString("ref", "")}
return d.Dispatch(WithDispatchInput(ctx, input), []string{"item", "claim"}, nil)
})
mcpSrv.MCP().AddTool(mcp.NewTool("next"), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return d.Dispatch(WithDispatchInput(ctx, map[string]any{"workspace": ws.Slug}), []string{"project", "next"}, nil)
})
// Stands in for the /mcp auth middleware: the request's user goes on its
// context, the owner unless the test names the other member.
transport := NewRemoteTransport(mcpSrv.MCP(), bug2772Sessions{})
ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
u := owner
if r.Header.Get(bug2772UserHeader) == "other" {
u = other
}
transport.ServeHTTP(rw, r.WithContext(padserver.WithCurrentUser(r.Context(), u)))
}))
t.Cleanup(ts.Close)
return &bug2772World{t: t, s: s, srv: srv, ws: ws, url: ts.URL + "/mcp", owner: owner}
}
// bug2772UserHeader picks the request's user in the test world.
const bug2772UserHeader = "X-Test-User"
func (w *bug2772World) post(body, session string, headers map[string]string) (string, string) {
w.t.Helper()
req, _ := http.NewRequest(http.MethodPost, w.url, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
if session != "" {
req.Header.Set("Mcp-Session-Id", session)
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
w.t.Fatal(err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode/100 != 2 {
w.t.Fatalf("POST %s: %d %s", body, resp.StatusCode, raw)
}
return string(raw), resp.Header.Get("Mcp-Session-Id")
}
// initialize runs a legacy handshake with the given clientInfo name and
// returns the session id pad issued.
func (w *bug2772World) initialize(clientName string) string {
w.t.Helper()
body := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":` + mustJSONString(clientName) + `,"version":"9.9"}}}`
_, session := w.post(body, "", nil)
if session == "" {
w.t.Fatal("precondition: initialize issued no Mcp-Session-Id")
}
w.post(`{"jsonrpc":"2.0","method":"notifications/initialized"}`, session, nil)
return session
}
// create calls the tool and returns the stored item's activity.
func (w *bug2772World) create(title, session string) (createdBy, agent string) {
w.t.Helper()
raw, _ := w.post(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"create_task","arguments":{"title":`+mustJSONString(title)+`}}}`, session, map[string]string{"MCP-Protocol-Version": "2025-11-25"})
if strings.Contains(raw, `"isError":true`) {
w.t.Fatalf("create %q failed: %s", title, raw)
}
return w.stored(title)
}
func (w *bug2772World) stored(title string) (createdBy, agent string) {
w.t.Helper()
items, err := w.s.ListItems(w.ws.ID, models.ItemListParams{CollectionSlug: "tasks"})
if err != nil {
w.t.Fatal(err)
}
for _, it := range items {
if it.Title != title {
continue
}
acts, err := w.s.ListDocumentActivity(it.ID, models.ActivityListParams{})
if err != nil {
w.t.Fatal(err)
}
for _, a := range acts {
if a.Action == "created" {
return it.CreatedBy, models.AgentNameFromMetadata(a.Metadata)
}
}
w.t.Fatalf("no created activity for %q", title)
}
w.t.Fatalf("no item titled %q", title)
return "", ""
}
// call runs a tool and returns its raw JSON-RPC response.
func (w *bug2772World) call(tool, args, session string) string {
w.t.Helper()
raw, _ := w.post(`{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"`+tool+`","arguments":`+args+`}}`, session, map[string]string{"MCP-Protocol-Version": "2025-11-25"})
return raw
}
func mustJSONString(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
func TestBUG2772_RemoteMCPWritesAreAgentWrites(t *testing.T) {
w := newBUG2772World(t)
t.Run("a legacy session's declared client names the write", func(t *testing.T) {
session := w.initialize("claude-code")
by, agent := w.create("from claude code", session)
if by != "agent" || agent != "claude-code" {
t.Fatalf("created_by=%q agent=%q, want agent / claude-code", by, agent)
}
})
t.Run("two sessions keep their own names", func(t *testing.T) {
a := w.initialize("cursor")
b := w.initialize("chatgpt")
if _, agent := w.create("from cursor", a); agent != "cursor" {
t.Errorf("session a wrote as %q", agent)
}
if _, agent := w.create("from chatgpt", b); agent != "chatgpt" {
t.Errorf("session b wrote as %q", agent)
}
})
t.Run("a client that declared no name: still an agent write, unnamed", func(t *testing.T) {
session := w.initialize("")
by, agent := w.create("from a nameless client", session)
if by != "agent" || agent != "" {
t.Fatalf("created_by=%q agent=%q, want agent / (none)", by, agent)
}
})
t.Run("a session this instance never saw: an agent write, unnamed, never a guessed name", func(t *testing.T) {
by, agent := w.create("from an unknown session", "pad-mcp-"+uuid.NewString())
if by != "agent" || agent != "" {
t.Fatalf("created_by=%q agent=%q, want agent / (none)", by, agent)
}
})
t.Run("another user's session id carries no name", func(t *testing.T) {
// The transport accepts any Mcp-Session-Id, so a caller who learned
// another account's session id can send it. The name that session
// declared stays with the account that declared it.
session := w.initialize("claude-code")
title := "borrowed session id"
raw, _ := w.post(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"create_task","arguments":{"title":`+mustJSONString(title)+`}}}`,
session, map[string]string{"MCP-Protocol-Version": "2025-11-25", bug2772UserHeader: "other"})
if strings.Contains(raw, `"isError":true`) {
t.Fatalf("create failed: %s", raw)
}
if by, agent := w.stored(title); by != "agent" || agent != "" {
t.Fatalf("created_by=%q agent=%q, want agent / (none): the other user wrote under the owner's session name", by, agent)
}
// The owner, on the same session, still gets its name.
if _, agent := w.create("owner on its own session", session); agent != "claude-code" {
t.Fatalf("the owner's own session lost its name: %q", agent)
}
})
t.Run("CONTROL: the dispatcher's own request, minus the remote-caller mark, is the human's", func(t *testing.T) {
// buildHTTPRequest is what the dispatcher sends, without the
// WithRemoteMCPCaller mark: so the mark, and nothing else about the
// in-process request, is what makes the writes above agent writes.
req, err := buildHTTPRequest(context.Background(), http.MethodPost,
"/api/v1/workspaces/"+w.ws.Slug+"/collections/tasks/items", []byte(`{"title":"typed by the human"}`), w.owner)
if err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
w.srv.ServeHTTP(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("create: %d %s", rr.Code, rr.Body.String())
}
if by, agent := w.stored("typed by the human"); by != "user" || agent != "" {
t.Fatalf("created_by=%q agent=%q, want user / (none)", by, agent)
}
})
}
func TestBUG2772_RemoteLeaseHolderIsPerConnection(t *testing.T) {
// Lead ruling: a remote caller's default lease holder is its client name
// plus a short session id, so two connections of one client do not
// silently share a claim, and next/ready agree with the claim default
// (both go through defaultLeaseHolder).
w := newBUG2772World(t)
tasks, err := w.s.GetCollectionBySlug(w.ws.ID, "tasks")
if err != nil || tasks == nil {
t.Fatalf("tasks: %v", err)
}
it, err := w.s.CreateItem(w.ws.ID, tasks.ID, models.ItemCreate{Title: "Claim me", Fields: `{"status":"open","priority":"critical"}`})
if err != nil {
t.Fatal(err)
}
a := w.initialize("claude-code")
b := w.initialize("claude-code")
if raw := w.call("claim_task", `{"ref":"`+it.Ref+`"}`, a); strings.Contains(raw, `"isError":true`) {
t.Fatalf("session a's claim failed: %s", raw)
}
lease, err := w.s.GetItemLease(it.ID)
if err != nil || lease == nil {
t.Fatalf("no lease stored: %v", err)
}
if want := "claude-code#" + shortSessionID(a); lease.Holder != want {
t.Fatalf("holder = %q, want %q", lease.Holder, want)
}
// The same client on another connection is a different holder, refused
// as lease_held with the holder and expiry (BUG-3496: the remote
// classifier used to drop that code and answer `conflict`).
raw := w.call("claim_task", `{"ref":"`+it.Ref+`"}`, b)
var resp struct {
Result struct {
IsError bool `json:"isError"`
StructuredContent struct {
Error struct {
Code string `json:"code"`
Hint string `json:"hint"`
Details struct {
Holder string `json:"holder"`
ExpiresAt string `json:"expires_at"`
} `json:"details"`
} `json:"error"`
} `json:"structuredContent"`
} `json:"result"`
}
if err := json.Unmarshal([]byte(raw), &resp); err != nil || !resp.Result.IsError {
t.Fatalf("session b claimed an item session a holds (err %v): %s", err, raw)
}
e := resp.Result.StructuredContent.Error
if e.Code != string(ErrLeaseHeld) || e.Hint != LeaseHeldHint {
t.Fatalf("code = %q hint = %q, want lease_held with LeaseHeldHint: %s", e.Code, e.Hint, raw)
}
if e.Details.Holder != lease.Holder || e.Details.ExpiresAt == "" {
t.Fatalf("details holder = %q expires_at = %q, want %q and an expiry: %s",
e.Details.Holder, e.Details.ExpiresAt, lease.Holder, raw)
}
// next/ready: the holder still sees its own claim; the other does not.
if raw := w.call("next", `{}`, a); !strings.Contains(raw, it.Ref) {
t.Errorf("session a's own claim was hidden from its next: %s", raw)
}
if raw := w.call("next", `{}`, b); strings.Contains(raw, it.Ref) {
t.Errorf("session b was offered an item session a holds: %s", raw)
}
}
func TestBUG2772_ResolveRemoteCaller(t *testing.T) {
r := NewClientRegistry()
t.Run("a modern request's own _meta clientInfo wins", func(t *testing.T) {
ctx := server.WithRequestProtocolInfo(context.Background(), &server.RequestProtocolInfo{
Modern: true, ClientInfo: &mcp.Implementation{Name: "modern-client"},
})
c := r.resolveRemoteCaller(ctx)
if c.Name != "modern-client" || c.LeaseHolder != "modern-client" {
t.Fatalf("got %+v", c)
}
})
t.Run("nothing declared and no session: unnamed, with a fallback lease holder", func(t *testing.T) {
c := r.resolveRemoteCaller(context.Background())
if c.Name != "" || c.LeaseHolder != remoteAgentFallback {
t.Fatalf("got %+v", c)
}
})
t.Run("short session id", func(t *testing.T) {
if got := shortSessionID("pad-mcp-3f9a12c4-0000"); got != "3f9a12c4" {
t.Fatalf("got %q", got)
}
})
t.Run("a nil registry still resolves (unnamed)", func(t *testing.T) {
var nilReg *ClientRegistry
if c := nilReg.resolveRemoteCaller(context.Background()); c.Name != "" {
t.Fatalf("got %+v", c)
}
})
}
func TestCLIAndMCPAgreeOnLeaseHeld(t *testing.T) {
if cli.LeaseHeldCode != string(ErrLeaseHeld) {
t.Errorf("code strings differ: cli %q, mcp %q", cli.LeaseHeldCode, ErrLeaseHeld)
}
if cli.LeaseHeldHint != LeaseHeldHint {
t.Errorf("hints differ:\n cli %q\n mcp %q", cli.LeaseHeldHint, LeaseHeldHint)
}
}
+234
View File
@@ -0,0 +1,234 @@
package mcp
import (
"context"
"log/slog"
"strings"
"sync"
"time"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
padserver "github.com/PerpetualSoftware/pad/internal/server"
)
// Remote MCP caller identity (BUG-2772, IDEA-2791 Tier B).
//
// A remote /mcp write used to reach the handlers with no X-Pad-Agent, so it
// was recorded as the human. The client's own declaration, MCP clientInfo, is
// now the agent name:
//
// 1. a MODERN request carries clientInfo in its own _meta (stateless; nothing
// to remember, and it works across instances);
// 2. a LEGACY client sends clientInfo once, at initialize, and echoes the
// Mcp-Session-Id pad issued on every later request (measured: Claude Code
// 2.1.294), so ClientRegistry remembers it by that id. mcp-go's own session
// state does not survive pad's stateless transport: a later POST gets an
// ephemeral session that never saw initialize;
// 3. otherwise no name, and the write is STILL an agent write (an app acting
// for the user), never the human and never a guessed name.
//
// Self-declared, recorded verbatim, like the CLI's X-Pad-Agent.
const (
// clientRegistryTTL matches the active-sessions tracker's idle window
// (TASK-1120): a session unused this long is forgotten.
clientRegistryTTL = 30 * time.Minute
// clientRegistryMax bounds memory; past it the stalest entry goes.
clientRegistryMax = 10000
// remoteAgentFallback names a remote caller in its lease holder when it
// declared nothing. Never stored as the agent name.
remoteAgentFallback = "mcp-client"
)
// ClientRegistry remembers each legacy session's clientInfo by the
// Mcp-Session-Id pad issued at initialize AND the user who initialized it.
// Per instance and in memory: a request that lands on another instance, or
// after a restart, falls back to the nameless agent write.
//
// The user is part of the key because the transport accepts any incoming
// Mcp-Session-Id: keyed on the id alone, a caller who learned another
// session's id could send it and have its own writes carry that session's
// declared name. Keyed on (user, id), the only name a caller can reach is
// one its own account declared.
type ClientRegistry struct {
mu sync.Mutex
entries map[string]*clientEntry
now func() time.Time
// owner returns the authenticated user's id from a request's context,
// "" when there is none (then nothing is recorded or looked up).
owner func(context.Context) string
}
type clientEntry struct {
name string
seen time.Time
}
// NewClientRegistry returns an empty registry.
func NewClientRegistry() *ClientRegistry {
return &ClientRegistry{entries: map[string]*clientEntry{}, now: time.Now, owner: currentUserID}
}
// currentUserID is the id of the user the /mcp auth middleware put on the
// request's context.
func currentUserID(ctx context.Context) string {
if u, ok := padserver.CurrentUserFromContext(ctx); ok && u != nil {
return u.ID
}
return ""
}
// registryKey binds a session id to the user who presented it; "" when
// either is missing, which records and finds nothing.
func registryKey(userID, sessionID string) string {
if userID == "" || sessionID == "" {
return ""
}
return userID + "\x00" + sessionID
}
// record stores a session's client name, evicting expired and, past the
// bound, the stalest entries.
func (r *ClientRegistry) record(key, name string) {
if r == nil || key == "" || name == "" {
return
}
r.mu.Lock()
defer r.mu.Unlock()
now := r.now()
if len(r.entries) >= clientRegistryMax {
var oldestID string
var oldest time.Time
for id, e := range r.entries {
if now.Sub(e.seen) > clientRegistryTTL {
delete(r.entries, id)
continue
}
if oldestID == "" || e.seen.Before(oldest) {
oldestID, oldest = id, e.seen
}
}
if len(r.entries) >= clientRegistryMax && oldestID != "" {
delete(r.entries, oldestID)
}
}
r.entries[key] = &clientEntry{name: name, seen: now}
}
// lookup returns a live session's client name and refreshes it.
func (r *ClientRegistry) lookup(key string) string {
if r == nil || key == "" {
return ""
}
r.mu.Lock()
defer r.mu.Unlock()
e, ok := r.entries[key]
if !ok {
return ""
}
now := r.now()
if now.Sub(e.seen) > clientRegistryTTL {
delete(r.entries, key)
return ""
}
e.seen = now
return e.name
}
// hooks returns the mcp-go hooks that feed the registry from initialize, and
// log the declared client once per handshake: the data that tells which
// clients send what (BUG-2772). Name, version and protocol only; never a
// token or a header.
func (r *ClientRegistry) hooks() *server.Hooks {
h := &server.Hooks{}
h.AddAfterInitialize(func(ctx context.Context, _ any, msg *mcp.InitializeRequest, _ *mcp.InitializeResult) {
if msg == nil {
return
}
info := msg.Params.ClientInfo
sessionID := ""
if s := server.ClientSessionFromContext(ctx); s != nil {
sessionID = s.SessionID()
}
slog.Info("mcp client initialized",
"client_name", cleanClientName(info.Name),
"client_version", cleanClientName(info.Version),
"protocol_version", msg.Params.ProtocolVersion,
"transport", "streamable-http",
"session_tracked", sessionID != "")
r.record(registryKey(r.owner(ctx), sessionID), cleanClientName(info.Name))
})
return h
}
// cleanClientName trims a client-declared string and drops control
// characters, so a log line or a stored name cannot be forged by it. The
// server's agentNameFromRequest bounds the stored length.
func cleanClientName(s string) string {
var b strings.Builder
for _, c := range strings.TrimSpace(s) {
if c < 0x20 || c == 0x7f {
continue
}
b.WriteRune(c)
}
return b.String()
}
// RemoteCaller is the identity a remote /mcp request writes under.
type RemoteCaller struct {
// Name is the declared client name ("claude-code"); empty when the
// client declared nothing, which still records an agent write.
Name string
// LeaseHolder is the default lease holder: Name (or remoteAgentFallback)
// plus a short id from the session, so two connections of one client do
// not silently share a lease (lead ruling, BUG-2772).
LeaseHolder string
}
// resolveRemoteCaller applies the precedence: a modern request's own _meta
// clientInfo, then the legacy session's remembered clientInfo, then nothing.
func (r *ClientRegistry) resolveRemoteCaller(ctx context.Context) RemoteCaller {
name := ""
if info := server.RequestProtocolInfoFromContext(ctx); info != nil && info.ClientInfo != nil {
name = cleanClientName(info.ClientInfo.Name)
}
sessionID := ""
if s := server.ClientSessionFromContext(ctx); s != nil {
sessionID = s.SessionID()
}
if name == "" && r != nil {
name = r.lookup(registryKey(r.owner(ctx), sessionID))
}
base := name
if base == "" {
base = remoteAgentFallback
}
holder := base
if short := shortSessionID(sessionID); short != "" {
holder = base + "#" + short
}
return RemoteCaller{Name: name, LeaseHolder: holder}
}
// shortSessionID is the first eight hex characters of the session's UUID
// ("pad-mcp-3f9a12c4-…" gives "3f9a12c4"): enough to tell one user's
// concurrent connections apart, short enough to read in a lease. A lease is
// ours only when its label AND its user match (store.ClaimItemLease), so a
// collision needs two live sessions of one ACCOUNT with the same client name
// and the same eight digits.
func shortSessionID(id string) string {
id = strings.TrimPrefix(id, "pad-mcp-")
var b strings.Builder
for _, c := range id {
if b.Len() == 8 {
break
}
if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') {
b.WriteRune(c)
}
}
return b.String()
}
+11
View File
@@ -68,6 +68,10 @@ import (
// yet wire produce a clear "not yet implemented over HTTP transport"
// error rather than failing silently — see Dispatch below.
type HTTPHandlerDispatcher struct {
// Clients resolves the remote caller's declared identity (BUG-2772). Nil
// still records every write as an agent write, unnamed.
Clients *ClientRegistry
// Handler is the pad-cloud API router. *server.Server already
// satisfies http.Handler via its ServeHTTP method.
Handler http.Handler
@@ -624,10 +628,17 @@ func (d *HTTPHandlerDispatcher) buildAuthedRequest(
if isMutatingMethod(method) && d.RequireVerifiedEmail != nil && d.RequireVerifiedEmail(user) {
return nil, fmt.Errorf("%s: verify your email address before mutating content over MCP", errEmailNotVerifiedPrefix)
}
// Who is writing (BUG-2772): an agent acting for the user, named by the
// client's declared clientInfo when it gave one.
caller := d.Clients.resolveRemoteCaller(ctx)
ctx = server.WithRemoteMCPCaller(ctx, server.RemoteMCPCaller{LeaseHolder: caller.LeaseHolder})
req, err := buildHTTPRequest(ctx, method, urlPath, body, user)
if err != nil {
return nil, err
}
if caller.Name != "" {
req.Header.Set("X-Pad-Agent", caller.Name)
}
if d.Apply != nil {
req = d.Apply(req)
}
+20
View File
@@ -203,6 +203,13 @@ const (
// comment_has_replies; that code stays for servers that predate it.
ErrCommentDeleted ErrorCode = "comment_deleted"
// ErrLeaseHeld fires on HTTP 409 responses that carry
// error.code="lease_held" (BUG-3496): a claim on an item another holder
// leases. Details carries ref, holder, acquired_at and expires_at, so the
// caller can see who has it and until when. A retry before expires_at
// refuses identically.
ErrLeaseHeld ErrorCode = "lease_held"
// ErrStoredStateUnreadable fires when an operation is refused because
// the ITEM'S STORED STATE cannot be decoded — today, an append to an
// implementation_notes / decision_log field whose value is not a list
@@ -475,6 +482,9 @@ var allowedStructuredErrorCodes = map[string]struct{}{
// BUG-3252 tombstone: a write addressed to a deleted comment that is
// kept only to hold its replies.
"comment_deleted": {},
// BUG-3496: a claim on an item someone else leases. details.holder and
// details.expires_at say who and until when.
"lease_held": {},
// BUG-2675. The only entry whose marker is written for a LOCALLY
// generated refusal rather than an upstream APIError — the CLI's
// append helpers refuse before any request is made (see
@@ -1012,6 +1022,10 @@ func classifyHTTPStatusKind(
// The tombstone stays a tombstone; no retry changes it.
hint = CommentDeletedHint
}
if upstream.Code == string(ErrLeaseHeld) {
// The lease stands until it expires or its holder releases it.
hint = LeaseHeldHint
}
if upstream.Code == string(ErrStoredStateUnreadable) {
// Same reason: the stored value stays undecodable, so every
// retry refuses identically. Since BUG-3056 the note/decide
@@ -1360,6 +1374,12 @@ const CommentHasRepliesHint = "Nothing was deleted. Delete the replies first (th
const CommentDeletedHint = "The comment was deleted and is kept only as a placeholder for its replies, so it cannot be edited, " +
"replied to or reacted to. Comment on the item, or reply to one of its replies, instead."
// LeaseHeldHint is the recovery guidance for ErrLeaseHeld on the remote
// transport. Duplicated in internal/cli (LeaseHeldHint), whose marker carries
// it on stdio; keep the two identical.
const LeaseHeldHint = "Another holder (details.holder) has claimed this item until details.expires_at. " +
"Pick other work, or wait until the lease expires; claiming again before then is refused the same way."
// ContentPendingFlushHint is the recovery guidance for ErrContentPendingFlush,
// on both transports. Duplicated in internal/cli (ContentPendingFlushHint) for
// the same dependency-graph reason StructuredErrorMarker is; a test asserts the
+1 -1
View File
@@ -6,7 +6,7 @@ Pad is a project tracker for developers and AI agents — issues (TASK, BUG), pl
If the user is asking general code questions with no project-management thread, you don't need this server.
## Tool surface (v0.76)
## Tool surface (v0.77)
Ten resource × action tools, plus `pad_set_workspace`. Eleven tools total.
+11 -4
View File
@@ -43,6 +43,10 @@ type Options struct {
// Experimental replaces capabilities.experimental when non-nil. Nil
// keeps experimentalCapabilities().
Experimental map[string]any
// Clients, when set, records each session's declared clientInfo at
// initialize for the remote dispatcher (BUG-2772). Remote transports only.
Clients *ClientRegistry
}
// NewServer constructs a pad MCP server. Call Run(ctx) to start it.
@@ -64,9 +68,7 @@ func NewServer(opts Options) *Server {
if experimental == nil {
experimental = experimentalCapabilities()
}
mcp := server.NewMCPServer(
ServerName,
version,
options := []server.ServerOption{
// Declare tool capability up-front so clients know the server
// CAN serve tools. The list stays empty until TASK-945 wires
// in the cmdhelp-derived registry.
@@ -87,7 +89,12 @@ func NewServer(opts Options) *Server {
// versioned with the binary; HTTPHandlerDispatcher (PLAN-943)
// advertises the same string.
server.WithInstructions(instructions),
)
}
if opts.Clients != nil {
// BUG-2772: record each session's declared client at initialize.
options = append(options, server.WithHooks(opts.Clients.hooks()))
}
mcp := server.NewMCPServer(ServerName, version, options...)
return &Server{mcp: mcp, debug: opts.Debug}
}
+3
View File
@@ -199,6 +199,9 @@ func TestEveryCLIMarkerCodeIsInTheStdioVocabulary(t *testing.T) {
"WriteCommentDeletedError": func(w *bytes.Buffer) {
cli.WriteCommentDeletedError(w, api("comment_deleted"))
},
"WriteLeaseHeldError": func(w *bytes.Buffer) {
cli.WriteLeaseHeldError(w, api("lease_held"))
},
}
for name, write := range writers {
+22 -1
View File
@@ -1738,6 +1738,27 @@ const CmdhelpVersion = "0.1"
// caller cannot read embeds nothing; a failed fetch stays non-fatal,
// as on stdio. Stdio is unchanged.
//
// 0.77 — BUG-2772 (lead ruling; IDEA-2791 Tier B). BEHAVIOR bump on the
// v0.76 / v0.49 grounds: a write over REMOTE /mcp is now recorded as an
// AGENT write (created_by / actor "agent"), named by the client's declared
// MCP clientInfo.name ("claude-code"), where it was recorded as the
// human. The name comes from a modern request's own _meta clientInfo,
// else the clientInfo the session sent at initialize (remembered per
// instance by the Mcp-Session-Id pad issued and the account that
// presented it, 30-minute idle TTL), else
// none: an unnamed agent write, never the human and never a guessed name.
// The default lease holder of a remote call is that name (or
// "mcp-client") plus a short id from the session, e.g.
// "claude-code#3f9a12c4", so two connections of one client do not share a
// lease; a reconnect gets a new session id, so the agent's own earlier
// lease reads as foreign until it expires (at most 15 minutes by
// default). Stdio is unchanged. No name, enum or param moved.
// BUG-3496 rides with it: a claim refused because another holder has
// the lease reaches the caller as `lease_held` (details ref, holder,
// acquired_at, expires_at, and a hint not to retry before expiry) on
// both transports, where remote answered `conflict` and stdio
// `server_error`.
//
// 0.76 — TASK-1809 (lead ruling). BEHAVIOR bump on the v0.70 / v0.49
// grounds: no tool name, action enum or param shape moved. Two
// changes, one identity:
@@ -1916,7 +1937,7 @@ const CmdhelpVersion = "0.1"
// this surface can receive it; the entry exists so a future action does
// not collapse it to permission_denied. When an action that can reach
// it is added, that addition is the contract change and owns the bump.
const ToolSurfaceVersion = "0.76"
const ToolSurfaceVersion = "0.77"
// MetaVersionURI is the canonical URI of the queryable version document.
// Lives outside the pad://workspace/{ws}/... namespace because it's a
+26
View File
@@ -281,6 +281,32 @@ func structuredEntryAuthor(r *http.Request) string {
return actor
}
// ctxRemoteMCPCaller marks an in-process request from the remote /mcp
// dispatcher (BUG-2772). Set only in-process; nothing reads it from the wire.
const ctxRemoteMCPCaller contextKey = "remote_mcp_caller"
// RemoteMCPCaller is a remote MCP client acting for the authenticated user.
type RemoteMCPCaller struct {
// LeaseHolder is the caller's default lease holder: its declared client
// name, or a fallback, plus a short id from its MCP session, so two
// connections of one client do not share a lease (lead ruling).
LeaseHolder string
}
// WithRemoteMCPCaller marks ctx as a remote MCP call. Every such write is an
// AGENT write (an app acting for the user), named by X-Pad-Agent when the
// client declared a name and unnamed when it did not, never the human
// (IDEA-2791's degradation contract).
func WithRemoteMCPCaller(ctx context.Context, c RemoteMCPCaller) context.Context {
return context.WithValue(ctx, ctxRemoteMCPCaller, c)
}
// remoteMCPCaller reports the remote MCP caller a request carries, if any.
func remoteMCPCaller(r *http.Request) (RemoteMCPCaller, bool) {
c, ok := r.Context().Value(ctxRemoteMCPCaller).(RemoteMCPCaller)
return c, ok
}
// ChatGPTDoorVersionsContent is the U1b guarantee the ChatGPT catalog's
// update_item description makes: a content write through the ChatGPT door
// is versioned first (handleUpdateItem, isChatGPTSurface). The ChatGPT
+5 -1
View File
@@ -368,9 +368,13 @@ func actorFromRequest(r *http.Request) (actor, source string) {
actor = "user"
source = "web"
// If an agent name header is present, mark as agent
// If an agent name header is present, mark as agent. A remote MCP call
// is an agent write whether or not its client declared a name
// (BUG-2772): an app acting for the user, never the user typing.
if agentNameFromRequest(r) != "" {
actor = "agent"
} else if _, ok := remoteMCPCaller(r); ok {
actor = "agent"
}
// Determine source from auth method. Two signals — both mean
+11 -2
View File
@@ -154,8 +154,10 @@ func (s *Server) resolveLeaseRequest(w http.ResponseWriter, r *http.Request) (it
// defaultLeaseHolder is the holder a claim or release names when the caller
// passes none, and the identity `pad project next` / `ready` compare leases
// against (TASK-1809): the request's agent name (X-Pad-Agent, which the CLI
// sets from the session registry), else the account's email, else its id.
// against (TASK-1809): for a remote MCP call, its client name plus a short
// session id (BUG-2772); else the request's agent name (X-Pad-Agent, which
// the CLI sets from the session registry); else the account's email, else its
// id.
//
// The agent name comes first because several agents commonly share ONE
// account. With the email default, a second agent's claim matched the first
@@ -164,6 +166,13 @@ func (s *Server) resolveLeaseRequest(w http.ResponseWriter, r *http.Request) (it
// is still bound to the authenticated account (BUG-3341), so it can only
// split the caller's own claims, never reach into another account's.
func defaultLeaseHolder(r *http.Request, user *models.User) string {
// A remote MCP caller holds by its declared client name plus a short id
// from its MCP session (BUG-2772, lead ruling): two connections of one
// client, both "claude-code", must not silently share a lease. Ahead of
// X-Pad-Agent, which carries only the name.
if c, ok := remoteMCPCaller(r); ok && c.LeaseHolder != "" {
return c.LeaseHolder
}
if name := agentNameFromRequest(r); name != "" {
return name
}