summaryrefslogtreecommitdiff
path: root/internal/executor/agentmcp.go
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-05-24 09:38:06 +0000
committerClaude <noreply@anthropic.com>2026-05-24 09:38:06 +0000
commit54f6631c28a8b85f6f874e17822549faba916a38 (patch)
treebfd2a45e77cdbe60ca95362caf90721da6c8ea22 /internal/executor/agentmcp.go
parentc7d95f3992d24f86ff71e5f3e18260a8ef8a09f0 (diff)
feat(executor): per-task agent MCP server + token registry (Phase 2)
Adds the agent-facing MCP transport foundation: a Registry that mints a per-task bearer token bound to a freshly built MCP server exposing the four agent tools (ask_user, report_summary, spawn_subtask, record_progress), and an HTTP handler (StreamableHTTP) that resolves the token to that server. The server never trusts an agent-supplied task ID — context comes from the token. The default storeChannel now buffers summary and question signals under a mutex (an MCP tool call lands on an HTTP-handler goroutine mid-run), exposing ReportedSummary/PendingQuestion. The pool flushes the buffered summary onto the execution after the run, replacing the runner's direct exec.Summary write and keeping the read race-free. ask_user follows the record-and-resume model: it buffers the question, returns ErrAgentBlocked, and the tool tells the agent to end its turn; the run blocks and resumes later via claude --resume (no live slot held). Tests cover registry lifecycle, in-memory tool dispatch, and HTTP end-to-end with bearer auth (valid token dispatches; invalid token rejected). Not yet wired into the runners or mounted on the API server — next increment. https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39
Diffstat (limited to 'internal/executor/agentmcp.go')
-rw-r--r--internal/executor/agentmcp.go160
1 files changed, 160 insertions, 0 deletions
diff --git a/internal/executor/agentmcp.go b/internal/executor/agentmcp.go
new file mode 100644
index 0000000..4368031
--- /dev/null
+++ b/internal/executor/agentmcp.go
@@ -0,0 +1,160 @@
+package executor
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strings"
+ "sync"
+
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// Registry maps per-task MCP bearer tokens to a built agent MCP server. A token
+// is minted when a runner spawns an agent subprocess and revoked when it exits,
+// so the server resolves task context from the token alone and never trusts an
+// agent-supplied task ID.
+type Registry struct {
+ mu sync.RWMutex
+ servers map[string]*mcp.Server
+}
+
+func NewRegistry() *Registry {
+ return &Registry{servers: make(map[string]*mcp.Server)}
+}
+
+// Mint creates a token bound to a freshly built MCP server for ch.
+func (r *Registry) Mint(ch AgentChannel) (string, error) {
+ buf := make([]byte, 32)
+ if _, err := rand.Read(buf); err != nil {
+ return "", err
+ }
+ token := hex.EncodeToString(buf)
+ r.mu.Lock()
+ r.servers[token] = newAgentServer(ch)
+ r.mu.Unlock()
+ return token, nil
+}
+
+func (r *Registry) server(token string) (*mcp.Server, bool) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ s, ok := r.servers[token]
+ return s, ok
+}
+
+func (r *Registry) Revoke(token string) {
+ r.mu.Lock()
+ delete(r.servers, token)
+ r.mu.Unlock()
+}
+
+type askUserInput struct {
+ Question string `json:"question" jsonschema:"the question to ask the user; phrase it as a real question ending with a question mark"`
+ Options []string `json:"options,omitempty" jsonschema:"optional list of suggested answer choices"`
+}
+
+type reportSummaryInput struct {
+ Summary string `json:"summary" jsonschema:"a 2-5 sentence summary of what you did and the outcome"`
+}
+
+type spawnSubtaskInput struct {
+ Name string `json:"name" jsonschema:"short descriptive name for the subtask"`
+ Instructions string `json:"instructions" jsonschema:"complete instructions for the subtask agent"`
+ Model string `json:"model,omitempty" jsonschema:"optional model override, e.g. sonnet or opus"`
+ MaxBudgetUSD float64 `json:"max_budget_usd,omitempty" jsonschema:"optional budget cap in USD"`
+}
+
+type recordProgressInput struct {
+ Message string `json:"message" jsonschema:"a short progress note describing what you are doing"`
+}
+
+func textResult(text string) *mcp.CallToolResult {
+ return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}}
+}
+
+// newAgentServer builds an MCP server exposing the four agent tools bound to ch.
+func newAgentServer(ch AgentChannel) *mcp.Server {
+ s := mcp.NewServer(&mcp.Implementation{Name: "claudomator", Version: "1"}, nil)
+
+ mcp.AddTool(s, &mcp.Tool{
+ Name: "ask_user",
+ Description: "Ask the user a question when you genuinely need a decision to proceed. Your turn ends after calling this; the task is resumed with the user's answer. Prefer making a reasonable decision and noting it in report_summary over asking.",
+ }, func(ctx context.Context, _ *mcp.CallToolRequest, in askUserInput) (*mcp.CallToolResult, any, error) {
+ q := map[string]any{"text": in.Question}
+ if len(in.Options) > 0 {
+ q["options"] = in.Options
+ }
+ payload, _ := json.Marshal(q)
+ ans, err := ch.AskUser(ctx, string(payload))
+ if errors.Is(err, ErrAgentBlocked) {
+ return textResult("Question recorded. End your turn now without calling any more tools; the task will be resumed once the user answers."), nil, nil
+ }
+ if err != nil {
+ return nil, nil, err
+ }
+ return textResult(ans), nil, nil
+ })
+
+ mcp.AddTool(s, &mcp.Tool{
+ Name: "report_summary",
+ Description: "Record a concise summary of what you accomplished. Call this before finishing.",
+ }, func(ctx context.Context, _ *mcp.CallToolRequest, in reportSummaryInput) (*mcp.CallToolResult, any, error) {
+ if err := ch.ReportSummary(ctx, in.Summary); err != nil {
+ return nil, nil, err
+ }
+ return textResult("Summary recorded."), nil, nil
+ })
+
+ mcp.AddTool(s, &mcp.Tool{
+ Name: "spawn_subtask",
+ Description: "Create a child task to be executed separately. Use this to break large work into focused pieces, then finish your turn.",
+ }, func(ctx context.Context, _ *mcp.CallToolRequest, in spawnSubtaskInput) (*mcp.CallToolResult, any, error) {
+ id, err := ch.SpawnSubtask(ctx, SubtaskSpec{
+ Name: in.Name,
+ Instructions: in.Instructions,
+ Model: in.Model,
+ MaxBudgetUSD: in.MaxBudgetUSD,
+ })
+ if err != nil {
+ return nil, nil, err
+ }
+ return textResult("Created subtask " + id), nil, nil
+ })
+
+ mcp.AddTool(s, &mcp.Tool{
+ Name: "record_progress",
+ Description: "Record a short progress note that appears in the task timeline.",
+ }, func(ctx context.Context, _ *mcp.CallToolRequest, in recordProgressInput) (*mcp.CallToolResult, any, error) {
+ if err := ch.RecordProgress(ctx, in.Message); err != nil {
+ return nil, nil, err
+ }
+ return textResult("Noted."), nil, nil
+ })
+
+ return s
+}
+
+func bearerToken(r *http.Request) string {
+ h := r.Header.Get("Authorization")
+ if h == "" {
+ return ""
+ }
+ return strings.TrimSpace(strings.TrimPrefix(h, "Bearer "))
+}
+
+// NewAgentMCPHandler returns the HTTP handler for the per-task agent MCP server.
+// It resolves the request's bearer token to the server for that task's run;
+// unknown tokens yield a 400 from the underlying handler.
+func NewAgentMCPHandler(reg *Registry) http.Handler {
+ return mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
+ s, ok := reg.server(bearerToken(r))
+ if !ok {
+ return nil
+ }
+ return s
+ }, nil)
+}