feat(agentic): add dispatch and scan MCP tools

agentic_dispatch: spawn Gemini/Codex/Claude subagents with prepped
workspace context. Builds structured prompts from CLAUDE.md, OpenBrain
memories, consumers, and git log. Runs in background with PID tracking.

agentic_scan: scan Forge repos for open issues with actionable labels
(agentic, help-wanted, bug). Deduplicates cross-label results.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-15 08:09:48 +00:00
parent ebd44bf2f2
commit ca5c235ece
3 changed files with 420 additions and 0 deletions

242
pkg/mcp/agentic/dispatch.go Normal file
View file

@ -0,0 +1,242 @@
// SPDX-License-Identifier: EUPL-1.2
package agentic
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// DispatchInput is the input for agentic_dispatch.
type DispatchInput struct {
Repo string `json:"repo"` // Target repo (e.g. "go-io")
Org string `json:"org,omitempty"` // Forge org (default "core")
Task string `json:"task"` // What the agent should do
Agent string `json:"agent,omitempty"` // "gemini" (default), "codex", "claude"
Issue int `json:"issue,omitempty"` // Forge issue to work from
DryRun bool `json:"dry_run,omitempty"` // Preview without executing
}
// DispatchOutput is the output for agentic_dispatch.
type DispatchOutput struct {
Success bool `json:"success"`
Agent string `json:"agent"`
Repo string `json:"repo"`
WorkDir string `json:"work_dir"`
Prompt string `json:"prompt,omitempty"`
PID int `json:"pid,omitempty"`
OutputFile string `json:"output_file,omitempty"`
}
func (s *PrepSubsystem) registerDispatchTool(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "agentic_dispatch",
Description: "Dispatch a subagent (Gemini, Codex, or Claude) to work on a task in a specific repo. Preps workspace context first, then spawns the agent with a structured prompt.",
}, s.dispatch)
}
func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest, input DispatchInput) (*mcp.CallToolResult, DispatchOutput, error) {
if input.Repo == "" {
return nil, DispatchOutput{}, fmt.Errorf("repo is required")
}
if input.Task == "" {
return nil, DispatchOutput{}, fmt.Errorf("task is required")
}
if input.Org == "" {
input.Org = "core"
}
if input.Agent == "" {
input.Agent = "gemini"
}
repoPath := filepath.Join(s.codePath, "core", input.Repo)
outputDir := filepath.Join(repoPath, ".core")
// Step 1: Prep workspace
prepInput := PrepInput{
Repo: input.Repo,
Org: input.Org,
Issue: input.Issue,
}
_, _, _ = s.prepWorkspace(ctx, req, prepInput)
// Step 2: Build prompt from prepped context
prompt := s.buildPrompt(input, outputDir)
if input.DryRun {
return nil, DispatchOutput{
Success: true,
Agent: input.Agent,
Repo: input.Repo,
WorkDir: repoPath,
Prompt: prompt,
}, nil
}
// Step 3: Spawn agent
switch input.Agent {
case "gemini":
return s.spawnGemini(repoPath, prompt, input)
case "codex":
return s.spawnCodex(repoPath, prompt, input)
case "claude":
return s.spawnClaude(repoPath, prompt, input)
default:
return nil, DispatchOutput{}, fmt.Errorf("unknown agent: %s (use gemini, codex, or claude)", input.Agent)
}
}
func (s *PrepSubsystem) buildPrompt(input DispatchInput, outputDir string) string {
var prompt strings.Builder
prompt.WriteString("You are working on the " + input.Repo + " repository.\n\n")
// Include CLAUDE.md context
if data, err := os.ReadFile(filepath.Join(outputDir, "CLAUDE.md")); err == nil {
prompt.WriteString("## Project Context (CLAUDE.md)\n\n")
prompt.WriteString(string(data))
prompt.WriteString("\n\n")
}
// Include OpenBrain context
if data, err := os.ReadFile(filepath.Join(outputDir, "context.md")); err == nil {
prompt.WriteString(string(data))
prompt.WriteString("\n\n")
}
// Include consumers
if data, err := os.ReadFile(filepath.Join(outputDir, "consumers.md")); err == nil {
prompt.WriteString(string(data))
prompt.WriteString("\n\n")
}
// Include recent changes
if data, err := os.ReadFile(filepath.Join(outputDir, "recent.md")); err == nil {
prompt.WriteString(string(data))
prompt.WriteString("\n\n")
}
// Include TODO if from issue
if data, err := os.ReadFile(filepath.Join(outputDir, "todo.md")); err == nil {
prompt.WriteString(string(data))
prompt.WriteString("\n\n")
}
// The actual task
prompt.WriteString("## Your Task\n\n")
prompt.WriteString(input.Task)
prompt.WriteString("\n\n")
// Conventions
prompt.WriteString("## Conventions\n\n")
prompt.WriteString("- UK English (colour, organisation, centre)\n")
prompt.WriteString("- Conventional commits: type(scope): description\n")
prompt.WriteString("- Co-Author: Co-Authored-By: Virgil <virgil@lethean.io>\n")
prompt.WriteString("- Licence: EUPL-1.2\n")
prompt.WriteString("- Push to forge: ssh://git@forge.lthn.ai:2223/" + input.Org + "/" + input.Repo + ".git\n")
return prompt.String()
}
func (s *PrepSubsystem) spawnGemini(repoPath, prompt string, input DispatchInput) (*mcp.CallToolResult, DispatchOutput, error) {
// Write prompt to temp file (gemini -p has length limits)
promptFile := filepath.Join(repoPath, ".core", "dispatch-prompt.md")
os.WriteFile(promptFile, []byte(prompt), 0644)
// Output file for capturing results
outputFile := filepath.Join(repoPath, ".core", fmt.Sprintf("dispatch-%s-%d.log", input.Agent, time.Now().Unix()))
cmd := exec.Command("gemini", "-p", prompt, "--yolo")
cmd.Dir = repoPath
outFile, _ := os.Create(outputFile)
cmd.Stdout = outFile
cmd.Stderr = outFile
if err := cmd.Start(); err != nil {
outFile.Close()
return nil, DispatchOutput{}, fmt.Errorf("failed to spawn gemini: %w", err)
}
// Don't wait — let it run in background
go func() {
cmd.Wait()
outFile.Close()
}()
return nil, DispatchOutput{
Success: true,
Agent: "gemini",
Repo: input.Repo,
WorkDir: repoPath,
PID: cmd.Process.Pid,
OutputFile: outputFile,
}, nil
}
func (s *PrepSubsystem) spawnCodex(repoPath, prompt string, input DispatchInput) (*mcp.CallToolResult, DispatchOutput, error) {
outputFile := filepath.Join(repoPath, ".core", fmt.Sprintf("dispatch-%s-%d.log", input.Agent, time.Now().Unix()))
cmd := exec.Command("codex", "--approval-mode", "full-auto", "-q", prompt)
cmd.Dir = repoPath
outFile, _ := os.Create(outputFile)
cmd.Stdout = outFile
cmd.Stderr = outFile
if err := cmd.Start(); err != nil {
outFile.Close()
return nil, DispatchOutput{}, fmt.Errorf("failed to spawn codex: %w", err)
}
go func() {
cmd.Wait()
outFile.Close()
}()
return nil, DispatchOutput{
Success: true,
Agent: "codex",
Repo: input.Repo,
WorkDir: repoPath,
PID: cmd.Process.Pid,
OutputFile: outputFile,
}, nil
}
func (s *PrepSubsystem) spawnClaude(repoPath, prompt string, input DispatchInput) (*mcp.CallToolResult, DispatchOutput, error) {
outputFile := filepath.Join(repoPath, ".core", fmt.Sprintf("dispatch-%s-%d.log", input.Agent, time.Now().Unix()))
cmd := exec.Command("claude", "-p", prompt, "--dangerously-skip-permissions")
cmd.Dir = repoPath
outFile, _ := os.Create(outputFile)
cmd.Stdout = outFile
cmd.Stderr = outFile
if err := cmd.Start(); err != nil {
outFile.Close()
return nil, DispatchOutput{}, fmt.Errorf("failed to spawn claude: %w", err)
}
go func() {
cmd.Wait()
outFile.Close()
}()
return nil, DispatchOutput{
Success: true,
Agent: "claude",
Repo: input.Repo,
WorkDir: repoPath,
PID: cmd.Process.Pid,
OutputFile: outputFile,
}, nil
}

