summaryrefslogtreecommitdiff
path: root/internal/executor/agentmcp.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-06-03 23:01:39 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-06-03 23:01:39 +0000
commit68cf1e24dd2ca2612b72babc4b35280cd3512f92 (patch)
tree14b73f21570a2f42ae729ca7e9676de6628d6819 /internal/executor/agentmcp.go
parentb28cfc6ff288d083f6c8e9c055b69bfcadbceccc (diff)
parent7388337d3be5cc7f65ef547e30b2e39884dd165b (diff)
merge: integrate oss branch — budget gating, MCP back-channel, events, loopback-only bind
Key features from OSS branch: - Budget gating: rolling per-provider spend caps with BUDGET_EXCEEDED task state - Agent MCP back-channel: runners mint tokens; /mcp endpoint resolves them - Chatbot MCP server at /chatbot/mcp (requires api_token in config) - Event timeline: unified event stream replacing ad-hoc question/summary flows - Loopback-only default bind (127.0.0.1:8484); external_bind_allowed=true to expose - repository_url required on task creation (enforces traceability) - Auto-checker: spawns verification task after each execution Conflict resolutions: - serve.go: keep cfg.Runners.XEnabled() conditional registration from main + agentRegistry from oss - config.go: keep RunnersConfig from main + BudgetConfig/ExternalBindAllowed from oss - server.go: handleStaticFiles (base-path rewrite) kept; deduplicated duplicate from both sides - executor/claude.go: accepted oss deletion (ClaudeRunner replaced by ContainerRunner) - container_test.go: added noopChannel + AgentChannel parameter to Run call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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)
+}