mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+14
-3
File diff suppressed because one or more lines are too long
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user