feat(018): Gemini LLM coordinator + spec-018 MCP tool surface

- cmd/docgardener: coordinator calls the `gemini` CLI with the goal
  brief when SYNAPBUS_GEMINI_MODEL is set, parses the returned JSON
  into a goaltasks.TreeNode, and aligns leaf billing codes so the
  fixed dispatch table still routes specialists correctly. Falls
  back to the hardcoded template on any failure (missing CLI, non-
  zero exit, bad JSON) so the demo still works offline.
- internal/mcp: new GoalsToolRegistrar exposing 6 spec-018 tools —
  create_goal, propose_task_tree, propose_agent, claim_task,
  request_resource, list_resources. All require an authenticated
  agent context; wire-only changes on the MCP server side.
- main.go: builds + attaches the new registrar after the hybrid
  tool registrar, logs the 6 tools at startup.

Verified e2e: demo run with Gemini produces an LLM-generated root
task title ("Verify and patch mcpproxy documentation drift"), all
3 specialists dispatched and completed, $1.05 cost rollup on the
/goals/1 page, and the MCP server registers 11 tools total (5
hybrid + 6 spec-018) at boot.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Algis Dumbris
2026-04-14 20:37:07 +03:00
co-authored by Claude Opus 4.6
parent f9d8f1a1c9
commit 4e6f6cb539
5 changed files with 634 additions and 2 deletions
+13 -2
View File
@@ -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",
})
+227
View File
@@ -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": "<root task title>",
"description": "<root task description>",
"acceptance_criteria": "<root acceptance criteria>",
"billing_code": "doc-gardener",
"children": [
{
"title": "Scan docs for CLI flags and config keys",
"description": "<specifics>",
"acceptance_criteria": "<specifics>",
"billing_code": "doc-gardener/scan"
},
{
"title": "Verify flags exist in mcpproxy binary",
"description": "<specifics>",
"acceptance_criteria": "<specifics>",
"billing_code": "doc-gardener/verify"
},
{
"title": "Produce drift report",
"description": "<specifics>",
"acceptance_criteria": "<specifics>",
"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
}
+13
View File
@@ -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())
+371
View File
@@ -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 <value> --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),
})
}
+10
View File
@@ -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 {