diff --git a/cmd/docgardener/agent.go b/cmd/docgardener/agent.go index 2a2c515..6a50629 100644 --- a/cmd/docgardener/agent.go +++ b/cmd/docgardener/agent.go @@ -175,11 +175,22 @@ func (a *agentRunner) handleCoordinatorKickoff(ctx context.Context) (string, err return "", err } - // Task tree. + // Task tree β€” prefer a real Gemini-generated decomposition when + // SYNAPBUS_GEMINI_MODEL is set; fall back to the fixed template + // on any failure so the demo works offline. + tree := buildTaskTree() + if llmTree, llmErr := geminiTaskTree(ctx, logger, a.msg.Body); llmErr == nil && llmTree != nil { + tree = *llmTree + a.postSystemMessage(ctx, g.ChannelID, + fmt.Sprintf("πŸ€– Coordinator invoked Gemini (%s) and materialized an LLM-generated task tree with %d leaves.", + os.Getenv("SYNAPBUS_GEMINI_MODEL"), countLeaves(llmTree))) + } else if llmErr != nil { + logger.Info("gemini coordinator skipped", "reason", llmErr) + } rootID, allIDs, err := a.tasks.CreateTree(ctx, goaltasks.CreateTreeInput{ GoalID: g.ID, CreatedByAgent: &coordID, - Root: buildTaskTree(), + Root: tree, InitialStatus: goaltasks.StatusApproved, DefaultBilling: "doc-gardener", }) diff --git a/cmd/docgardener/gemini_tree.go b/cmd/docgardener/gemini_tree.go new file mode 100644 index 0000000..1f934f6 --- /dev/null +++ b/cmd/docgardener/gemini_tree.go @@ -0,0 +1,227 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "os" + "os/exec" + "strings" + "time" + + "github.com/synapbus/synapbus/internal/goaltasks" +) + +// geminiTaskTree calls the `gemini` CLI with the goal brief and asks it +// to emit a JSON task tree. If GEMINI is not configured (env var +// SYNAPBUS_GEMINI_MODEL unset) or the call fails for any reason, it +// returns a nil tree and a non-nil error β€” callers fall back to the +// fixed buildTaskTree() template. +// +// Why shell out rather than use the Gemini SDK directly? +// 1. Zero extra dependencies keeps the docgardener binary lean. +// 2. Users already have `gemini` configured (see cold-topic-explainer +// wrapper.sh) and the CLI's auth is persistent. +// 3. The prompt is small enough that subprocess overhead is negligible. +func geminiTaskTree(ctx context.Context, logger *slog.Logger, goalBrief string) (*goaltasks.TreeNode, error) { + model := os.Getenv("SYNAPBUS_GEMINI_MODEL") + if model == "" { + return nil, errors.New("SYNAPBUS_GEMINI_MODEL not set") + } + if _, err := exec.LookPath("gemini"); err != nil { + return nil, fmt.Errorf("gemini CLI not found on PATH: %w", err) + } + + prompt := geminiPrompt(goalBrief) + _ = os.WriteFile("gemini_prompt.txt", []byte(prompt), 0o644) + + runCtx, cancel := context.WithTimeout(ctx, 120*time.Second) + defer cancel() + + cmd := exec.CommandContext(runCtx, "gemini", + "-m", model, + "--approval-mode", "yolo", + "-p", prompt, + ) + cmd.Env = append(os.Environ(), "NO_COLOR=1") + + out, err := cmd.Output() + if err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) { + return nil, fmt.Errorf("gemini exit %d: %s", ee.ExitCode(), string(ee.Stderr)) + } + return nil, fmt.Errorf("gemini exec: %w", err) + } + + raw := string(out) + _ = os.WriteFile("gemini_response.txt", []byte(raw), 0o644) + + // Gemini sometimes prepends diagnostic lines ("MCP issues detected…") + // and often wraps JSON in a ```json fenced block. Extract the first + // balanced {...} object. + jsonBlob, err := extractJSONObject(raw) + if err != nil { + return nil, fmt.Errorf("gemini response had no JSON object: %w (raw=%q)", err, truncate(raw, 500)) + } + + var tree goaltasks.TreeNode + if err := json.Unmarshal([]byte(jsonBlob), &tree); err != nil { + return nil, fmt.Errorf("parse gemini JSON: %w (raw=%q)", err, truncate(jsonBlob, 500)) + } + if tree.Title == "" || len(tree.Children) == 0 { + return nil, fmt.Errorf("gemini returned an empty or rootless tree") + } + + // Force the leaves to use the billing codes docgardener's dispatcher + // matches on; if Gemini invents its own leaves we keep the coordinator + // happy by aligning them with the 3 role slots. + alignLeafBillingCodes(&tree) + + logger.Info("gemini task tree", "leaves", countLeaves(&tree), "bytes", len(jsonBlob)) + return &tree, nil +} + +func geminiPrompt(brief string) string { + return `You are the doc-gardener coordinator. A human has given you this brief: + +""" +` + brief + ` +""" + +Decompose this into a task tree with exactly 3 leaf tasks for these 3 specialists: + + 1. docs-scanner β€” scans the documentation website and extracts every CLI flag + and config option mentioned; emits #finding messages. + 2. cli-verifier β€” runs the target binary and reacts to each #finding with + #verified or #missing. + 3. drift-reporter β€” aggregates the verifier reactions and produces a final + drift report with counts of matches, drifts, and patches. + +Return ONLY a JSON object with this exact schema (no prose, no markdown fences): + +{ + "title": "", + "description": "", + "acceptance_criteria": "", + "billing_code": "doc-gardener", + "children": [ + { + "title": "Scan docs for CLI flags and config keys", + "description": "", + "acceptance_criteria": "", + "billing_code": "doc-gardener/scan" + }, + { + "title": "Verify flags exist in mcpproxy binary", + "description": "", + "acceptance_criteria": "", + "billing_code": "doc-gardener/verify" + }, + { + "title": "Produce drift report", + "description": "", + "acceptance_criteria": "", + "billing_code": "doc-gardener/report" + } + ] +} + +The billing_code values MUST be exactly "doc-gardener", "doc-gardener/scan", +"doc-gardener/verify", "doc-gardener/report" β€” the coordinator routes tasks to +specialists by matching on those strings. Emit ONLY the JSON object.` +} + +// extractJSONObject finds the first balanced {...} object in raw, ignoring +// markdown fences and preamble. +func extractJSONObject(raw string) (string, error) { + // Strip common preambles. + raw = strings.TrimPrefix(raw, "MCP issues detected. Run /mcp list for status.") + start := strings.IndexByte(raw, '{') + if start < 0 { + return "", errors.New("no '{' in output") + } + depth := 0 + inStr := false + escape := false + for i := start; i < len(raw); i++ { + ch := raw[i] + if escape { + escape = false + continue + } + if ch == '\\' && inStr { + escape = true + continue + } + if ch == '"' { + inStr = !inStr + continue + } + if inStr { + continue + } + switch ch { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return raw[start : i+1], nil + } + } + } + return "", errors.New("unbalanced braces") +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} + +func countLeaves(n *goaltasks.TreeNode) int { + if len(n.Children) == 0 { + return 1 + } + total := 0 + for i := range n.Children { + total += countLeaves(&n.Children[i]) + } + return total +} + +// alignLeafBillingCodes ensures exactly the 3 expected billing codes are set +// on the leaves so the coordinator's dispatch-by-billing-code logic still +// works even when Gemini drifts slightly. Leaves are aligned in order. +func alignLeafBillingCodes(root *goaltasks.TreeNode) { + want := []string{"doc-gardener/scan", "doc-gardener/verify", "doc-gardener/report"} + if root.BillingCode == "" { + root.BillingCode = "doc-gardener" + } + // If the root's children are themselves parents, descend. + leaves := collectLeaves(root) + for i, leaf := range leaves { + if i >= len(want) { + break + } + leaf.BillingCode = want[i] + if leaf.VerifierConfig == nil { + leaf.VerifierConfig = &goaltasks.VerifierConfig{Kind: goaltasks.VerifierKindAuto} + } + } +} + +func collectLeaves(n *goaltasks.TreeNode) []*goaltasks.TreeNode { + if len(n.Children) == 0 { + return []*goaltasks.TreeNode{n} + } + var out []*goaltasks.TreeNode + for i := range n.Children { + out = append(out, collectLeaves(&n.Children[i])...) + } + return out +} diff --git a/cmd/synapbus/main.go b/cmd/synapbus/main.go index 0fe2010..ec2b592 100644 --- a/cmd/synapbus/main.go +++ b/cmd/synapbus/main.go @@ -569,6 +569,19 @@ func runServe(cmd *cobra.Command, args []string) error { mcpSrv.SetMarketplaceService(marketplaceSvc) slog.Info("agent marketplace service initialized (spec 016)") + // Wire the spec-018 tool surface (dynamic agent spawning). Only + // registered if the goals/tasks services are up β€” which they + // always are after the block above. + goalsToolReg := mcpserver.NewGoalsToolRegistrar( + goalsService, + goalTasksService, + agentService, + secretsStore, + db.DB, + ) + mcpSrv.WireGoalsTools(goalsToolReg) + slog.Info("spec-018 MCP tools wired (create_goal, propose_task_tree, propose_agent, claim_task, request_resource, list_resources)") + // Set up SQL query executor for agents (uses read pool if available) queryDB := db.QueryDB() queryExec := agentquery.New(queryDB, slog.Default()) diff --git a/internal/mcp/goals_tools.go b/internal/mcp/goals_tools.go new file mode 100644 index 0000000..d4bcbac --- /dev/null +++ b/internal/mcp/goals_tools.go @@ -0,0 +1,371 @@ +// goals_tools.go registers the spec-018 (dynamic agent spawning) MCP +// tools. All tools require an authenticated agent context (the caller +// is identified via extractAgentName). They wrap the goals/goal_tasks/ +// secrets services so LLM agents can drive the full +// goal β†’ task tree β†’ spawn β†’ verify flow through MCP. +package mcp + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "log/slog" + "strings" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + + "github.com/synapbus/synapbus/internal/agents" + "github.com/synapbus/synapbus/internal/goals" + "github.com/synapbus/synapbus/internal/goaltasks" + "github.com/synapbus/synapbus/internal/secrets" +) + +// GoalsToolRegistrar wires the spec-018 primitives into MCP tool calls. +type GoalsToolRegistrar struct { + goals *goals.Service + tasks *goaltasks.Service + agents *agents.AgentService + secrets *secrets.Store + db *sql.DB + logger *slog.Logger +} + +// NewGoalsToolRegistrar builds a registrar. All dependencies are +// optional β€” handlers return a clear error when they need something +// that wasn't wired. +func NewGoalsToolRegistrar( + g *goals.Service, + t *goaltasks.Service, + a *agents.AgentService, + sec *secrets.Store, + db *sql.DB, +) *GoalsToolRegistrar { + return &GoalsToolRegistrar{ + goals: g, + tasks: t, + agents: a, + secrets: sec, + db: db, + logger: slog.Default().With("component", "mcp-goals-tools"), + } +} + +// RegisterAllOnServer attaches create_goal, propose_task_tree, +// propose_agent, claim_task, request_resource, list_resources to the +// MCP server. +func (r *GoalsToolRegistrar) RegisterAllOnServer(s *server.MCPServer) { + s.AddTool(r.createGoalTool(), r.handleCreateGoal) + s.AddTool(r.proposeTaskTreeTool(), r.handleProposeTaskTree) + s.AddTool(r.proposeAgentTool(), r.handleProposeAgent) + s.AddTool(r.claimTaskTool(), r.handleClaimTask) + s.AddTool(r.requestResourceTool(), r.handleRequestResource) + s.AddTool(r.listResourcesTool(), r.handleListResources) + r.logger.Info("spec-018 MCP tools registered", "count", 6) +} + +// --- Tool Definitions --- + +func (r *GoalsToolRegistrar) createGoalTool() mcplib.Tool { + return mcplib.NewTool("create_goal", + mcplib.WithDescription("Create a new top-level goal owned by the calling agent's human owner. Returns the goal id + backing channel id. The goal starts in draft status β€” call propose_task_tree to populate it and then use the execute tool to transition it to active."), + mcplib.WithString("title", mcplib.Description("Short goal title"), mcplib.Required()), + mcplib.WithString("description", mcplib.Description("Full goal brief (the 'blob' from the human)"), mcplib.Required()), + mcplib.WithNumber("budget_dollars_cents", mcplib.Description("Optional dollar budget in cents (e.g. 5000 = $50)")), + mcplib.WithNumber("budget_tokens", mcplib.Description("Optional token budget")), + mcplib.WithNumber("max_spawn_depth", mcplib.Description("Max agent spawn depth (default 3)")), + ) +} + +func (r *GoalsToolRegistrar) proposeTaskTreeTool() mcplib.Tool { + return mcplib.NewTool("propose_task_tree", + mcplib.WithDescription("Materialize a task tree under a goal. The tree field is a JSON TreeNode with {title, description, acceptance_criteria, billing_code, children[]}. Tasks are inserted in 'approved' status so specialists can claim them immediately."), + mcplib.WithNumber("goal_id", mcplib.Description("Goal id from create_goal"), mcplib.Required()), + mcplib.WithString("tree", mcplib.Description("JSON-encoded TreeNode"), mcplib.Required()), + ) +} + +func (r *GoalsToolRegistrar) proposeAgentTool() mcplib.Tool { + return mcplib.NewTool("propose_agent", + mcplib.WithDescription("Propose creating a new specialist agent. Writes an agent_proposals row so a human can approve it via the #approvals channel. Returns the proposal id."), + mcplib.WithString("name", mcplib.Description("Desired agent name"), mcplib.Required()), + mcplib.WithString("display_name", mcplib.Description("Human-readable display name")), + mcplib.WithString("system_prompt", mcplib.Description("System prompt for the spawned agent"), mcplib.Required()), + mcplib.WithString("tool_scope", mcplib.Description("Comma-separated scope (e.g. 'messages:read,messages:send')")), + mcplib.WithNumber("parent_task_id", mcplib.Description("The task this agent will work on")), + mcplib.WithString("autonomy_tier", mcplib.Description("supervised | assisted | autonomous (default assisted)")), + ) +} + +func (r *GoalsToolRegistrar) claimTaskTool() mcplib.Tool { + return mcplib.NewTool("claim_task", + mcplib.WithDescription("Atomically claim an approved task. Returns the claimed task or an error if another agent got it first."), + mcplib.WithNumber("task_id", mcplib.Description("Task id to claim"), mcplib.Required()), + ) +} + +func (r *GoalsToolRegistrar) requestResourceTool() mcplib.Tool { + return mcplib.NewTool("request_resource", + mcplib.WithDescription("Post a structured resource request to the #requests channel when a task needs a secret the agent doesn't currently have. The human reads it and provisions the secret via `synapbus secrets set`."), + mcplib.WithString("resource_name", mcplib.Description("Uppercase env var name, e.g. BREVO_API_KEY"), mcplib.Required()), + mcplib.WithString("reason", mcplib.Description("Why this resource is needed"), mcplib.Required()), + mcplib.WithNumber("task_id", mcplib.Description("Task this request is attached to"), mcplib.Required()), + ) +} + +func (r *GoalsToolRegistrar) listResourcesTool() mcplib.Tool { + return mcplib.NewTool("list_resources", + mcplib.WithDescription("List the names of secrets currently available to the calling agent. Returns names only β€” never values. Use this to check whether a secret has been provisioned after calling request_resource."), + ) +} + +// --- Handlers --- + +func (r *GoalsToolRegistrar) handleCreateGoal(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + agentName, ok := extractAgentName(ctx) + if !ok { + return mcplib.NewToolResultError("authentication required"), nil + } + if r.goals == nil { + return mcplib.NewToolResultError("goals service not configured"), nil + } + title := req.GetString("title", "") + description := req.GetString("description", "") + if title == "" || description == "" { + return mcplib.NewToolResultError("title and description are required"), nil + } + agent, err := r.agents.GetAgent(ctx, agentName) + if err != nil { + return mcplib.NewToolResultError(fmt.Sprintf("resolve caller: %s", err)), nil + } + var ownerUsername string + _ = r.db.QueryRowContext(ctx, `SELECT username FROM users WHERE id=?`, agent.OwnerID).Scan(&ownerUsername) + + var budgetTokens, budgetDollars *int64 + if v := req.GetInt("budget_tokens", 0); v > 0 { + t := int64(v) + budgetTokens = &t + } + if v := req.GetInt("budget_dollars_cents", 0); v > 0 { + t := int64(v) + budgetDollars = &t + } + maxDepth := req.GetInt("max_spawn_depth", 3) + + g, err := r.goals.CreateGoal(ctx, goals.CreateGoalInput{ + Title: title, + Description: description, + OwnerUserID: agent.OwnerID, + OwnerUsername: ownerUsername, + CoordinatorAgentID: &agent.ID, + BudgetTokens: budgetTokens, + BudgetDollarsCents: budgetDollars, + MaxSpawnDepth: maxDepth, + }) + if err != nil { + return mcplib.NewToolResultError(fmt.Sprintf("create_goal: %s", err)), nil + } + + return resultJSON(map[string]any{ + "goal_id": g.ID, + "slug": g.Slug, + "channel_id": g.ChannelID, + "status": g.Status, + }) +} + +func (r *GoalsToolRegistrar) handleProposeTaskTree(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + agentName, ok := extractAgentName(ctx) + if !ok { + return mcplib.NewToolResultError("authentication required"), nil + } + if r.tasks == nil { + return mcplib.NewToolResultError("goal_tasks service not configured"), nil + } + goalID := int64(req.GetInt("goal_id", 0)) + if goalID <= 0 { + return mcplib.NewToolResultError("goal_id is required"), nil + } + treeJSON := req.GetString("tree", "") + if treeJSON == "" { + return mcplib.NewToolResultError("tree is required"), nil + } + var tree goaltasks.TreeNode + if err := json.Unmarshal([]byte(treeJSON), &tree); err != nil { + return mcplib.NewToolResultError(fmt.Sprintf("parse tree: %s", err)), nil + } + + agent, err := r.agents.GetAgent(ctx, agentName) + if err != nil { + return mcplib.NewToolResultError(fmt.Sprintf("resolve caller: %s", err)), nil + } + + rootID, allIDs, err := r.tasks.CreateTree(ctx, goaltasks.CreateTreeInput{ + GoalID: goalID, + CreatedByAgent: &agent.ID, + Root: tree, + InitialStatus: goaltasks.StatusApproved, + DefaultBilling: "mcp", + }) + if err != nil { + return mcplib.NewToolResultError(fmt.Sprintf("create tree: %s", err)), nil + } + + // Also set the goal's root_task_id. + _, _ = r.db.ExecContext(ctx, `UPDATE goals SET root_task_id=? WHERE id=?`, rootID, goalID) + + return resultJSON(map[string]any{ + "root_task_id": rootID, + "task_count": len(allIDs), + }) +} + +func (r *GoalsToolRegistrar) handleProposeAgent(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + agentName, ok := extractAgentName(ctx) + if !ok { + return mcplib.NewToolResultError("authentication required"), nil + } + if r.db == nil { + return mcplib.NewToolResultError("db not configured"), nil + } + name := req.GetString("name", "") + if name == "" { + return mcplib.NewToolResultError("name is required"), nil + } + systemPrompt := req.GetString("system_prompt", "") + if systemPrompt == "" { + return mcplib.NewToolResultError("system_prompt is required"), nil + } + toolScope := req.GetString("tool_scope", "") + if toolScope == "" { + toolScope = "[]" + } else if !strings.HasPrefix(toolScope, "[") { + // Accept comma-separated convenience form. + parts := strings.Split(toolScope, ",") + for i := range parts { + parts[i] = `"` + strings.TrimSpace(parts[i]) + `"` + } + toolScope = "[" + strings.Join(parts, ",") + "]" + } + tier := req.GetString("autonomy_tier", "assisted") + parentTaskID := int64(req.GetInt("parent_task_id", 0)) + if parentTaskID <= 0 { + return mcplib.NewToolResultError("parent_task_id is required (proposals must attach to a task)"), nil + } + + agent, err := r.agents.GetAgent(ctx, agentName) + if err != nil { + return mcplib.NewToolResultError(fmt.Sprintf("resolve caller: %s", err)), nil + } + + // Resolve goal_id from the parent task. + var goalID int64 + if err := r.db.QueryRowContext(ctx, + `SELECT goal_id FROM goal_tasks WHERE id=?`, parentTaskID).Scan(&goalID); err != nil { + return mcplib.NewToolResultError(fmt.Sprintf("resolve goal from task %d: %s", parentTaskID, err)), nil + } + + res, err := r.db.ExecContext(ctx, ` + INSERT INTO agent_proposals ( + proposer_agent_id, goal_id, parent_task_id, proposed_name, + proposed_model, proposed_system_prompt, proposed_tool_scope_json, + proposed_autonomy_tier, status + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending')`, + agent.ID, goalID, parentTaskID, name, + "gemini-2.5-flash", systemPrompt, toolScope, tier) + if err != nil { + return mcplib.NewToolResultError(fmt.Sprintf("insert proposal: %s", err)), nil + } + id, _ := res.LastInsertId() + + return resultJSON(map[string]any{ + "proposal_id": id, + "status": "pending", + "name": name, + }) +} + +func (r *GoalsToolRegistrar) handleClaimTask(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + agentName, ok := extractAgentName(ctx) + if !ok { + return mcplib.NewToolResultError("authentication required"), nil + } + if r.tasks == nil { + return mcplib.NewToolResultError("goal_tasks service not configured"), nil + } + taskID := int64(req.GetInt("task_id", 0)) + if taskID <= 0 { + return mcplib.NewToolResultError("task_id is required"), nil + } + agent, err := r.agents.GetAgent(ctx, agentName) + if err != nil { + return mcplib.NewToolResultError(fmt.Sprintf("resolve caller: %s", err)), nil + } + if err := r.tasks.Claim(ctx, taskID, agent.ID, nil); err != nil { + return mcplib.NewToolResultError(fmt.Sprintf("claim_task: %s", err)), nil + } + _ = r.tasks.Transition(ctx, taskID, goaltasks.StatusInProgress, goaltasks.Extras{}) + return resultJSON(map[string]any{ + "task_id": taskID, + "status": "claimed", + }) +} + +func (r *GoalsToolRegistrar) handleRequestResource(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + agentName, ok := extractAgentName(ctx) + if !ok { + return mcplib.NewToolResultError("authentication required"), nil + } + resourceName := strings.ToUpper(strings.TrimSpace(req.GetString("resource_name", ""))) + reason := req.GetString("reason", "") + taskID := int64(req.GetInt("task_id", 0)) + if resourceName == "" || reason == "" || taskID <= 0 { + return mcplib.NewToolResultError("resource_name, reason, and task_id are all required"), nil + } + agent, err := r.agents.GetAgent(ctx, agentName) + if err != nil { + return mcplib.NewToolResultError(fmt.Sprintf("resolve caller: %s", err)), nil + } + _, err = r.db.ExecContext(ctx, ` + INSERT INTO resource_requests (requester_agent_id, task_id, resource_name, resource_type, reason, status) + VALUES (?, ?, ?, 'env_var', ?, 'pending')`, + agent.ID, taskID, resourceName, reason) + if err != nil { + return mcplib.NewToolResultError(fmt.Sprintf("insert resource_request: %s", err)), nil + } + return resultJSON(map[string]any{ + "status": "pending", + "resource": resourceName, + "next_action": fmt.Sprintf("synapbus secrets set %s --scope agent:%s", resourceName, agentName), + }) +} + +func (r *GoalsToolRegistrar) handleListResources(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + agentName, ok := extractAgentName(ctx) + if !ok { + return mcplib.NewToolResultError("authentication required"), nil + } + if r.secrets == nil { + return resultJSON(map[string]any{"resources": []any{}}) + } + agent, err := r.agents.GetAgent(ctx, agentName) + if err != nil { + return mcplib.NewToolResultError(fmt.Sprintf("resolve caller: %s", err)), nil + } + infos, err := r.secrets.List(ctx, []secrets.Scope{ + {Type: secrets.ScopeUser, ID: agent.OwnerID}, + {Type: secrets.ScopeAgent, ID: agent.ID}, + }) + if err != nil { + return mcplib.NewToolResultError(fmt.Sprintf("list secrets: %s", err)), nil + } + names := make([]string, 0, len(infos)) + for _, i := range infos { + names = append(names, i.Name) + } + return resultJSON(map[string]any{ + "resources": names, + "count": len(names), + }) +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 25e788f..fd1b0df 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -206,6 +206,16 @@ func NewMCPServer( return s } +// WireGoalsTools registers the spec-018 tool surface (create_goal, +// propose_task_tree, propose_agent, claim_task, request_resource, +// list_resources) on the MCP server. Must be called after NewMCPServer. +func (s *MCPServer) WireGoalsTools(r *GoalsToolRegistrar) { + if r == nil || s.mcpServer == nil { + return + } + r.RegisterAllOnServer(s.mcpServer) +} + // SetQueryExecutor sets the SQL query executor for agent queries via the execute tool. func (s *MCPServer) SetQueryExecutor(exec *agentquery.Executor) { if s.hybridRegistrar != nil {