View file

@ -74,6 +74,13 @@ func (s *PrepSubsystem) RegisterTools(server *mcp.Server) {
Name: "agentic_prep_workspace",
Description: "Prepare an agent workspace with CLAUDE.md, wiki KB, specs, OpenBrain context, consumer list, and recent git log for a target repo. Output goes to the repo's .core/ directory.",
}, s.prepWorkspace)
s.registerDispatchTool(server)
mcp.AddTool(server, &mcp.Tool{
Name: "agentic_scan",
Description: "Scan Forge repos for open issues with actionable labels (agentic, help-wanted, bug). Returns a list of issues that can be dispatched to subagents.",
}, s.scan)
}
// Shutdown implements mcp.SubsystemWithShutdown.

171
pkg/mcp/agentic/scan.go Normal file
View file

@ -0,0 +1,171 @@
// SPDX-License-Identifier: EUPL-1.2
package agentic
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// ScanInput is the input for agentic_scan.
type ScanInput struct {
Org string `json:"org,omitempty"` // default "core"
Labels []string `json:"labels,omitempty"` // filter by labels (default: agentic, help-wanted, bug)
Limit int `json:"limit,omitempty"` // max issues to return
}
// ScanOutput is the output for agentic_scan.
type ScanOutput struct {
Success bool `json:"success"`
Count int `json:"count"`
Issues []ScanIssue `json:"issues"`
}
// ScanIssue is a single actionable issue.
type ScanIssue struct {
Repo string `json:"repo"`
Number int `json:"number"`
Title string `json:"title"`
Labels []string `json:"labels"`
Assignee string `json:"assignee,omitempty"`
URL string `json:"url"`
}
func (s *PrepSubsystem) scan(ctx context.Context, _ *mcp.CallToolRequest, input ScanInput) (*mcp.CallToolResult, ScanOutput, error) {
if s.forgeToken == "" {
return nil, ScanOutput{}, fmt.Errorf("no Forge token configured")
}
if input.Org == "" {
input.Org = "core"
}
if input.Limit == 0 {
input.Limit = 20
}
if len(input.Labels) == 0 {
input.Labels = []string{"agentic", "help-wanted", "bug"}
}
var allIssues []ScanIssue
// Get repos for the org
repos, err := s.listOrgRepos(ctx, input.Org)
if err != nil {
return nil, ScanOutput{}, err
}
for _, repo := range repos {
for _, label := range input.Labels {
issues, err := s.listRepoIssues(ctx, input.Org, repo, label)
if err != nil {
continue
}
allIssues = append(allIssues, issues...)
if len(allIssues) >= input.Limit {
break
}
}
if len(allIssues) >= input.Limit {
break
}
}
// Deduplicate by repo+number
seen := make(map[string]bool)
var unique []ScanIssue
for _, issue := range allIssues {
key := fmt.Sprintf("%s#%d", issue.Repo, issue.Number)
if !seen[key] {
seen[key] = true
unique = append(unique, issue)
}
}
if len(unique) > input.Limit {
unique = unique[:input.Limit]
}
return nil, ScanOutput{
Success: true,
Count: len(unique),
Issues: unique,
}, nil
}
func (s *PrepSubsystem) listOrgRepos(ctx context.Context, org string) ([]string, error) {
url := fmt.Sprintf("%s/api/v1/orgs/%s/repos?limit=50", s.forgeURL, org)
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
req.Header.Set("Authorization", "token "+s.forgeToken)
resp, err := s.client.Do(req)
if err != nil || resp.StatusCode != 200 {
return nil, fmt.Errorf("failed to list repos: %v", err)
}
defer resp.Body.Close()
var repos []struct {
Name string `json:"name"`
}
json.NewDecoder(resp.Body).Decode(&repos)
var names []string
for _, r := range repos {
names = append(names, r.Name)
}
return names, nil
}
func (s *PrepSubsystem) listRepoIssues(ctx context.Context, org, repo, label string) ([]ScanIssue, error) {
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues?state=open&labels=%s&limit=10&type=issues",
s.forgeURL, org, repo, label)
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
req.Header.Set("Authorization", "token "+s.forgeToken)
resp, err := s.client.Do(req)
if err != nil || resp.StatusCode != 200 {
return nil, fmt.Errorf("failed to list issues for %s: %v", repo, err)
}
defer resp.Body.Close()
var issues []struct {
Number int `json:"number"`
Title string `json:"title"`
Labels []struct {
Name string `json:"name"`
} `json:"labels"`
Assignee *struct {
Login string `json:"login"`
} `json:"assignee"`
HTMLURL string `json:"html_url"`
}
json.NewDecoder(resp.Body).Decode(&issues)
var result []ScanIssue
for _, issue := range issues {
var labels []string
for _, l := range issue.Labels {
labels = append(labels, l.Name)
}
assignee := ""
if issue.Assignee != nil {
assignee = issue.Assignee.Login
}
result = append(result, ScanIssue{
Repo: repo,
Number: issue.Number,
Title: issue.Title,
Labels: labels,
Assignee: assignee,
URL: strings.Replace(issue.HTMLURL, "https://forge.lthn.ai", s.forgeURL, 1),
})
}
return result, nil
}