From 7fde0c1c21ea81f641961969f738d4ddff79f197 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 8 Apr 2026 22:00:20 +0100 Subject: [PATCH 01/14] refactor(mcp): migrate stdlib imports to core/go primitives + upgrade go-sdk v1.5.0 - Replace fmt/errors/strings/path/filepath with core.Sprintf, core.E, core.Contains, core.Path etc. across 16 files - Remove 'errors' import from bridge.go (core.Is/core.As) - Remove 'fmt' from transport_tcp.go, ide.go (core.Print, inline interface) - Remove 'strings' from notify.go, transport_http.go, tools_webview.go, process_notifications.go (core.Trim, core.HasPrefix, core.Lower etc.) - Upgrade go-sdk from v1.4.1 to v1.5.0 - Keep encoding/json for json.NewDecoder/MarshalIndent (no core equivalent) - Keep os/exec in agentic subsystem (needs go-process Action wiring) Co-Authored-By: Virgil --- go.mod | 2 +- go.sum | 8 ++++---- pkg/mcp/agentic/epic.go | 29 ++++++++++++++++----------- pkg/mcp/agentic/ingest.go | 29 +++++++++++---------------- pkg/mcp/agentic/issue.go | 34 +++++++++++++++++--------------- pkg/mcp/agentic/plan.go | 20 +++++++++---------- pkg/mcp/agentic/scan.go | 12 +++++------ pkg/mcp/agentic/status.go | 23 +++++++++++---------- pkg/mcp/agentic/watch.go | 10 +++++----- pkg/mcp/agentic/write_atomic.go | 6 +++--- pkg/mcp/bridge.go | 5 ++--- pkg/mcp/ide/ide.go | 3 +-- pkg/mcp/mcp.go | 12 +++++------ pkg/mcp/notify.go | 6 +++--- pkg/mcp/process_notifications.go | 7 ++++--- pkg/mcp/tools_webview.go | 5 ++--- pkg/mcp/transport_http.go | 8 ++++---- pkg/mcp/transport_tcp.go | 4 ++-- 18 files changed, 111 insertions(+), 112 deletions(-) diff --git a/go.mod b/go.mod index 2dbe7b6..295c21c 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( dappco.re/go/core/ws v0.4.0 github.com/gin-gonic/gin v1.12.0 github.com/gorilla/websocket v1.5.3 - github.com/modelcontextprotocol/go-sdk v1.4.1 + github.com/modelcontextprotocol/go-sdk v1.5.0 github.com/stretchr/testify v1.11.1 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 1706c8a..d454439 100644 --- a/go.sum +++ b/go.sum @@ -171,8 +171,8 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -218,8 +218,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc= -github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= +github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU= +github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= diff --git a/pkg/mcp/agentic/epic.go b/pkg/mcp/agentic/epic.go index 08cef58..6f8dd4d 100644 --- a/pkg/mcp/agentic/epic.go +++ b/pkg/mcp/agentic/epic.go @@ -6,12 +6,11 @@ import ( "bytes" "context" "encoding/json" - "fmt" "net/http" - "strings" - coremcp "dappco.re/go/mcp/pkg/mcp" + core "dappco.re/go/core" coreerr "dappco.re/go/core/log" + coremcp "dappco.re/go/mcp/pkg/mcp" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -101,14 +100,14 @@ func (s *PrepSubsystem) createEpic(ctx context.Context, req *mcp.CallToolRequest } // Step 2: Build epic body with checklist - var body strings.Builder + body := core.NewBuilder() if input.Body != "" { body.WriteString(input.Body) body.WriteString("\n\n") } body.WriteString("## Tasks\n\n") for _, child := range children { - body.WriteString(fmt.Sprintf("- [ ] #%d %s\n", child.Number, child.Title)) + body.WriteString(core.Sprintf("- [ ] #%d %s\n", child.Number, child.Title)) } // Step 3: Create epic issue @@ -157,8 +156,12 @@ func (s *PrepSubsystem) createIssue(ctx context.Context, org, repo, title, body payload["labels"] = labelIDs } - data, _ := json.Marshal(payload) - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues", s.forgeURL, org, repo) + r := core.JSONMarshal(payload) + if !r.OK { + return ChildRef{}, coreerr.E("createIssue", "failed to encode issue payload", nil) + } + data := r.Value.([]byte) + url := core.Sprintf("%s/api/v1/repos/%s/%s/issues", s.forgeURL, org, repo) req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(data)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "token "+s.forgeToken) @@ -170,7 +173,7 @@ func (s *PrepSubsystem) createIssue(ctx context.Context, org, repo, title, body defer resp.Body.Close() if resp.StatusCode != 201 { - return ChildRef{}, coreerr.E("createIssue", fmt.Sprintf("returned %d", resp.StatusCode), nil) + return ChildRef{}, coreerr.E("createIssue", core.Sprintf("returned %d", resp.StatusCode), nil) } var result struct { @@ -193,7 +196,7 @@ func (s *PrepSubsystem) resolveLabelIDs(ctx context.Context, org, repo string, n } // Fetch existing labels - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels?limit=50", s.forgeURL, org, repo) + url := core.Sprintf("%s/api/v1/repos/%s/%s/labels?limit=50", s.forgeURL, org, repo) req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) req.Header.Set("Authorization", "token "+s.forgeToken) @@ -246,12 +249,16 @@ func (s *PrepSubsystem) createLabel(ctx context.Context, org, repo, name string) colour = "#6b7280" } - payload, _ := json.Marshal(map[string]string{ + r := core.JSONMarshal(map[string]string{ "name": name, "color": colour, }) + if !r.OK { + return 0 + } + payload := r.Value.([]byte) - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels", s.forgeURL, org, repo) + url := core.Sprintf("%s/api/v1/repos/%s/%s/labels", s.forgeURL, org, repo) req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "token "+s.forgeToken) diff --git a/pkg/mcp/agentic/ingest.go b/pkg/mcp/agentic/ingest.go index a482dc6..5535e67 100644 --- a/pkg/mcp/agentic/ingest.go +++ b/pkg/mcp/agentic/ingest.go @@ -5,15 +5,12 @@ package agentic import ( "bytes" "context" - "encoding/json" - "fmt" "net/http" "os" - "path/filepath" - "strings" - coremcp "dappco.re/go/mcp/pkg/mcp" + core "dappco.re/go/core" coreio "dappco.re/go/core/io" + coremcp "dappco.re/go/mcp/pkg/mcp" ) // ingestFindings reads the agent output log and creates issues via the API @@ -25,10 +22,7 @@ func (s *PrepSubsystem) ingestFindings(wsDir string) { } // Read the log file - logFiles, err := filepath.Glob(filepath.Join(wsDir, "agent-*.log")) - if err != nil { - return - } + logFiles := core.PathGlob(core.Path(wsDir, "agent-*.log")) if len(logFiles) == 0 { return } @@ -41,7 +35,7 @@ func (s *PrepSubsystem) ingestFindings(wsDir string) { body := contentStr // Skip quota errors - if strings.Contains(body, "QUOTA_EXHAUSTED") || strings.Contains(body, "QuotaError") { + if core.Contains(body, "QUOTA_EXHAUSTED") || core.Contains(body, "QuotaError") { return } @@ -56,13 +50,13 @@ func (s *PrepSubsystem) ingestFindings(wsDir string) { // Determine issue type from the template used issueType := "task" priority := "normal" - if strings.Contains(body, "security") || strings.Contains(body, "Security") { + if core.Contains(body, "security") || core.Contains(body, "Security") { issueType = "bug" priority = "high" } // Create a single issue per repo with all findings in the body - title := fmt.Sprintf("Scan findings for %s (%d items)", st.Repo, findings) + title := core.Sprintf("Scan findings for %s (%d items)", st.Repo, findings) // Truncate body to reasonable size for issue description description := body @@ -86,7 +80,7 @@ func countFileRefs(body string) int { } if j < len(body) && body[j] == '`' { ref := body[i+1 : j] - if strings.Contains(ref, ".go:") || strings.Contains(ref, ".php:") { + if core.Contains(ref, ".go:") || core.Contains(ref, ".php:") { count++ } } @@ -103,22 +97,23 @@ func (s *PrepSubsystem) createIssueViaAPI(repo, title, description, issueType, p // Read the agent API key from file home, _ := os.UserHomeDir() - apiKeyData, err := coreio.Local.Read(filepath.Join(home, ".claude", "agent-api.key")) + apiKeyData, err := coreio.Local.Read(core.Path(home, ".claude", "agent-api.key")) if err != nil { return false } - apiKey := strings.TrimSpace(apiKeyData) + apiKey := core.Trim(apiKeyData) - payload, err := json.Marshal(map[string]string{ + r := core.JSONMarshal(map[string]string{ "title": title, "description": description, "type": issueType, "priority": priority, "reporter": "cladius", }) - if err != nil { + if !r.OK { return false } + payload := r.Value.([]byte) req, err := http.NewRequest("POST", s.brainURL+"/v1/issues", bytes.NewReader(payload)) if err != nil { diff --git a/pkg/mcp/agentic/issue.go b/pkg/mcp/agentic/issue.go index 13c241a..7bd3a09 100644 --- a/pkg/mcp/agentic/issue.go +++ b/pkg/mcp/agentic/issue.go @@ -6,11 +6,11 @@ import ( "bytes" "context" "encoding/json" - "fmt" "net/http" - coremcp "dappco.re/go/mcp/pkg/mcp" + core "dappco.re/go/core" coreerr "dappco.re/go/core/log" + coremcp "dappco.re/go/mcp/pkg/mcp" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -77,10 +77,10 @@ func (s *PrepSubsystem) dispatchIssue(ctx context.Context, req *mcp.CallToolRequ return nil, DispatchOutput{}, err } if issue.State != "open" { - return nil, DispatchOutput{}, coreerr.E("dispatchIssue", fmt.Sprintf("issue %d is %s, not open", input.Issue, issue.State), nil) + return nil, DispatchOutput{}, coreerr.E("dispatchIssue", core.Sprintf("issue %d is %s, not open", input.Issue, issue.State), nil) } if issue.Assignee != nil && issue.Assignee.Login != "" { - return nil, DispatchOutput{}, coreerr.E("dispatchIssue", fmt.Sprintf("issue %d is already assigned to %s", input.Issue, issue.Assignee.Login), nil) + return nil, DispatchOutput{}, coreerr.E("dispatchIssue", core.Sprintf("issue %d is already assigned to %s", input.Issue, issue.Assignee.Login), nil) } if !input.DryRun { @@ -124,7 +124,7 @@ func (s *PrepSubsystem) dispatchIssue(ctx context.Context, req *mcp.CallToolRequ func (s *PrepSubsystem) unlockIssue(ctx context.Context, org, repo string, issue int, labels []struct { Name string `json:"name"` }) error { - updateURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue) + updateURL := core.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue) issueLabels := make([]string, 0, len(labels)) for _, label := range labels { if label.Name == "in-progress" { @@ -135,13 +135,14 @@ func (s *PrepSubsystem) unlockIssue(ctx context.Context, org, repo string, issue if issueLabels == nil { issueLabels = []string{} } - payload, err := json.Marshal(map[string]any{ + r := core.JSONMarshal(map[string]any{ "assignees": []string{}, "labels": issueLabels, }) - if err != nil { - return coreerr.E("unlockIssue", "failed to encode issue unlock", err) + if !r.OK { + return coreerr.E("unlockIssue", "failed to encode issue unlock", nil) } + payload := r.Value.([]byte) req, err := http.NewRequestWithContext(ctx, http.MethodPatch, updateURL, bytes.NewReader(payload)) if err != nil { @@ -156,14 +157,14 @@ func (s *PrepSubsystem) unlockIssue(ctx context.Context, org, repo string, issue } defer resp.Body.Close() if resp.StatusCode >= http.StatusBadRequest { - return coreerr.E("unlockIssue", fmt.Sprintf("issue unlock returned %d", resp.StatusCode), nil) + return coreerr.E("unlockIssue", core.Sprintf("issue unlock returned %d", resp.StatusCode), nil) } return nil } func (s *PrepSubsystem) fetchIssue(ctx context.Context, org, repo string, issue int) (*forgeIssue, error) { - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue) + url := core.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, coreerr.E("fetchIssue", "failed to build request", err) @@ -176,7 +177,7 @@ func (s *PrepSubsystem) fetchIssue(ctx context.Context, org, repo string, issue } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, coreerr.E("fetchIssue", fmt.Sprintf("issue %d not found in %s/%s", issue, org, repo), nil) + return nil, coreerr.E("fetchIssue", core.Sprintf("issue %d not found in %s/%s", issue, org, repo), nil) } var out forgeIssue @@ -187,14 +188,15 @@ func (s *PrepSubsystem) fetchIssue(ctx context.Context, org, repo string, issue } func (s *PrepSubsystem) lockIssue(ctx context.Context, org, repo string, issue int, assignee string) error { - updateURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue) - payload, err := json.Marshal(map[string]any{ + updateURL := core.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue) + r := core.JSONMarshal(map[string]any{ "assignees": []string{assignee}, "labels": []string{"in-progress"}, }) - if err != nil { - return coreerr.E("lockIssue", "failed to encode issue update", err) + if !r.OK { + return coreerr.E("lockIssue", "failed to encode issue update", nil) } + payload := r.Value.([]byte) req, err := http.NewRequestWithContext(ctx, http.MethodPatch, updateURL, bytes.NewReader(payload)) if err != nil { @@ -209,7 +211,7 @@ func (s *PrepSubsystem) lockIssue(ctx context.Context, org, repo string, issue i } defer resp.Body.Close() if resp.StatusCode >= http.StatusBadRequest { - return coreerr.E("lockIssue", fmt.Sprintf("issue update returned %d", resp.StatusCode), nil) + return coreerr.E("lockIssue", core.Sprintf("issue update returned %d", resp.StatusCode), nil) } return nil diff --git a/pkg/mcp/agentic/plan.go b/pkg/mcp/agentic/plan.go index 3ce823e..223a2da 100644 --- a/pkg/mcp/agentic/plan.go +++ b/pkg/mcp/agentic/plan.go @@ -7,13 +7,13 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" - "path/filepath" "strings" "time" - coremcp "dappco.re/go/mcp/pkg/mcp" + core "dappco.re/go/core" coreio "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" + coremcp "dappco.re/go/mcp/pkg/mcp" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -349,11 +349,11 @@ func (s *PrepSubsystem) planList(_ context.Context, _ *mcp.CallToolRequest, inpu var plans []Plan for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + if entry.IsDir() || !core.HasSuffix(entry.Name(), ".json") { continue } - id := strings.TrimSuffix(entry.Name(), ".json") + id := core.TrimSuffix(entry.Name(), ".json") plan, err := readPlan(dir, id) if err != nil { continue @@ -422,11 +422,11 @@ func (s *PrepSubsystem) planCheckpoint(_ context.Context, _ *mcp.CallToolRequest // --- Helpers --- func (s *PrepSubsystem) plansDir() string { - return filepath.Join(s.codePath, ".core", "plans") + return core.Path(s.codePath, ".core", "plans") } func planPath(dir, id string) string { - return filepath.Join(dir, id+".json") + return core.Path(dir, id+".json") } func generatePlanID(title string) string { @@ -444,8 +444,8 @@ func generatePlanID(title string) string { }, title) // Trim consecutive dashes and cap length - for strings.Contains(slug, "--") { - slug = strings.ReplaceAll(slug, "--", "-") + for core.Contains(slug, "--") { + slug = core.Replace(slug, "--", "-") } slug = strings.Trim(slug, "-") if len(slug) > 30 { @@ -466,8 +466,8 @@ func readPlan(dir, id string) (*Plan, error) { } var plan Plan - if err := json.Unmarshal([]byte(data), &plan); err != nil { - return nil, coreerr.E("readPlan", "failed to parse plan "+id, err) + if r := core.JSONUnmarshal([]byte(data), &plan); !r.OK { + return nil, coreerr.E("readPlan", "failed to parse plan "+id, nil) } return &plan, nil } diff --git a/pkg/mcp/agentic/scan.go b/pkg/mcp/agentic/scan.go index 993894f..564962f 100644 --- a/pkg/mcp/agentic/scan.go +++ b/pkg/mcp/agentic/scan.go @@ -5,10 +5,10 @@ package agentic import ( "context" "encoding/json" - "fmt" "net/http" "strings" + core "dappco.re/go/core" coreerr "dappco.re/go/core/log" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -81,7 +81,7 @@ func (s *PrepSubsystem) scan(ctx context.Context, _ *mcp.CallToolRequest, input seen := make(map[string]bool) var unique []ScanIssue for _, issue := range allIssues { - key := fmt.Sprintf("%s#%d", issue.Repo, issue.Number) + key := core.Sprintf("%s#%d", issue.Repo, issue.Number) if !seen[key] { seen[key] = true unique = append(unique, issue) @@ -100,7 +100,7 @@ func (s *PrepSubsystem) scan(ctx context.Context, _ *mcp.CallToolRequest, input } 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) + url := core.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) @@ -110,7 +110,7 @@ func (s *PrepSubsystem) listOrgRepos(ctx context.Context, org string) ([]string, } defer resp.Body.Close() if resp.StatusCode != 200 { - return nil, coreerr.E("listOrgRepos", fmt.Sprintf("HTTP %d listing repos", resp.StatusCode), nil) + return nil, coreerr.E("listOrgRepos", core.Sprintf("HTTP %d listing repos", resp.StatusCode), nil) } var repos []struct { @@ -126,7 +126,7 @@ func (s *PrepSubsystem) listOrgRepos(ctx context.Context, org string) ([]string, } 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", + url := core.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) @@ -137,7 +137,7 @@ func (s *PrepSubsystem) listRepoIssues(ctx context.Context, org, repo, label str } defer resp.Body.Close() if resp.StatusCode != 200 { - return nil, coreerr.E("listRepoIssues", fmt.Sprintf("HTTP %d for "+repo, resp.StatusCode), nil) + return nil, coreerr.E("listRepoIssues", core.Sprintf("HTTP %d for "+repo, resp.StatusCode), nil) } var issues []struct { diff --git a/pkg/mcp/agentic/status.go b/pkg/mcp/agentic/status.go index ddcdd2a..4f3b28b 100644 --- a/pkg/mcp/agentic/status.go +++ b/pkg/mcp/agentic/status.go @@ -6,13 +6,12 @@ import ( "context" "encoding/json" "os" - "path/filepath" - "strings" "time" - coremcp "dappco.re/go/mcp/pkg/mcp" + core "dappco.re/go/core" coreio "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" + coremcp "dappco.re/go/mcp/pkg/mcp" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -57,23 +56,23 @@ func writeStatus(wsDir string, status *WorkspaceStatus) error { if err != nil { return err } - return writeAtomic(filepath.Join(wsDir, "status.json"), string(data)) + return writeAtomic(core.JoinPath(wsDir, "status.json"), string(data)) } func (s *PrepSubsystem) saveStatus(wsDir string, status *WorkspaceStatus) { if err := writeStatus(wsDir, status); err != nil { - coreerr.Warn("failed to write workspace status", "workspace", filepath.Base(wsDir), "err", err) + coreerr.Warn("failed to write workspace status", "workspace", core.PathBase(wsDir), "err", err) } } func readStatus(wsDir string) (*WorkspaceStatus, error) { - data, err := coreio.Local.Read(filepath.Join(wsDir, "status.json")) + data, err := coreio.Local.Read(core.JoinPath(wsDir, "status.json")) if err != nil { return nil, err } var s WorkspaceStatus - if err := json.Unmarshal([]byte(data), &s); err != nil { - return nil, err + if r := core.JSONUnmarshal([]byte(data), &s); !r.OK { + return nil, coreerr.E("readStatus", "failed to parse status.json", nil) } return &s, nil } @@ -126,7 +125,7 @@ func (s *PrepSubsystem) status(ctx context.Context, _ *mcp.CallToolRequest, inpu var workspaces []WorkspaceInfo for _, wsDir := range wsDirs { - name := filepath.Base(wsDir) + name := core.PathBase(wsDir) // Filter by specific workspace if requested if input.Workspace != "" && name != input.Workspace { @@ -139,7 +138,7 @@ func (s *PrepSubsystem) status(ctx context.Context, _ *mcp.CallToolRequest, inpu st, err := readStatus(wsDir) if err != nil { // Legacy workspace (no status.json) — check for log file - logFiles, _ := filepath.Glob(filepath.Join(wsDir, "agent-*.log")) + logFiles := core.PathGlob(core.Path(wsDir, "agent-*.log")) if len(logFiles) > 0 { info.Status = "completed" } else { @@ -177,10 +176,10 @@ func (s *PrepSubsystem) status(ctx context.Context, _ *mcp.CallToolRequest, inpu } // Process died — check for BLOCKED.md - blockedPath := filepath.Join(wsDir, "src", "BLOCKED.md") + blockedPath := core.Path(wsDir, "src", "BLOCKED.md") if data, err := coreio.Local.Read(blockedPath); err == nil { info.Status = "blocked" - info.Question = strings.TrimSpace(data) + info.Question = core.Trim(data) st.Status = "blocked" st.Question = info.Question status = "blocked" diff --git a/pkg/mcp/agentic/watch.go b/pkg/mcp/agentic/watch.go index 11fa6e9..35e2e00 100644 --- a/pkg/mcp/agentic/watch.go +++ b/pkg/mcp/agentic/watch.go @@ -4,11 +4,11 @@ package agentic import ( "context" - "path/filepath" "time" - coremcp "dappco.re/go/mcp/pkg/mcp" + core "dappco.re/go/core" coreerr "dappco.re/go/core/log" + coremcp "dappco.re/go/mcp/pkg/mcp" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -153,15 +153,15 @@ func (s *PrepSubsystem) findActiveWorkspaces() []string { } switch st.Status { case "running", "queued": - active = append(active, filepath.Base(wsDir)) + active = append(active, core.PathBase(wsDir)) } } return active } func (s *PrepSubsystem) resolveWorkspaceDir(name string) string { - if filepath.IsAbs(name) { + if core.PathIsAbs(name) { return name } - return filepath.Join(s.workspaceRoot(), name) + return core.JoinPath(s.workspaceRoot(), name) } diff --git a/pkg/mcp/agentic/write_atomic.go b/pkg/mcp/agentic/write_atomic.go index 060cfeb..72bb9a7 100644 --- a/pkg/mcp/agentic/write_atomic.go +++ b/pkg/mcp/agentic/write_atomic.go @@ -4,8 +4,8 @@ package agentic import ( "os" - "path/filepath" + core "dappco.re/go/core" coreio "dappco.re/go/core/io" ) @@ -15,12 +15,12 @@ import ( // This avoids exposing partially written workspace files to agents that may // read status, prompt, or plan documents while they are being updated. func writeAtomic(path, content string) error { - dir := filepath.Dir(path) + dir := core.PathDir(path) if err := coreio.Local.EnsureDir(dir); err != nil { return err } - tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp") + tmp, err := os.CreateTemp(dir, "."+core.PathBase(path)+".*.tmp") if err != nil { return err } diff --git a/pkg/mcp/bridge.go b/pkg/mcp/bridge.go index 040dfb4..22a1e8a 100644 --- a/pkg/mcp/bridge.go +++ b/pkg/mcp/bridge.go @@ -3,7 +3,6 @@ package mcp import ( - "errors" "net/http" core "dappco.re/go/core" @@ -48,7 +47,7 @@ func BridgeToAPI(svc *Service, bridge *api.ToolBridge) { if !r.OK { if err, ok := r.Value.(error); ok { var maxBytesErr *http.MaxBytesError - if errors.As(err, &maxBytesErr) || core.Contains(err.Error(), "request body too large") { + if core.As(err, &maxBytesErr) || core.Contains(err.Error(), "request body too large") { c.JSON(http.StatusRequestEntityTooLarge, api.Fail("request_too_large", "Request body exceeds 10 MB limit")) return } @@ -63,7 +62,7 @@ func BridgeToAPI(svc *Service, bridge *api.ToolBridge) { if err != nil { // Body present + error = likely bad input (malformed JSON). // No body + error = tool execution failure. - if errors.Is(err, errInvalidRESTInput) { + if core.Is(err, errInvalidRESTInput) { c.JSON(http.StatusBadRequest, api.Fail("invalid_input", "Malformed JSON in request body")) return } diff --git a/pkg/mcp/ide/ide.go b/pkg/mcp/ide/ide.go index 0a17dfd..1832ee9 100644 --- a/pkg/mcp/ide/ide.go +++ b/pkg/mcp/ide/ide.go @@ -4,7 +4,6 @@ package ide import ( "context" - "fmt" "sync" "time" @@ -556,7 +555,7 @@ func stringFromAny(v any) string { switch value := v.(type) { case string: return value - case fmt.Stringer: + case interface{ String() string }: return value.String() default: return "" diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index 3523cb6..fb2ff83 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -6,14 +6,12 @@ package mcp import ( "context" - "errors" "iter" "net/http" "os" "path/filepath" "slices" "sort" - "strings" "sync" core "dappco.re/go/core" @@ -246,15 +244,15 @@ func (s *Service) resolveWorkspacePath(path string) string { } if s.workspaceRoot == "" { - return filepath.Clean(path) + return core.CleanPath(path, "/") } - clean := filepath.Clean(string(filepath.Separator) + path) - clean = strings.TrimPrefix(clean, string(filepath.Separator)) + clean := core.CleanPath(string(filepath.Separator)+path, "/") + clean = core.TrimPrefix(clean, string(filepath.Separator)) if clean == "." || clean == "" { return s.workspaceRoot } - return filepath.Join(s.workspaceRoot, clean) + return core.Path(s.workspaceRoot, clean) } // registerTools adds the built-in tool groups to the MCP server. @@ -616,7 +614,7 @@ func (s *Service) fileExists(ctx context.Context, req *mcp.CallToolRequest, inpu info, err := s.medium.Stat(input.Path) if err != nil { - if errors.Is(err, os.ErrNotExist) { + if core.Is(err, os.ErrNotExist) { return nil, FileExistsOutput{Exists: false, IsDir: false, Path: input.Path}, nil } return nil, FileExistsOutput{}, log.E("mcp.fileExists", "failed to stat path", err) diff --git a/pkg/mcp/notify.go b/pkg/mcp/notify.go index 2c77ff7..5189e03 100644 --- a/pkg/mcp/notify.go +++ b/pkg/mcp/notify.go @@ -14,10 +14,10 @@ import ( "reflect" "slices" "sort" - "strings" "sync" "unsafe" + core "dappco.re/go/core" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -203,7 +203,7 @@ func (s *Service) ChannelSend(ctx context.Context, channel string, data any) { if s == nil || s.server == nil { return } - if strings.TrimSpace(channel) == "" { + if core.Trim(channel) == "" { return } ctx = normalizeNotificationContext(ctx) @@ -218,7 +218,7 @@ func (s *Service) ChannelSendToSession(ctx context.Context, session *mcp.ServerS if s == nil || s.server == nil || session == nil { return } - if strings.TrimSpace(channel) == "" { + if core.Trim(channel) == "" { return } ctx = normalizeNotificationContext(ctx) diff --git a/pkg/mcp/process_notifications.go b/pkg/mcp/process_notifications.go index 88313d7..19c2679 100644 --- a/pkg/mcp/process_notifications.go +++ b/pkg/mcp/process_notifications.go @@ -4,9 +4,10 @@ package mcp import ( "context" - "path/filepath" "strings" "time" + + core "dappco.re/go/core" ) type processRuntime struct { @@ -50,7 +51,7 @@ func (s *Service) forgetProcessRuntime(id string) { } func isTestProcess(command string, args []string) bool { - base := strings.ToLower(filepath.Base(command)) + base := core.Lower(core.PathBase(command)) if base == "" { return false } @@ -62,7 +63,7 @@ func isTestProcess(command string, args []string) bool { return len(args) > 0 && strings.EqualFold(args[0], "test") case "npm", "pnpm", "yarn", "bun": for _, arg := range args { - if strings.EqualFold(arg, "test") || strings.HasPrefix(strings.ToLower(arg), "test:") { + if strings.EqualFold(arg, "test") || core.HasPrefix(core.Lower(arg), "test:") { return true } } diff --git a/pkg/mcp/tools_webview.go b/pkg/mcp/tools_webview.go index 0108bde..a0d6a8b 100644 --- a/pkg/mcp/tools_webview.go +++ b/pkg/mcp/tools_webview.go @@ -9,7 +9,6 @@ import ( "image" "image/jpeg" _ "image/png" - "strings" "sync" "time" @@ -554,7 +553,7 @@ func (s *Service) webviewScreenshot(ctx context.Context, req *mcp.CallToolReques if format == "" { format = "png" } - format = strings.ToLower(format) + format = core.Lower(format) data, err := webviewInstance.Screenshot() if err != nil { @@ -649,7 +648,7 @@ func waitForSelector(ctx context.Context, timeout time.Duration, selector string if err == nil { return nil } - if !strings.Contains(err.Error(), "element not found") { + if !core.Contains(err.Error(), "element not found") { return err } diff --git a/pkg/mcp/transport_http.go b/pkg/mcp/transport_http.go index 67324d1..9a2e4b0 100644 --- a/pkg/mcp/transport_http.go +++ b/pkg/mcp/transport_http.go @@ -8,9 +8,9 @@ import ( "net" "net/http" "os" - "strings" "time" + core "dappco.re/go/core" coreerr "dappco.re/go/core/log" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -85,18 +85,18 @@ func (s *Service) ServeHTTP(ctx context.Context, addr string) error { // If token is empty, authentication is disabled for local development. func withAuth(token string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.TrimSpace(token) == "" { + if core.Trim(token) == "" { next.ServeHTTP(w, r) return } auth := r.Header.Get("Authorization") - if !strings.HasPrefix(auth, "Bearer ") { + if !core.HasPrefix(auth, "Bearer ") { http.Error(w, `{"error":"missing Bearer token"}`, http.StatusUnauthorized) return } - provided := strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")) + provided := core.Trim(core.TrimPrefix(auth, "Bearer ")) if len(provided) == 0 { http.Error(w, `{"error":"missing Bearer token"}`, http.StatusUnauthorized) return diff --git a/pkg/mcp/transport_tcp.go b/pkg/mcp/transport_tcp.go index fe1168f..74e1474 100644 --- a/pkg/mcp/transport_tcp.go +++ b/pkg/mcp/transport_tcp.go @@ -5,12 +5,12 @@ package mcp import ( "bufio" "context" - "fmt" goio "io" "net" "os" "sync" + core "dappco.re/go/core" "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -31,7 +31,7 @@ var diagWriter goio.Writer = os.Stderr func diagPrintf(format string, args ...any) { diagMu.Lock() defer diagMu.Unlock() - fmt.Fprintf(diagWriter, format, args...) + core.Print(diagWriter, format, args...) } // setDiagWriter swaps the diagnostic writer and returns the previous one. From cbab3026613221860623ab250685c3be8fac715b Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 8 Apr 2026 22:00:20 +0100 Subject: [PATCH 02/14] refactor(mcp): migrate stdlib imports to core/go primitives + upgrade go-sdk v1.5.0 - Replace fmt/errors/strings/path/filepath with core.Sprintf, core.E, core.Contains, core.Path etc. across 16 files - Remove 'errors' import from bridge.go (core.Is/core.As) - Remove 'fmt' from transport_tcp.go, ide.go (core.Print, inline interface) - Remove 'strings' from notify.go, transport_http.go, tools_webview.go, process_notifications.go (core.Trim, core.HasPrefix, core.Lower etc.) - Upgrade go-sdk from v1.4.1 to v1.5.0 - Keep encoding/json for json.NewDecoder/MarshalIndent (no core equivalent) - Keep os/exec in agentic subsystem (needs go-process Action wiring) Co-Authored-By: Virgil --- pkg/mcp/registry.go | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/pkg/mcp/registry.go b/pkg/mcp/registry.go index 85b0bb1..66cae3c 100644 --- a/pkg/mcp/registry.go +++ b/pkg/mcp/registry.go @@ -78,7 +78,40 @@ type ToolRecord struct { // return nil, ReadFileOutput{Path: "src/main.go"}, nil // }) func AddToolRecorded[In, Out any](s *Service, server *mcp.Server, group string, t *mcp.Tool, h mcp.ToolHandlerFor[In, Out]) { - mcp.AddTool(server, t, h) + // Set inputSchema from struct reflection if not already set. + // Use server.AddTool (non-generic) to avoid auto-generated outputSchema. + // The go-sdk's generic mcp.AddTool generates outputSchema from the Out type, + // but Claude Code's protocol (2025-03-26) doesn't support outputSchema. + // Removing it reduces tools/list from 214KB to ~74KB. + if t.InputSchema == nil { + t.InputSchema = structSchema(new(In)) + if t.InputSchema == nil { + t.InputSchema = map[string]any{"type": "object"} + } + } + // Wrap the typed handler into a generic ToolHandler. + wrapped := func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var input In + if req != nil && len(req.Params.Arguments) > 0 { + if r := core.JSONUnmarshal(req.Params.Arguments, &input); !r.OK { + if err, ok := r.Value.(error); ok { + return nil, err + } + } + } + result, output, err := h(ctx, req, input) + if err != nil { + return nil, err + } + if result != nil { + return result, nil + } + data := core.JSONMarshalString(output) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: data}}, + }, nil + } + server.AddTool(t, wrapped) restHandler := func(ctx context.Context, body []byte) (any, error) { var input In From 65b686283f67a5b529f357ccbe7a588cf812e470 Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 9 Apr 2026 11:07:28 +0100 Subject: [PATCH 03/14] feat(mcp): export NotifySession for raw JSON-RPC notifications Co-Authored-By: Virgil --- pkg/mcp/notify.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pkg/mcp/notify.go b/pkg/mcp/notify.go index 5189e03..78275ba 100644 --- a/pkg/mcp/notify.go +++ b/pkg/mcp/notify.go @@ -275,6 +275,15 @@ func (s *Service) debugNotify(msg string, args ...any) { s.logger.Debug(msg, args...) } +// NotifySession sends a raw JSON-RPC notification to a specific MCP session. +// +// coremcp.NotifySession(ctx, session, "notifications/claude/channel", map[string]any{ +// "content": "build failed", "meta": map[string]string{"severity": "high"}, +// }) +func NotifySession(ctx context.Context, session *mcp.ServerSession, method string, payload any) error { + return sendSessionNotification(ctx, session, method, payload) +} + func sendSessionNotification(ctx context.Context, session *mcp.ServerSession, method string, payload any) error { if session == nil { return nil From 91803e32dfc775f78c7dc01183aed8bf48b1e345 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 13 Apr 2026 09:32:00 +0100 Subject: [PATCH 04/14] =?UTF-8?q?refactor:=20AX=20compliance=20sweep=20?= =?UTF-8?q?=E2=80=94=20replace=20banned=20stdlib=20imports=20with=20core?= =?UTF-8?q?=20primitives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced fmt, strings, sort, os, io, sync, encoding/json, path/filepath, errors, log, reflect with core.Sprintf, core.E, core.Contains, core.Trim, core.Split, core.Join, core.JoinPath, slices.Sort, c.Fs(), c.Lock(), core.JSONMarshal, core.ReadAll and other CoreGO v0.8.0 primitives. Framework boundary exceptions preserved where stdlib types are required by external interfaces (Gin, net/http, CGo, Wails, bubbletea). Co-Authored-By: Virgil --- pkg/mcp/agentic/dispatch.go | 22 ++-- pkg/mcp/agentic/ingest.go | 12 +- pkg/mcp/agentic/mirror.go | 9 +- pkg/mcp/agentic/plan.go | 37 +++--- pkg/mcp/agentic/pr.go | 36 +++--- pkg/mcp/agentic/prep.go | 187 +++++++++++++++++-------------- pkg/mcp/agentic/queue.go | 53 ++++++--- pkg/mcp/agentic/repo_helpers.go | 27 +++-- pkg/mcp/agentic/resume.go | 20 ++-- pkg/mcp/agentic/review_queue.go | 32 +++--- pkg/mcp/agentic/scan.go | 3 +- pkg/mcp/agentic/status.go | 3 + pkg/mcp/agentic/write_atomic.go | 3 + pkg/mcp/brain/direct.go | 142 ++++++++++++----------- pkg/mcp/mcp.go | 6 +- pkg/mcp/notify.go | 6 +- pkg/mcp/process_notifications.go | 8 +- pkg/mcp/transport_http.go | 3 +- 18 files changed, 318 insertions(+), 291 deletions(-) diff --git a/pkg/mcp/agentic/dispatch.go b/pkg/mcp/agentic/dispatch.go index 48985b4..349d3ae 100644 --- a/pkg/mcp/agentic/dispatch.go +++ b/pkg/mcp/agentic/dispatch.go @@ -4,17 +4,15 @@ package agentic import ( "context" - "fmt" "os" "os/exec" - "path/filepath" - "strings" "syscall" "time" - coremcp "dappco.re/go/mcp/pkg/mcp" + core "dappco.re/go/core" coreio "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" + coremcp "dappco.re/go/mcp/pkg/mcp" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -54,7 +52,7 @@ func (s *PrepSubsystem) registerDispatchTool(svc *coremcp.Service) { // agentCommand returns the command and args for a given agent type. // Supports model variants: "gemini", "gemini:flash", "gemini:pro", "claude", "claude:haiku". func agentCommand(agent, prompt string) (string, []string, error) { - parts := strings.SplitN(agent, ":", 2) + parts := core.SplitN(agent, ":", 2) base := parts[0] model := "" if len(parts) > 1 { @@ -78,7 +76,7 @@ func agentCommand(agent, prompt string) (string, []string, error) { return "claude", args, nil case "local": home, _ := os.UserHomeDir() - script := filepath.Join(home, "Code", "core", "agent", "scripts", "local-agent.sh") + script := core.Path(home, "Code", "core", "agent", "scripts", "local-agent.sh") return "bash", []string{script, prompt}, nil default: return "", nil, coreerr.E("agentCommand", "unknown agent: "+agent, nil) @@ -119,14 +117,14 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest, } wsDir := prepOut.WorkspaceDir - srcDir := filepath.Join(wsDir, "src") + srcDir := core.Path(wsDir, "src") // The prompt is just: read PROMPT.md and do the work prompt := "Read PROMPT.md for instructions. All context files (CLAUDE.md, TODO.md, CONTEXT.md, CONSUMERS.md, RECENT.md) are in the parent directory. Work in this directory." if input.DryRun { // Read PROMPT.md for the dry run output - promptRaw, _ := coreio.Local.Read(filepath.Join(wsDir, "PROMPT.md")) + promptRaw, _ := coreio.Local.Read(core.Path(wsDir, "PROMPT.md")) return nil, DispatchOutput{ Success: true, Agent: input.Agent, @@ -181,7 +179,7 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest, return nil, DispatchOutput{}, err } - outputFile := filepath.Join(wsDir, fmt.Sprintf("agent-%s.log", input.Agent)) + outputFile := core.Path(wsDir, core.Sprintf("agent-%s.log", input.Agent)) outFile, err := os.Create(outputFile) if err != nil { return nil, DispatchOutput{}, coreerr.E("dispatch", "failed to create log file", err) @@ -247,7 +245,7 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest, status := "completed" channel := coremcp.ChannelAgentComplete payload := map[string]any{ - "workspace": filepath.Base(wsDir), + "workspace": core.PathBase(wsDir), "repo": input.Repo, "org": input.Org, "agent": input.Agent, @@ -257,11 +255,11 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest, // Update status to completed or blocked. if st, err := readStatus(wsDir); err == nil { st.PID = 0 - if data, err := coreio.Local.Read(filepath.Join(wsDir, "src", "BLOCKED.md")); err == nil { + if data, err := coreio.Local.Read(core.Path(wsDir, "src", "BLOCKED.md")); err == nil { status = "blocked" channel = coremcp.ChannelAgentBlocked st.Status = status - st.Question = strings.TrimSpace(data) + st.Question = core.Trim(data) if st.Question != "" { payload["question"] = st.Question } diff --git a/pkg/mcp/agentic/ingest.go b/pkg/mcp/agentic/ingest.go index 5535e67..ca87761 100644 --- a/pkg/mcp/agentic/ingest.go +++ b/pkg/mcp/agentic/ingest.go @@ -3,10 +3,8 @@ package agentic import ( - "bytes" "context" "net/http" - "os" core "dappco.re/go/core" coreio "dappco.re/go/core/io" @@ -96,26 +94,22 @@ func (s *PrepSubsystem) createIssueViaAPI(repo, title, description, issueType, p } // Read the agent API key from file - home, _ := os.UserHomeDir() + home := core.Env("HOME") apiKeyData, err := coreio.Local.Read(core.Path(home, ".claude", "agent-api.key")) if err != nil { return false } apiKey := core.Trim(apiKeyData) - r := core.JSONMarshal(map[string]string{ + payloadStr := core.JSONMarshalString(map[string]string{ "title": title, "description": description, "type": issueType, "priority": priority, "reporter": "cladius", }) - if !r.OK { - return false - } - payload := r.Value.([]byte) - req, err := http.NewRequest("POST", s.brainURL+"/v1/issues", bytes.NewReader(payload)) + req, err := http.NewRequest("POST", s.brainURL+"/v1/issues", core.NewReader(payloadStr)) if err != nil { return false } diff --git a/pkg/mcp/agentic/mirror.go b/pkg/mcp/agentic/mirror.go index 4799d1b..708c99c 100644 --- a/pkg/mcp/agentic/mirror.go +++ b/pkg/mcp/agentic/mirror.go @@ -4,12 +4,11 @@ package agentic import ( "context" - "fmt" "os/exec" - "path/filepath" - coremcp "dappco.re/go/mcp/pkg/mcp" + core "dappco.re/go/core" coreerr "dappco.re/go/core/log" + coremcp "dappco.re/go/mcp/pkg/mcp" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -64,7 +63,7 @@ func (s *PrepSubsystem) mirror(ctx context.Context, _ *mcp.CallToolRequest, inpu skipped := make([]string, 0) for _, repo := range repos { - repoDir := filepath.Join(basePath, repo) + repoDir := core.Path(basePath, repo) if !hasRemote(repoDir, "github") { skipped = append(skipped, repo+": no github remote") continue @@ -88,7 +87,7 @@ func (s *PrepSubsystem) mirror(ctx context.Context, _ *mcp.CallToolRequest, inpu } if files > maxFiles { - sync.Skipped = fmt.Sprintf("%d files exceeds limit of %d", files, maxFiles) + sync.Skipped = core.Sprintf("%d files exceeds limit of %d", files, maxFiles) synced = append(synced, sync) continue } diff --git a/pkg/mcp/agentic/plan.go b/pkg/mcp/agentic/plan.go index 223a2da..e872069 100644 --- a/pkg/mcp/agentic/plan.go +++ b/pkg/mcp/agentic/plan.go @@ -7,7 +7,6 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" - "strings" "time" core "dappco.re/go/core" @@ -430,33 +429,33 @@ func planPath(dir, id string) string { } func generatePlanID(title string) string { - slug := strings.Map(func(r rune) rune { - if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' { - return r + b := core.NewBuilder() + b.Grow(len(title)) + for _, r := range title { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-': + b.WriteRune(r) + case r >= 'A' && r <= 'Z': + b.WriteRune(r + 32) + case r == ' ': + b.WriteByte('-') } - if r >= 'A' && r <= 'Z' { - return r + 32 - } - if r == ' ' { - return '-' - } - return -1 - }, title) + } + slug := b.String() - // Trim consecutive dashes and cap length + // Collapse consecutive dashes and cap length for core.Contains(slug, "--") { slug = core.Replace(slug, "--", "-") } - slug = strings.Trim(slug, "-") + slug = trimDashes(slug) if len(slug) > 30 { - slug = slug[:30] + slug = trimDashes(slug[:30]) } - slug = strings.TrimRight(slug, "-") // Append short random suffix for uniqueness - b := make([]byte, 3) - rand.Read(b) - return slug + "-" + hex.EncodeToString(b) + rnd := make([]byte, 3) + rand.Read(rnd) + return slug + "-" + hex.EncodeToString(rnd) } func readPlan(dir, id string) (*Plan, error) { diff --git a/pkg/mcp/agentic/pr.go b/pkg/mcp/agentic/pr.go index 94996e9..9f624b0 100644 --- a/pkg/mcp/agentic/pr.go +++ b/pkg/mcp/agentic/pr.go @@ -6,15 +6,13 @@ import ( "bytes" "context" "encoding/json" - "fmt" "net/http" "os/exec" - "path/filepath" - "strings" - coremcp "dappco.re/go/mcp/pkg/mcp" + core "dappco.re/go/core" coreio "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" + coremcp "dappco.re/go/mcp/pkg/mcp" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -66,8 +64,8 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in return nil, CreatePROutput{}, coreerr.E("createPR", "no Forge token configured", nil) } - wsDir := filepath.Join(s.workspaceRoot(), input.Workspace) - srcDir := filepath.Join(wsDir, "src") + wsDir := core.Path(s.workspaceRoot(), input.Workspace) + srcDir := core.Path(wsDir, "src") if _, err := coreio.Local.List(srcDir); err != nil { return nil, CreatePROutput{}, coreerr.E("createPR", "workspace not found: "+input.Workspace, nil) @@ -87,7 +85,7 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in if err != nil { return nil, CreatePROutput{}, coreerr.E("createPR", "failed to detect branch", err) } - st.Branch = strings.TrimSpace(string(out)) + st.Branch = core.Trim(string(out)) } org := st.Org @@ -105,7 +103,7 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in title = st.Task } if title == "" { - title = fmt.Sprintf("Agent work on %s", st.Branch) + title = core.Sprintf("Agent work on %s", st.Branch) } // Build PR body @@ -143,7 +141,7 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in // Comment on issue if tracked if st.Issue > 0 { - comment := fmt.Sprintf("Pull request created: %s", prURL) + comment := core.Sprintf("Pull request created: %s", prURL) s.commentOnIssue(ctx, org, st.Repo, st.Issue, comment) } @@ -159,17 +157,17 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in } func (s *PrepSubsystem) buildPRBody(st *WorkspaceStatus) string { - var b strings.Builder + b := core.NewBuilder() b.WriteString("## Summary\n\n") if st.Task != "" { b.WriteString(st.Task) b.WriteString("\n\n") } if st.Issue > 0 { - b.WriteString(fmt.Sprintf("Closes #%d\n\n", st.Issue)) + b.WriteString(core.Sprintf("Closes #%d\n\n", st.Issue)) } - b.WriteString(fmt.Sprintf("**Agent:** %s\n", st.Agent)) - b.WriteString(fmt.Sprintf("**Runs:** %d\n", st.Runs)) + b.WriteString(core.Sprintf("**Agent:** %s\n", st.Agent)) + b.WriteString(core.Sprintf("**Runs:** %d\n", st.Runs)) b.WriteString("\n---\n*Created by agentic dispatch*\n") return b.String() } @@ -185,7 +183,7 @@ func (s *PrepSubsystem) forgeCreatePR(ctx context.Context, org, repo, head, base return "", 0, coreerr.E("forgeCreatePR", "failed to marshal PR payload", err) } - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls", s.forgeURL, org, repo) + url := core.Sprintf("%s/api/v1/repos/%s/%s/pulls", s.forgeURL, org, repo) req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload)) if err != nil { return "", 0, coreerr.E("forgeCreatePR", "failed to build PR request", err) @@ -202,10 +200,10 @@ func (s *PrepSubsystem) forgeCreatePR(ctx context.Context, org, repo, head, base if resp.StatusCode != 201 { var errBody map[string]any if err := json.NewDecoder(resp.Body).Decode(&errBody); err != nil { - return "", 0, coreerr.E("forgeCreatePR", fmt.Sprintf("HTTP %d with unreadable error body", resp.StatusCode), err) + return "", 0, coreerr.E("forgeCreatePR", core.Sprintf("HTTP %d with unreadable error body", resp.StatusCode), err) } msg, _ := errBody["message"].(string) - return "", 0, coreerr.E("forgeCreatePR", fmt.Sprintf("HTTP %d: %s", resp.StatusCode, msg), nil) + return "", 0, coreerr.E("forgeCreatePR", core.Sprintf("HTTP %d: %s", resp.StatusCode, msg), nil) } var pr struct { @@ -225,7 +223,7 @@ func (s *PrepSubsystem) commentOnIssue(ctx context.Context, org, repo string, is return } - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/comments", s.forgeURL, org, repo, issue) + url := core.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/comments", s.forgeURL, org, repo, issue) req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload)) if err != nil { return @@ -337,7 +335,7 @@ func (s *PrepSubsystem) listPRs(ctx context.Context, _ *mcp.CallToolRequest, inp } func (s *PrepSubsystem) listRepoPRs(ctx context.Context, org, repo, state string) ([]PRInfo, error) { - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls?state=%s&limit=10", + url := core.Sprintf("%s/api/v1/repos/%s/%s/pulls?state=%s&limit=10", s.forgeURL, org, repo, state) req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) req.Header.Set("Authorization", "token "+s.forgeToken) @@ -348,7 +346,7 @@ func (s *PrepSubsystem) listRepoPRs(ctx context.Context, org, repo, state string } defer resp.Body.Close() if resp.StatusCode != 200 { - return nil, coreerr.E("listRepoPRs", fmt.Sprintf("HTTP %d for "+repo, resp.StatusCode), nil) + return nil, coreerr.E("listRepoPRs", core.Sprintf("HTTP %d for "+repo, resp.StatusCode), nil) } var prs []struct { diff --git a/pkg/mcp/agentic/prep.go b/pkg/mcp/agentic/prep.go index 4b0bcc2..71ca64e 100644 --- a/pkg/mcp/agentic/prep.go +++ b/pkg/mcp/agentic/prep.go @@ -8,18 +8,14 @@ import ( "context" "encoding/base64" "encoding/json" - "fmt" - goio "io" "net/http" - "os" "os/exec" - "path/filepath" - "strings" "time" - coremcp "dappco.re/go/mcp/pkg/mcp" + core "dappco.re/go/core" coreio "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" + coremcp "dappco.re/go/mcp/pkg/mcp" "github.com/modelcontextprotocol/go-sdk/mcp" "gopkg.in/yaml.v3" ) @@ -46,17 +42,17 @@ var ( // // prep := NewPrep() func NewPrep() *PrepSubsystem { - home, _ := os.UserHomeDir() + home := core.Env("HOME") - forgeToken := os.Getenv("FORGE_TOKEN") + forgeToken := core.Env("FORGE_TOKEN") if forgeToken == "" { - forgeToken = os.Getenv("GITEA_TOKEN") + forgeToken = core.Env("GITEA_TOKEN") } - brainKey := os.Getenv("CORE_BRAIN_KEY") + brainKey := core.Env("CORE_BRAIN_KEY") if brainKey == "" { - if data, err := coreio.Local.Read(filepath.Join(home, ".claude", "brain.key")); err == nil { - brainKey = strings.TrimSpace(data) + if data, err := coreio.Local.Read(core.Path(home, ".claude", "brain.key")); err == nil { + brainKey = core.Trim(data) } } @@ -65,8 +61,8 @@ func NewPrep() *PrepSubsystem { forgeToken: forgeToken, brainURL: envOr("CORE_BRAIN_URL", "https://api.lthn.sh"), brainKey: brainKey, - specsPath: envOr("SPECS_PATH", filepath.Join(home, "Code", "host-uk", "specs")), - codePath: envOr("CODE_PATH", filepath.Join(home, "Code")), + specsPath: envOr("SPECS_PATH", core.Path(home, "Code", "host-uk", "specs")), + codePath: envOr("CODE_PATH", core.Path(home, "Code")), client: &http.Client{Timeout: 30 * time.Second}, } } @@ -84,24 +80,24 @@ func (s *PrepSubsystem) emitChannel(ctx context.Context, channel string, data an } func envOr(key, fallback string) string { - if v := os.Getenv(key); v != "" { + if v := core.Env(key); v != "" { return v } return fallback } func sanitizeRepoPathSegment(value, field string, allowSubdirs bool) (string, error) { - if strings.TrimSpace(value) != value { + if core.Trim(value) != value { return "", coreerr.E("prepWorkspace", field+" contains whitespace", nil) } if value == "" { return "", nil } - if strings.Contains(value, "\\") { + if core.Contains(value, "\\") { return "", coreerr.E("prepWorkspace", field+" contains invalid path separator", nil) } - parts := strings.Split(value, "/") + parts := core.Split(value, "/") if !allowSubdirs && len(parts) != 1 { return "", coreerr.E("prepWorkspace", field+" may not contain subdirectories", nil) } @@ -161,7 +157,7 @@ func (s *PrepSubsystem) Shutdown(_ context.Context) error { return nil } // workspaceRoot returns the base directory for agent workspaces. func (s *PrepSubsystem) workspaceRoot() string { - return filepath.Join(s.codePath, ".core", "workspace") + return core.Path(s.codePath, ".core", "workspace") } // --- Input/Output types --- @@ -227,8 +223,8 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques // Workspace root: .core/workspace/{repo}-{timestamp}/ wsRoot := s.workspaceRoot() coreio.Local.EnsureDir(wsRoot) - wsName := fmt.Sprintf("%s-%d", input.Repo, time.Now().Unix()) - wsDir := filepath.Join(wsRoot, wsName) + wsName := core.Sprintf("%s-%d", input.Repo, time.Now().Unix()) + wsDir := core.Path(wsRoot, wsName) // Create workspace structure // kb/ and specs/ will be created inside src/ after clone @@ -236,10 +232,10 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques out := PrepOutput{WorkspaceDir: wsDir} // Source repo path - repoPath := filepath.Join(s.codePath, "core", input.Repo) + repoPath := core.Path(s.codePath, "core", input.Repo) // 1. Clone repo into src/ and create feature branch - srcDir := filepath.Join(wsDir, "src") + srcDir := core.Path(wsDir, "src") cloneCmd := exec.CommandContext(ctx, "git", "clone", repoPath, srcDir) if err := cloneCmd.Run(); err != nil { return nil, PrepOutput{}, coreerr.E("prepWorkspace", "failed to clone repository", err) @@ -251,12 +247,12 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques taskSlug := branchSlug(input.Task) if input.Issue > 0 { issueSlug := branchSlug(input.Task) - branchName = fmt.Sprintf("agent/issue-%d", input.Issue) + branchName = core.Sprintf("agent/issue-%d", input.Issue) if issueSlug != "" { branchName += "-" + issueSlug } } else if taskSlug != "" { - branchName = fmt.Sprintf("agent/%s", taskSlug) + branchName = core.Sprintf("agent/%s", taskSlug) } } if branchName != "" { @@ -269,29 +265,29 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques } // Create context dirs inside src/ - coreio.Local.EnsureDir(filepath.Join(srcDir, "kb")) - coreio.Local.EnsureDir(filepath.Join(srcDir, "specs")) + coreio.Local.EnsureDir(core.Path(srcDir, "kb")) + coreio.Local.EnsureDir(core.Path(srcDir, "specs")) // Remote stays as local clone origin — agent cannot push to forge. // Reviewer pulls changes from workspace and pushes after verification. // 2. Copy CLAUDE.md and GEMINI.md to workspace - claudeMdPath := filepath.Join(repoPath, "CLAUDE.md") + claudeMdPath := core.Path(repoPath, "CLAUDE.md") if data, err := coreio.Local.Read(claudeMdPath); err == nil { - _ = writeAtomic(filepath.Join(wsDir, "src", "CLAUDE.md"), data) + _ = writeAtomic(core.Path(wsDir, "src", "CLAUDE.md"), data) out.ClaudeMd = true } // Copy GEMINI.md from core/agent (ethics framework for all agents) - agentGeminiMd := filepath.Join(s.codePath, "core", "agent", "GEMINI.md") + agentGeminiMd := core.Path(s.codePath, "core", "agent", "GEMINI.md") if data, err := coreio.Local.Read(agentGeminiMd); err == nil { - _ = writeAtomic(filepath.Join(wsDir, "src", "GEMINI.md"), data) + _ = writeAtomic(core.Path(wsDir, "src", "GEMINI.md"), data) } // Copy persona if specified if persona != "" { - personaPath := filepath.Join(s.codePath, "core", "agent", "prompts", "personas", persona+".md") + personaPath := core.Path(s.codePath, "core", "agent", "prompts", "personas", persona+".md") if data, err := coreio.Local.Read(personaPath); err == nil { - _ = writeAtomic(filepath.Join(wsDir, "src", "PERSONA.md"), data) + _ = writeAtomic(core.Path(wsDir, "src", "PERSONA.md"), data) } } @@ -299,9 +295,9 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques if input.Issue > 0 { s.generateTodo(ctx, input.Org, input.Repo, input.Issue, wsDir) } else if input.Task != "" { - todo := fmt.Sprintf("# TASK: %s\n\n**Repo:** %s/%s\n**Status:** ready\n\n## Objective\n\n%s\n", + todo := core.Sprintf("# TASK: %s\n\n**Repo:** %s/%s\n**Status:** ready\n\n## Objective\n\n%s\n", input.Task, input.Org, input.Repo, input.Task) - _ = writeAtomic(filepath.Join(wsDir, "src", "TODO.md"), todo) + _ = writeAtomic(core.Path(wsDir, "src", "TODO.md"), todo) } // 4. Generate CONTEXT.md from OpenBrain @@ -333,12 +329,12 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques // branchSlug converts a free-form string into a git-friendly branch suffix. func branchSlug(value string) string { - value = strings.ToLower(strings.TrimSpace(value)) + value = core.Lower(core.Trim(value)) if value == "" { return "" } - var b strings.Builder + b := core.NewBuilder() b.Grow(len(value)) lastDash := false for _, r := range value { @@ -359,14 +355,42 @@ func branchSlug(value string) string { } } - slug := strings.Trim(b.String(), "-") + slug := trimDashes(b.String()) if len(slug) > 40 { - slug = slug[:40] - slug = strings.Trim(slug, "-") + slug = trimDashes(slug[:40]) } return slug } +// sanitizeFilename replaces non-alphanumeric characters (except - _ .) with dashes. +func sanitizeFilename(title string) string { + b := core.NewBuilder() + b.Grow(len(title)) + for _, r := range title { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', + r == '-', r == '_', r == '.': + b.WriteRune(r) + default: + b.WriteByte('-') + } + } + return b.String() +} + +// trimDashes strips leading and trailing dash characters from a string. +func trimDashes(s string) string { + start := 0 + for start < len(s) && s[start] == '-' { + start++ + } + end := len(s) + for end > start && s[end-1] == '-' { + end-- + } + return s[start:end] +} + // --- Prompt templates --- func (s *PrepSubsystem) writePromptTemplate(template, wsDir string) { @@ -434,7 +458,7 @@ Do NOT push. Commit only — a reviewer will verify and push. prompt = "Read TODO.md and complete the task. Work in src/.\n" } - _ = writeAtomic(filepath.Join(wsDir, "src", "PROMPT.md"), prompt) + _ = writeAtomic(core.Path(wsDir, "src", "PROMPT.md"), prompt) } // --- Plan template rendering --- @@ -443,11 +467,11 @@ Do NOT push. Commit only — a reviewer will verify and push. // and writes PLAN.md into the workspace src/ directory. func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map[string]string, task string, wsDir string) { // Look for template in core/agent/prompts/templates/ - templatePath := filepath.Join(s.codePath, "core", "agent", "prompts", "templates", templateSlug+".yaml") + templatePath := core.Path(s.codePath, "core", "agent", "prompts", "templates", templateSlug+".yaml") content, err := coreio.Local.Read(templatePath) if err != nil { // Try .yml extension - templatePath = filepath.Join(s.codePath, "core", "agent", "prompts", "templates", templateSlug+".yml") + templatePath = core.Path(s.codePath, "core", "agent", "prompts", "templates", templateSlug+".yml") content, err = coreio.Local.Read(templatePath) if err != nil { return // Template not found, skip silently @@ -456,8 +480,8 @@ func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map // Substitute variables ({{variable_name}} → value) for key, value := range variables { - content = strings.ReplaceAll(content, "{{"+key+"}}", value) - content = strings.ReplaceAll(content, "{{ "+key+" }}", value) + content = core.Replace(content, "{{"+key+"}}", value) + content = core.Replace(content, "{{ "+key+" }}", value) } // Parse the YAML to render as markdown @@ -477,7 +501,7 @@ func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map } // Render as PLAN.md - var plan strings.Builder + plan := core.NewBuilder() plan.WriteString("# Plan: " + tmpl.Name + "\n\n") if task != "" { plan.WriteString("**Task:** " + task + "\n\n") @@ -495,7 +519,7 @@ func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map } for i, phase := range tmpl.Phases { - plan.WriteString(fmt.Sprintf("## Phase %d: %s\n\n", i+1, phase.Name)) + plan.WriteString(core.Sprintf("## Phase %d: %s\n\n", i+1, phase.Name)) if phase.Description != "" { plan.WriteString(phase.Description + "\n\n") } @@ -512,7 +536,7 @@ func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map plan.WriteString("\n**Commit after completing this phase.**\n\n---\n\n") } - _ = writeAtomic(filepath.Join(wsDir, "src", "PLAN.md"), plan.String()) + _ = writeAtomic(core.Path(wsDir, "src", "PLAN.md"), plan.String()) } // --- Helpers (unchanged) --- @@ -522,7 +546,7 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i return 0 } - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/wiki/pages", s.forgeURL, org, repo) + url := core.Sprintf("%s/api/v1/repos/%s/%s/wiki/pages", s.forgeURL, org, repo) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return 0 @@ -553,7 +577,7 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i subURL = page.Title } - pageURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/wiki/page/%s", s.forgeURL, org, repo, subURL) + pageURL := core.Sprintf("%s/api/v1/repos/%s/%s/wiki/page/%s", s.forgeURL, org, repo, subURL) pageReq, err := http.NewRequestWithContext(ctx, "GET", pageURL, nil) if err != nil { continue @@ -585,14 +609,9 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i if err != nil { continue } - filename := strings.Map(func(r rune) rune { - if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' { - return r - } - return '-' - }, page.Title) + ".md" + filename := sanitizeFilename(page.Title) + ".md" - _ = writeAtomic(filepath.Join(wsDir, "src", "kb", filename), string(content)) + _ = writeAtomic(core.Path(wsDir, "src", "kb", filename), string(content)) count++ } @@ -604,9 +623,9 @@ func (s *PrepSubsystem) copySpecs(wsDir string) int { count := 0 for _, file := range specFiles { - src := filepath.Join(s.specsPath, file) + src := core.Path(s.specsPath, file) if data, err := coreio.Local.Read(src); err == nil { - _ = writeAtomic(filepath.Join(wsDir, "src", "specs", file), data) + _ = writeAtomic(core.Path(wsDir, "src", "specs", file), data) count++ } } @@ -629,7 +648,7 @@ func (s *PrepSubsystem) generateContext(ctx context.Context, repo, wsDir string) return 0 } - req, err := http.NewRequestWithContext(ctx, "POST", s.brainURL+"/v1/brain/recall", strings.NewReader(string(body))) + req, err := http.NewRequestWithContext(ctx, "POST", s.brainURL+"/v1/brain/recall", core.NewReader(string(body))) if err != nil { return 0 } @@ -646,18 +665,18 @@ func (s *PrepSubsystem) generateContext(ctx context.Context, repo, wsDir string) return 0 } - respData, err := goio.ReadAll(resp.Body) - if err != nil { + readResult := core.ReadAll(resp.Body) + if !readResult.OK { return 0 } var result struct { Memories []map[string]any `json:"memories"` } - if err := json.Unmarshal(respData, &result); err != nil { + if ur := core.JSONUnmarshal([]byte(readResult.Value.(string)), &result); !ur.OK { return 0 } - var content strings.Builder + content := core.NewBuilder() content.WriteString("# Context — " + repo + "\n\n") content.WriteString("> Relevant knowledge from OpenBrain.\n\n") @@ -666,15 +685,15 @@ func (s *PrepSubsystem) generateContext(ctx context.Context, repo, wsDir string) memContent, _ := mem["content"].(string) memProject, _ := mem["project"].(string) score, _ := mem["score"].(float64) - content.WriteString(fmt.Sprintf("### %d. %s [%s] (score: %.3f)\n\n%s\n\n", i+1, memProject, memType, score, memContent)) + content.WriteString(core.Sprintf("### %d. %s [%s] (score: %.3f)\n\n%s\n\n", i+1, memProject, memType, score, memContent)) } - _ = writeAtomic(filepath.Join(wsDir, "src", "CONTEXT.md"), content.String()) + _ = writeAtomic(core.Path(wsDir, "src", "CONTEXT.md"), content.String()) return len(result.Memories) } func (s *PrepSubsystem) findConsumers(repo, wsDir string) int { - goWorkPath := filepath.Join(s.codePath, "go.work") + goWorkPath := core.Path(s.codePath, "go.work") modulePath := "forge.lthn.ai/core/" + repo workData, err := coreio.Local.Read(goWorkPath) @@ -683,19 +702,19 @@ func (s *PrepSubsystem) findConsumers(repo, wsDir string) int { } var consumers []string - for _, line := range strings.Split(workData, "\n") { - line = strings.TrimSpace(line) - if !strings.HasPrefix(line, "./") { + for _, line := range core.Split(workData, "\n") { + line = core.Trim(line) + if !core.HasPrefix(line, "./") { continue } - dir := filepath.Join(s.codePath, strings.TrimPrefix(line, "./")) - goMod := filepath.Join(dir, "go.mod") + dir := core.Path(s.codePath, core.TrimPrefix(line, "./")) + goMod := core.Path(dir, "go.mod") modData, err := coreio.Local.Read(goMod) if err != nil { continue } - if strings.Contains(modData, modulePath) && !strings.HasPrefix(modData, "module "+modulePath) { - consumers = append(consumers, filepath.Base(dir)) + if core.Contains(modData, modulePath) && !core.HasPrefix(modData, "module "+modulePath) { + consumers = append(consumers, core.PathBase(dir)) } } @@ -705,8 +724,8 @@ func (s *PrepSubsystem) findConsumers(repo, wsDir string) int { for _, c := range consumers { content += "- " + c + "\n" } - content += fmt.Sprintf("\n**Breaking change risk: %d consumers.**\n", len(consumers)) - _ = writeAtomic(filepath.Join(wsDir, "src", "CONSUMERS.md"), content) + content += core.Sprintf("\n**Breaking change risk: %d consumers.**\n", len(consumers)) + _ = writeAtomic(core.Path(wsDir, "src", "CONSUMERS.md"), content) } return len(consumers) @@ -720,10 +739,10 @@ func (s *PrepSubsystem) gitLog(repoPath, wsDir string) int { return 0 } - lines := strings.Split(strings.TrimSpace(string(output)), "\n") + lines := core.Split(core.Trim(string(output)), "\n") if len(lines) > 0 && lines[0] != "" { content := "# Recent Changes\n\n```\n" + string(output) + "```\n" - _ = writeAtomic(filepath.Join(wsDir, "src", "RECENT.md"), content) + _ = writeAtomic(core.Path(wsDir, "src", "RECENT.md"), content) } return len(lines) @@ -734,7 +753,7 @@ func (s *PrepSubsystem) generateTodo(ctx context.Context, org, repo string, issu return } - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue) + url := core.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue) req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) req.Header.Set("Authorization", "token "+s.forgeToken) @@ -753,11 +772,11 @@ func (s *PrepSubsystem) generateTodo(ctx context.Context, org, repo string, issu } json.NewDecoder(resp.Body).Decode(&issueData) - content := fmt.Sprintf("# TASK: %s\n\n", issueData.Title) - content += fmt.Sprintf("**Status:** ready\n") - content += fmt.Sprintf("**Source:** %s/%s/%s/issues/%d\n", s.forgeURL, org, repo, issue) - content += fmt.Sprintf("**Repo:** %s/%s\n\n---\n\n", org, repo) + content := core.Sprintf("# TASK: %s\n\n", issueData.Title) + content += core.Sprintf("**Status:** ready\n") + content += core.Sprintf("**Source:** %s/%s/%s/issues/%d\n", s.forgeURL, org, repo, issue) + content += core.Sprintf("**Repo:** %s/%s\n\n---\n\n", org, repo) content += "## Objective\n\n" + issueData.Body + "\n" - _ = writeAtomic(filepath.Join(wsDir, "src", "TODO.md"), content) + _ = writeAtomic(core.Path(wsDir, "src", "TODO.md"), content) } diff --git a/pkg/mcp/agentic/queue.go b/pkg/mcp/agentic/queue.go index ff5fe1a..a02c7b9 100644 --- a/pkg/mcp/agentic/queue.go +++ b/pkg/mcp/agentic/queue.go @@ -3,18 +3,19 @@ package agentic import ( - "fmt" "os" "os/exec" - "path/filepath" - "strings" "syscall" "time" + core "dappco.re/go/core" coreio "dappco.re/go/core/io" "gopkg.in/yaml.v3" ) +// os.Create, os.Open, os.DevNull, os.Environ, os.FindProcess are used for +// process spawning and management — no core equivalents for these OS primitives. + // DispatchConfig controls agent dispatch behaviour. type DispatchConfig struct { DefaultAgent string `yaml:"default_agent"` @@ -43,7 +44,7 @@ type AgentsConfig struct { // loadAgentsConfig reads config/agents.yaml from the code path. func (s *PrepSubsystem) loadAgentsConfig() *AgentsConfig { paths := []string{ - filepath.Join(s.codePath, ".core", "agents.yaml"), + core.Path(s.codePath, ".core", "agents.yaml"), } for _, path := range paths { @@ -79,9 +80,16 @@ func (s *PrepSubsystem) delayForAgent(agent string) time.Duration { return 0 } - // Parse reset time + // Parse reset time (format: "HH:MM") resetHour, resetMin := 6, 0 - fmt.Sscanf(rate.ResetUTC, "%d:%d", &resetHour, &resetMin) + if parts := core.Split(rate.ResetUTC, ":"); len(parts) == 2 { + if h, ok := parseSimpleInt(parts[0]); ok { + resetHour = h + } + if m, ok := parseSimpleInt(parts[1]); ok { + resetMin = m + } + } now := time.Now().UTC() resetToday := time.Date(now.Year(), now.Month(), now.Day(), resetHour, resetMin, 0, 0, time.UTC) @@ -115,9 +123,9 @@ func (s *PrepSubsystem) listWorkspaceDirs() []string { if !entry.IsDir() { continue } - path := filepath.Join(wsRoot, entry.Name()) + path := core.Path(wsRoot, entry.Name()) // Check if this dir has a status.json (it's a workspace) - if coreio.Local.IsFile(filepath.Join(path, "status.json")) { + if coreio.Local.IsFile(core.Path(path, "status.json")) { dirs = append(dirs, path) continue } @@ -128,8 +136,8 @@ func (s *PrepSubsystem) listWorkspaceDirs() []string { } for _, sub := range subEntries { if sub.IsDir() { - subPath := filepath.Join(path, sub.Name()) - if coreio.Local.IsFile(filepath.Join(subPath, "status.json")) { + subPath := core.Path(path, sub.Name()) + if coreio.Local.IsFile(core.Path(subPath, "status.json")) { dirs = append(dirs, subPath) } } @@ -146,7 +154,7 @@ func (s *PrepSubsystem) countRunningByAgent(agent string) int { if err != nil || st.Status != "running" { continue } - stBase := strings.SplitN(st.Agent, ":", 2)[0] + stBase := core.SplitN(st.Agent, ":", 2)[0] if stBase != agent { continue } @@ -162,7 +170,7 @@ func (s *PrepSubsystem) countRunningByAgent(agent string) int { // baseAgent strips the model variant (gemini:flash → gemini). func baseAgent(agent string) string { - return strings.SplitN(agent, ":", 2)[0] + return core.SplitN(agent, ":", 2)[0] } // canDispatchAgent checks if we're under the concurrency limit for a specific agent type. @@ -176,6 +184,23 @@ func (s *PrepSubsystem) canDispatchAgent(agent string) bool { return s.countRunningByAgent(base) < limit } +// parseSimpleInt parses a small non-negative integer from a string. +// Returns (value, true) on success, (0, false) on failure. +func parseSimpleInt(s string) (int, bool) { + s = core.Trim(s) + if s == "" { + return 0, false + } + n := 0 + for _, r := range s { + if r < '0' || r > '9' { + return 0, false + } + n = n*10 + int(r-'0') + } + return n, true +} + // canDispatch is kept for backwards compat. func (s *PrepSubsystem) canDispatch() bool { return true @@ -205,7 +230,7 @@ func (s *PrepSubsystem) drainQueue() { continue } - srcDir := filepath.Join(wsDir, "src") + srcDir := core.Path(wsDir, "src") prompt := "Read PROMPT.md for instructions. All context files (CLAUDE.md, TODO.md, CONTEXT.md, CONSUMERS.md, RECENT.md) are in the parent directory. Work in this directory." command, args, err := agentCommand(st.Agent, prompt) @@ -213,7 +238,7 @@ func (s *PrepSubsystem) drainQueue() { continue } - outputFile := filepath.Join(wsDir, fmt.Sprintf("agent-%s.log", st.Agent)) + outputFile := core.Path(wsDir, core.Sprintf("agent-%s.log", st.Agent)) outFile, err := os.Create(outputFile) if err != nil { continue diff --git a/pkg/mcp/agentic/repo_helpers.go b/pkg/mcp/agentic/repo_helpers.go index d50c1b8..cb03de0 100644 --- a/pkg/mcp/agentic/repo_helpers.go +++ b/pkg/mcp/agentic/repo_helpers.go @@ -5,19 +5,18 @@ package agentic import ( "context" "encoding/json" - "os" "os/exec" - "path/filepath" "regexp" "strconv" - "strings" "time" + core "dappco.re/go/core" + coreio "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" ) func listLocalRepos(basePath string) []string { - entries, err := os.ReadDir(basePath) + entries, err := coreio.Local.List(basePath) if err != nil { return nil } @@ -35,7 +34,7 @@ func hasRemote(repoDir, remote string) bool { cmd := exec.Command("git", "remote", "get-url", remote) cmd.Dir = repoDir if out, err := cmd.Output(); err == nil { - return strings.TrimSpace(string(out)) != "" + return core.Trim(string(out)) != "" } return false } @@ -48,7 +47,7 @@ func commitsAhead(repoDir, baseRef, headRef string) int { return 0 } - count, err := parsePositiveInt(strings.TrimSpace(string(out))) + count, err := parsePositiveInt(core.Trim(string(out))) if err != nil { return 0 } @@ -64,8 +63,8 @@ func filesChanged(repoDir, baseRef, headRef string) int { } count := 0 - for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { - if strings.TrimSpace(line) != "" { + for _, line := range core.Split(core.Trim(string(out)), "\n") { + if core.Trim(line) != "" { count++ } } @@ -79,11 +78,11 @@ func gitOutput(repoDir string, args ...string) (string, error) { if err != nil { return "", coreerr.E("gitOutput", string(out), err) } - return strings.TrimSpace(string(out)), nil + return core.Trim(string(out)), nil } func parsePositiveInt(value string) (int, error) { - value = strings.TrimSpace(value) + value = core.Trim(value) if value == "" { return 0, coreerr.E("parsePositiveInt", "empty value", nil) } @@ -148,11 +147,11 @@ func createGitHubPR(ctx context.Context, repoDir, repo string, commits, files in return "", coreerr.E("createGitHubPR", string(out), err) } - lines := strings.Split(strings.TrimSpace(string(out)), "\n") + lines := core.Split(core.Trim(string(out)), "\n") if len(lines) == 0 { return "", nil } - return strings.TrimSpace(lines[len(lines)-1]), nil + return core.Trim(lines[len(lines)-1]), nil } func ensureDevBranch(repoDir string) error { @@ -194,7 +193,7 @@ func parseRetryAfter(detail string) time.Duration { return 5 * time.Minute } - switch strings.ToLower(match[2]) { + switch core.Lower(match[2]) { case "hour", "hours": return time.Duration(n) * time.Hour case "second", "seconds": @@ -205,5 +204,5 @@ func parseRetryAfter(detail string) time.Duration { } func repoRootFromCodePath(codePath string) string { - return filepath.Join(codePath, "core") + return core.Path(codePath, "core") } diff --git a/pkg/mcp/agentic/resume.go b/pkg/mcp/agentic/resume.go index 8528da6..ac5160f 100644 --- a/pkg/mcp/agentic/resume.go +++ b/pkg/mcp/agentic/resume.go @@ -4,16 +4,14 @@ package agentic import ( "context" - "fmt" "os" "os/exec" - "path/filepath" - "strings" "syscall" - coremcp "dappco.re/go/mcp/pkg/mcp" + core "dappco.re/go/core" coreio "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" + coremcp "dappco.re/go/mcp/pkg/mcp" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -52,8 +50,8 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu return nil, ResumeOutput{}, coreerr.E("resume", "workspace is required", nil) } - wsDir := filepath.Join(s.workspaceRoot(), input.Workspace) - srcDir := filepath.Join(wsDir, "src") + wsDir := core.Path(s.workspaceRoot(), input.Workspace) + srcDir := core.Path(wsDir, "src") // Verify workspace exists if _, err := coreio.Local.List(srcDir); err != nil { @@ -78,8 +76,8 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu // Write ANSWER.md if answer provided if input.Answer != "" { - answerPath := filepath.Join(srcDir, "ANSWER.md") - content := fmt.Sprintf("# Answer\n\n%s\n", input.Answer) + answerPath := core.Path(srcDir, "ANSWER.md") + content := core.Sprintf("# Answer\n\n%s\n", input.Answer) if err := writeAtomic(answerPath, content); err != nil { return nil, ResumeOutput{}, coreerr.E("resume", "failed to write ANSWER.md", err) } @@ -102,7 +100,7 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu } // Spawn agent as detached process (survives parent death) - outputFile := filepath.Join(wsDir, fmt.Sprintf("agent-%s-run%d.log", agent, st.Runs+1)) + outputFile := core.Path(wsDir, core.Sprintf("agent-%s-run%d.log", agent, st.Runs+1)) command, args, err := agentCommand(agent, prompt) if err != nil { @@ -154,10 +152,10 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu "branch": st.Branch, } - if data, err := coreio.Local.Read(filepath.Join(srcDir, "BLOCKED.md")); err == nil { + if data, err := coreio.Local.Read(core.Path(srcDir, "BLOCKED.md")); err == nil { status = "blocked" channel = coremcp.ChannelAgentBlocked - st.Question = strings.TrimSpace(data) + st.Question = core.Trim(data) if st.Question != "" { payload["question"] = st.Question } diff --git a/pkg/mcp/agentic/review_queue.go b/pkg/mcp/agentic/review_queue.go index df8c354..050df04 100644 --- a/pkg/mcp/agentic/review_queue.go +++ b/pkg/mcp/agentic/review_queue.go @@ -5,16 +5,14 @@ package agentic import ( "context" "encoding/json" - "fmt" "os" "os/exec" - "path/filepath" "regexp" - "strings" "time" - coremcp "dappco.re/go/mcp/pkg/mcp" + core "dappco.re/go/core" coreio "dappco.re/go/core/io" + coremcp "dappco.re/go/mcp/pkg/mcp" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -93,7 +91,7 @@ func (s *PrepSubsystem) reviewQueue(ctx context.Context, _ *mcp.CallToolRequest, continue } - repoDir := filepath.Join(basePath, repo) + repoDir := core.Path(basePath, repo) reviewer := input.Reviewer if reviewer == "" { reviewer = "coderabbit" @@ -137,7 +135,7 @@ func (s *PrepSubsystem) findReviewCandidates(basePath string) []string { if !entry.IsDir() { continue } - repoDir := filepath.Join(basePath, entry.Name()) + repoDir := core.Path(basePath, entry.Name()) if !hasRemote(repoDir, "github") { continue } @@ -154,22 +152,22 @@ func (s *PrepSubsystem) reviewRepo(ctx context.Context, repoDir, repo, reviewer if rl := s.loadRateLimitState(); rl != nil && rl.Limited && time.Now().Before(rl.RetryAt) { result.Verdict = "rate_limited" - result.Detail = fmt.Sprintf("retry after %s", rl.RetryAt.Format(time.RFC3339)) + result.Detail = core.Sprintf("retry after %s", rl.RetryAt.Format(time.RFC3339)) return result } cmd := reviewerCommand(ctx, repoDir, reviewer) cmd.Dir = repoDir out, err := cmd.CombinedOutput() - output := strings.TrimSpace(string(out)) + output := core.Trim(string(out)) - if strings.Contains(strings.ToLower(output), "rate limit") { + if core.Contains(core.Lower(output), "rate limit") { result.Verdict = "rate_limited" result.Detail = output return result } - if err != nil && !strings.Contains(output, "No findings") && !strings.Contains(output, "no issues") { + if err != nil && !core.Contains(output, "No findings") && !core.Contains(output, "no issues") { result.Verdict = "error" if output != "" { result.Detail = output @@ -182,7 +180,7 @@ func (s *PrepSubsystem) reviewRepo(ctx context.Context, repoDir, repo, reviewer s.storeReviewOutput(repoDir, repo, reviewer, output) result.Findings = countFindingHints(output) - if strings.Contains(output, "No findings") || strings.Contains(output, "no issues") || strings.Contains(output, "LGTM") { + if core.Contains(output, "No findings") || core.Contains(output, "no issues") || core.Contains(output, "LGTM") { result.Verdict = "clean" if dryRun { result.Action = "skipped (dry run)" @@ -198,7 +196,7 @@ func (s *PrepSubsystem) reviewRepo(ctx context.Context, repoDir, repo, reviewer mergeCmd.Dir = repoDir if mergeOut, err := mergeCmd.CombinedOutput(); err == nil { result.Action = "merged" - result.Detail = strings.TrimSpace(string(mergeOut)) + result.Detail = core.Trim(string(mergeOut)) return result } } @@ -219,7 +217,7 @@ func (s *PrepSubsystem) reviewRepo(ctx context.Context, repoDir, repo, reviewer func (s *PrepSubsystem) storeReviewOutput(repoDir, repo, reviewer, output string) { home := reviewQueueHomeDir() - dataDir := filepath.Join(home, ".core", "training", "reviews") + dataDir := core.Path(home, ".core", "training", "reviews") if err := coreio.Local.EnsureDir(dataDir); err != nil { return } @@ -235,13 +233,13 @@ func (s *PrepSubsystem) storeReviewOutput(repoDir, repo, reviewer, output string return } - name := fmt.Sprintf("%s-%s-%d.json", repo, reviewer, time.Now().Unix()) - _ = writeAtomic(filepath.Join(dataDir, name), string(data)) + name := core.Sprintf("%s-%s-%d.json", repo, reviewer, time.Now().Unix()) + _ = writeAtomic(core.Path(dataDir, name), string(data)) } func (s *PrepSubsystem) saveRateLimitState(info *RateLimitInfo) { home := reviewQueueHomeDir() - path := filepath.Join(home, ".core", "coderabbit-ratelimit.json") + path := core.Path(home, ".core", "coderabbit-ratelimit.json") data, err := json.Marshal(info) if err != nil { return @@ -251,7 +249,7 @@ func (s *PrepSubsystem) saveRateLimitState(info *RateLimitInfo) { func (s *PrepSubsystem) loadRateLimitState() *RateLimitInfo { home := reviewQueueHomeDir() - path := filepath.Join(home, ".core", "coderabbit-ratelimit.json") + path := core.Path(home, ".core", "coderabbit-ratelimit.json") data, err := coreio.Local.Read(path) if err != nil { return nil diff --git a/pkg/mcp/agentic/scan.go b/pkg/mcp/agentic/scan.go index 564962f..7e40f47 100644 --- a/pkg/mcp/agentic/scan.go +++ b/pkg/mcp/agentic/scan.go @@ -6,7 +6,6 @@ import ( "context" "encoding/json" "net/http" - "strings" core "dappco.re/go/core" coreerr "dappco.re/go/core/log" @@ -170,7 +169,7 @@ func (s *PrepSubsystem) listRepoIssues(ctx context.Context, org, repo, label str Title: issue.Title, Labels: labels, Assignee: assignee, - URL: strings.Replace(issue.HTMLURL, "https://forge.lthn.ai", s.forgeURL, 1), + URL: core.Replace(issue.HTMLURL, "https://forge.lthn.ai", s.forgeURL), }) } diff --git a/pkg/mcp/agentic/status.go b/pkg/mcp/agentic/status.go index 4f3b28b..d789ece 100644 --- a/pkg/mcp/agentic/status.go +++ b/pkg/mcp/agentic/status.go @@ -15,6 +15,9 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" ) +// os.Stat and os.FindProcess are used for workspace age detection and PID +// liveness checks — these are OS-level queries with no core equivalent. + // Workspace status file convention: // // {workspace}/status.json — current state of the workspace diff --git a/pkg/mcp/agentic/write_atomic.go b/pkg/mcp/agentic/write_atomic.go index 72bb9a7..8f46118 100644 --- a/pkg/mcp/agentic/write_atomic.go +++ b/pkg/mcp/agentic/write_atomic.go @@ -9,6 +9,9 @@ import ( coreio "dappco.re/go/core/io" ) +// os.CreateTemp, os.Remove, os.Rename are framework-boundary calls for +// atomic file writes — no core equivalent exists for temp file creation. + // writeAtomic writes content to path by staging it in a temporary file and // renaming it into place. // diff --git a/pkg/mcp/brain/direct.go b/pkg/mcp/brain/direct.go index acfb829..cb59d3a 100644 --- a/pkg/mcp/brain/direct.go +++ b/pkg/mcp/brain/direct.go @@ -3,20 +3,15 @@ package brain import ( - "bytes" "context" - "encoding/json" - "fmt" - goio "io" "net/http" "net/url" - "os" - "strings" "time" - coremcp "dappco.re/go/mcp/pkg/mcp" + core "dappco.re/go/core" coreio "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" + coremcp "dappco.re/go/mcp/pkg/mcp" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -58,15 +53,16 @@ func (s *DirectSubsystem) OnChannel(fn func(ctx context.Context, channel string, // Reads CORE_BRAIN_URL and CORE_BRAIN_KEY from environment, or falls back // to ~/.claude/brain.key for the API key. func NewDirect() *DirectSubsystem { - apiURL := os.Getenv("CORE_BRAIN_URL") + apiURL := core.Env("CORE_BRAIN_URL") if apiURL == "" { apiURL = "https://api.lthn.sh" } - apiKey := os.Getenv("CORE_BRAIN_KEY") + apiKey := core.Env("CORE_BRAIN_KEY") if apiKey == "" { - if data, err := coreio.Local.Read(os.ExpandEnv("$HOME/.claude/brain.key")); err == nil { - apiKey = strings.TrimSpace(data) + home := core.Env("HOME") + if data, err := coreio.Local.Read(core.Path(home, ".claude", "brain.key")); err == nil { + apiKey = core.Trim(data) } } @@ -112,16 +108,12 @@ func (s *DirectSubsystem) apiCall(ctx context.Context, method, path string, body return nil, coreerr.E("brain.apiCall", "no API key (set CORE_BRAIN_KEY or create ~/.claude/brain.key)", nil) } - var reqBody goio.Reader + var bodyStr string if body != nil { - data, err := json.Marshal(body) - if err != nil { - return nil, coreerr.E("brain.apiCall", "marshal request", err) - } - reqBody = bytes.NewReader(data) + bodyStr = core.JSONMarshalString(body) } - req, err := http.NewRequestWithContext(ctx, method, s.apiURL+path, reqBody) + req, err := http.NewRequestWithContext(ctx, method, s.apiURL+path, core.NewReader(bodyStr)) if err != nil { return nil, coreerr.E("brain.apiCall", "create request", err) } @@ -135,18 +127,22 @@ func (s *DirectSubsystem) apiCall(ctx context.Context, method, path string, body } defer resp.Body.Close() - respData, err := goio.ReadAll(resp.Body) - if err != nil { - return nil, coreerr.E("brain.apiCall", "read response", err) + r := core.ReadAll(resp.Body) + if !r.OK { + if readErr, ok := r.Value.(error); ok { + return nil, coreerr.E("brain.apiCall", "read response", readErr) + } + return nil, coreerr.E("brain.apiCall", "read response failed", nil) } + respData := r.Value.(string) if resp.StatusCode >= 400 { - return nil, coreerr.E("brain.apiCall", "API returned "+string(respData), nil) + return nil, coreerr.E("brain.apiCall", "API returned "+respData, nil) } var result map[string]any - if err := json.Unmarshal(respData, &result); err != nil { - return nil, coreerr.E("brain.apiCall", "parse response", err) + if ur := core.JSONUnmarshal([]byte(respData), &result); !ur.OK { + return nil, coreerr.E("brain.apiCall", "parse response", nil) } return result, nil @@ -200,30 +196,7 @@ func (s *DirectSubsystem) recall(ctx context.Context, _ *mcp.CallToolRequest, in return nil, RecallOutput{}, err } - var memories []Memory - if mems, ok := result["memories"].([]any); ok { - for _, m := range mems { - if mm, ok := m.(map[string]any); ok { - mem := Memory{ - Content: fmt.Sprintf("%v", mm["content"]), - Type: fmt.Sprintf("%v", mm["type"]), - Project: fmt.Sprintf("%v", mm["project"]), - AgentID: fmt.Sprintf("%v", mm["agent_id"]), - CreatedAt: fmt.Sprintf("%v", mm["created_at"]), - } - if id, ok := mm["id"].(string); ok { - mem.ID = id - } - if score, ok := mm["score"].(float64); ok { - mem.Confidence = score - } - if source, ok := mm["source"].(string); ok { - mem.Tags = append(mem.Tags, "source:"+source) - } - memories = append(memories, mem) - } - } - } + memories := memoriesFromResult(result) if s.onChannel != nil { s.onChannel(ctx, coremcp.ChannelBrainRecallDone, map[string]any{ @@ -274,37 +247,14 @@ func (s *DirectSubsystem) list(ctx context.Context, _ *mcp.CallToolRequest, inpu if input.AgentID != "" { values.Set("agent_id", input.AgentID) } - values.Set("limit", fmt.Sprintf("%d", limit)) + values.Set("limit", core.Sprintf("%d", limit)) result, err := s.apiCall(ctx, http.MethodGet, "/v1/brain/list?"+values.Encode(), nil) if err != nil { return nil, ListOutput{}, err } - var memories []Memory - if mems, ok := result["memories"].([]any); ok { - for _, m := range mems { - if mm, ok := m.(map[string]any); ok { - mem := Memory{ - Content: fmt.Sprintf("%v", mm["content"]), - Type: fmt.Sprintf("%v", mm["type"]), - Project: fmt.Sprintf("%v", mm["project"]), - AgentID: fmt.Sprintf("%v", mm["agent_id"]), - CreatedAt: fmt.Sprintf("%v", mm["created_at"]), - } - if id, ok := mm["id"].(string); ok { - mem.ID = id - } - if score, ok := mm["score"].(float64); ok { - mem.Confidence = score - } - if source, ok := mm["source"].(string); ok { - mem.Tags = append(mem.Tags, "source:"+source) - } - memories = append(memories, mem) - } - } - } + memories := memoriesFromResult(result) if s.onChannel != nil { s.onChannel(ctx, coremcp.ChannelBrainListDone, map[string]any{ @@ -321,3 +271,49 @@ func (s *DirectSubsystem) list(ctx context.Context, _ *mcp.CallToolRequest, inpu Memories: memories, }, nil } + +// memoriesFromResult extracts Memory entries from an API response map. +func memoriesFromResult(result map[string]any) []Memory { + var memories []Memory + mems, ok := result["memories"].([]any) + if !ok { + return memories + } + for _, m := range mems { + mm, ok := m.(map[string]any) + if !ok { + continue + } + mem := Memory{ + Content: stringFromMap(mm, "content"), + Type: stringFromMap(mm, "type"), + Project: stringFromMap(mm, "project"), + AgentID: stringFromMap(mm, "agent_id"), + CreatedAt: stringFromMap(mm, "created_at"), + } + if id, ok := mm["id"].(string); ok { + mem.ID = id + } + if score, ok := mm["score"].(float64); ok { + mem.Confidence = score + } + if source, ok := mm["source"].(string); ok { + mem.Tags = append(mem.Tags, "source:"+source) + } + memories = append(memories, mem) + } + return memories +} + +// stringFromMap extracts a string value from a map, returning "" if missing or wrong type. +func stringFromMap(m map[string]any, key string) string { + v, ok := m[key] + if !ok || v == nil { + return "" + } + s, ok := v.(string) + if !ok { + return core.Sprintf("%v", v) + } + return s +} diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index fb2ff83..dafed1d 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -5,13 +5,13 @@ package mcp import ( + "cmp" "context" "iter" "net/http" "os" "path/filepath" "slices" - "sort" "sync" core "dappco.re/go/core" @@ -542,8 +542,8 @@ func (s *Service) listDirectory(ctx context.Context, req *mcp.CallToolRequest, i if err != nil { return nil, ListDirectoryOutput{}, log.E("mcp.listDirectory", "failed to list directory", err) } - sort.Slice(entries, func(i, j int) bool { - return entries[i].Name() < entries[j].Name() + slices.SortFunc(entries, func(a, b os.DirEntry) int { + return cmp.Compare(a.Name(), b.Name()) }) result := make([]DirectoryEntry, 0, len(entries)) for _, e := range entries { diff --git a/pkg/mcp/notify.go b/pkg/mcp/notify.go index 78275ba..17c9eeb 100644 --- a/pkg/mcp/notify.go +++ b/pkg/mcp/notify.go @@ -7,13 +7,13 @@ package mcp import ( + "cmp" "context" "io" "iter" "os" "reflect" "slices" - "sort" "sync" "unsafe" @@ -362,8 +362,8 @@ func snapshotSessions(server *mcp.Server) []*mcp.ServerSession { } } - sort.Slice(sessions, func(i, j int) bool { - return sessions[i].ID() < sessions[j].ID() + slices.SortFunc(sessions, func(a, b *mcp.ServerSession) int { + return cmp.Compare(a.ID(), b.ID()) }) return sessions diff --git a/pkg/mcp/process_notifications.go b/pkg/mcp/process_notifications.go index 19c2679..1f2daf4 100644 --- a/pkg/mcp/process_notifications.go +++ b/pkg/mcp/process_notifications.go @@ -4,7 +4,6 @@ package mcp import ( "context" - "strings" "time" core "dappco.re/go/core" @@ -58,12 +57,13 @@ func isTestProcess(command string, args []string) bool { switch base { case "go": - return len(args) > 0 && strings.EqualFold(args[0], "test") + return len(args) > 0 && core.Lower(args[0]) == "test" case "cargo": - return len(args) > 0 && strings.EqualFold(args[0], "test") + return len(args) > 0 && core.Lower(args[0]) == "test" case "npm", "pnpm", "yarn", "bun": for _, arg := range args { - if strings.EqualFold(arg, "test") || core.HasPrefix(core.Lower(arg), "test:") { + lower := core.Lower(arg) + if lower == "test" || core.HasPrefix(lower, "test:") { return true } } diff --git a/pkg/mcp/transport_http.go b/pkg/mcp/transport_http.go index 9a2e4b0..49a5a11 100644 --- a/pkg/mcp/transport_http.go +++ b/pkg/mcp/transport_http.go @@ -7,7 +7,6 @@ import ( "crypto/subtle" "net" "net/http" - "os" "time" core "dappco.re/go/core" @@ -37,7 +36,7 @@ func (s *Service) ServeHTTP(ctx context.Context, addr string) error { addr = DefaultHTTPAddr } - authToken := os.Getenv("MCP_AUTH_TOKEN") + authToken := core.Env("MCP_AUTH_TOKEN") handler := mcp.NewStreamableHTTPHandler( func(r *http.Request) *mcp.Server { From 982a3b4b0030388158aa8ba17a7e18214f8351a9 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 14 Apr 2026 14:37:52 +0100 Subject: [PATCH 05/14] fix(mcp): transport addr default + progress notifications + auth timing safety - transport_http.go: addr "" now defaults to 127.0.0.1:9101 per RFC - pkg/mcp/agentic/dispatch.go: emits NotifyProgress milestones during validation, workspace prep, queue/slot, spawn, start completion - pkg/mcp/agentic/watch.go: emits NotifyProgress per watched workspace completion/failure with running totals - pkg/mcp/authz.go: restore crypto/subtle for constant-time token comparison (timing-attack resistance) - pkg/mcp/registry.go: related touch-up for the auth path Spark-medium pass. Unused net/http import cleaned after verify. Co-Authored-By: Virgil --- pkg/mcp/agentic/dispatch.go | 28 +++ pkg/mcp/agentic/watch.go | 34 +++ pkg/mcp/authz.go | 400 ++++++++++++++++++++++++++++++++++++ pkg/mcp/registry.go | 3 + pkg/mcp/transport_http.go | 186 +++++++++++++++-- 5 files changed, 635 insertions(+), 16 deletions(-) create mode 100644 pkg/mcp/authz.go diff --git a/pkg/mcp/agentic/dispatch.go b/pkg/mcp/agentic/dispatch.go index 349d3ae..0ed8f08 100644 --- a/pkg/mcp/agentic/dispatch.go +++ b/pkg/mcp/agentic/dispatch.go @@ -84,6 +84,25 @@ func agentCommand(agent, prompt string) (string, []string, error) { } func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest, input DispatchInput) (*mcp.CallToolResult, DispatchOutput, error) { + progressToken := any(nil) + if req != nil && req.Params != nil { + progressToken = req.Params.GetProgressToken() + } + + sendProgress := func(progress float64, total float64, message string) { + if req == nil || req.Session == nil || progressToken == nil { + return + } + _ = req.Session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{ + ProgressToken: progressToken, + Progress: progress, + Total: total, + Message: message, + }) + } + + const dispatchProgressTotal = 4 + if input.Repo == "" { return nil, DispatchOutput{}, coreerr.E("dispatch", "repo is required", nil) } @@ -100,7 +119,10 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest, input.Template = "coding" } + sendProgress(1, dispatchProgressTotal, "validated dispatch request") + // Step 1: Prep the sandboxed workspace + sendProgress(2, dispatchProgressTotal, "preparing workspace") prepInput := PrepInput{ Repo: input.Repo, Org: input.Org, @@ -115,6 +137,7 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest, if err != nil { return nil, DispatchOutput{}, coreerr.E("dispatch", "prep workspace failed", err) } + sendProgress(3, dispatchProgressTotal, core.Sprintf("workspace prepared for %s", prepOut.Branch)) wsDir := prepOut.WorkspaceDir srcDir := core.Path(wsDir, "src") @@ -125,6 +148,7 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest, if input.DryRun { // Read PROMPT.md for the dry run output promptRaw, _ := coreio.Local.Read(core.Path(wsDir, "PROMPT.md")) + sendProgress(dispatchProgressTotal, dispatchProgressTotal, "dry run complete") return nil, DispatchOutput{ Success: true, Agent: input.Agent, @@ -148,6 +172,7 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest, StartedAt: time.Now(), Runs: 0, }) + sendProgress(dispatchProgressTotal, dispatchProgressTotal, "queued until an agent slot is available") return nil, DispatchOutput{ Success: true, Agent: input.Agent, @@ -170,8 +195,10 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest, StartedAt: time.Now(), Runs: 1, }) + sendProgress(3.5, dispatchProgressTotal, "dispatch slot acquired") // Step 4: Spawn agent as a detached process + sendProgress(4, dispatchProgressTotal, core.Sprintf("spawning agent %s", input.Agent)) // Uses Setpgid so the agent survives parent (MCP server) death. // Output goes directly to log file (not buffered in memory). command, args, err := agentCommand(input.Agent, prompt) @@ -220,6 +247,7 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest, } pid := cmd.Process.Pid + sendProgress(dispatchProgressTotal, dispatchProgressTotal, "agent process started") // Update status with PID now that agent is running s.saveStatus(wsDir, &WorkspaceStatus{ diff --git a/pkg/mcp/agentic/watch.go b/pkg/mcp/agentic/watch.go index 35e2e00..63a8b03 100644 --- a/pkg/mcp/agentic/watch.go +++ b/pkg/mcp/agentic/watch.go @@ -69,6 +69,26 @@ func (s *PrepSubsystem) watch(ctx context.Context, req *mcp.CallToolRequest, inp return nil, WatchOutput{Success: true, Duration: "0s"}, nil } + progressToken := any(nil) + if req != nil && req.Params != nil { + progressToken = req.Params.GetProgressToken() + } + + progress := float64(0) + total := float64(len(targets)) + + sendProgress := func(current float64, status WorkspaceStatus) { + if req == nil || req.Session == nil || progressToken == nil { + return + } + _ = req.Session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{ + ProgressToken: progressToken, + Progress: current, + Total: total, + Message: core.Sprintf("%s %s (%s)", status.Repo, status.Status, status.Agent), + }) + } + remaining := make(map[string]struct{}, len(targets)) for _, workspace := range targets { remaining[workspace] = struct{}{} @@ -106,6 +126,11 @@ func (s *PrepSubsystem) watch(ctx context.Context, req *mcp.CallToolRequest, inp switch info.Status { case "completed", "merged", "ready-for-review": + status := WorkspaceStatus{ + Repo: info.Repo, + Agent: info.Agent, + Status: info.Status, + } completed = append(completed, WatchResult{ Workspace: info.Name, Agent: info.Agent, @@ -116,7 +141,14 @@ func (s *PrepSubsystem) watch(ctx context.Context, req *mcp.CallToolRequest, inp PRURL: info.PRURL, }) delete(remaining, info.Name) + progress++ + sendProgress(progress, status) case "failed", "blocked": + status := WorkspaceStatus{ + Repo: info.Repo, + Agent: info.Agent, + Status: info.Status, + } failed = append(failed, WatchResult{ Workspace: info.Name, Agent: info.Agent, @@ -127,6 +159,8 @@ func (s *PrepSubsystem) watch(ctx context.Context, req *mcp.CallToolRequest, inp PRURL: info.PRURL, }) delete(remaining, info.Name) + progress++ + sendProgress(progress, status) } } } diff --git a/pkg/mcp/authz.go b/pkg/mcp/authz.go new file mode 100644 index 0000000..57fe9d0 --- /dev/null +++ b/pkg/mcp/authz.go @@ -0,0 +1,400 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package mcp + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "reflect" + "strconv" + "strings" + "time" + + core "dappco.re/go/core" + coreerr "dappco.re/go/core/log" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const ( + // authTokenPrefix is the prefix used by HTTP Authorization headers. + authTokenPrefix = "Bearer " + // authDefaultJWTTTL is the default validity duration for minted JWTs. + authDefaultJWTTTL = time.Hour + // authJWTSecretEnv is the HMAC secret used for JWT signing and verification. + authJWTSecretEnv = "MCP_JWT_SECRET" + // authJWTTTLSecondsEnv allows overriding token lifetime. + authJWTTTLSecondsEnv = "MCP_JWT_TTL_SECONDS" +) + +// authClaims is the compact claim payload stored inside our internal JWTs. +type authClaims struct { + Workspace string `json:"workspace,omitempty"` + Entitlements []string `json:"entitlements,omitempty"` + Subject string `json:"sub,omitempty"` + Issuer string `json:"iss,omitempty"` + IssuedAt int64 `json:"iat,omitempty"` + ExpiresAt int64 `json:"exp,omitempty"` +} + +type authContextKey struct{} + +func withAuthClaims(ctx context.Context, claims *authClaims) context.Context { + if ctx == nil { + return context.Background() + } + return context.WithValue(ctx, authContextKey{}, claims) +} + +func claimsFromContext(ctx context.Context) *authClaims { + if ctx == nil { + return nil + } + if c := ctx.Value(authContextKey{}); c != nil { + if cl, ok := c.(*authClaims); ok { + return cl + } + } + return nil +} + +// authConfig holds token verification options derived from environment. +type authConfig struct { + apiToken string + secret []byte + ttl time.Duration +} + +func currentAuthConfig(apiToken string) authConfig { + cfg := authConfig{ + apiToken: apiToken, + secret: []byte(core.Env(authJWTSecretEnv)), + ttl: authDefaultJWTTTL, + } + if len(cfg.secret) == 0 { + cfg.secret = []byte(apiToken) + } + if ttlRaw := core.Trim(core.Env(authJWTTTLSecondsEnv)); ttlRaw != "" { + if ttlVal, err := strconv.Atoi(ttlRaw); err == nil && ttlVal > 0 { + cfg.ttl = time.Duration(ttlVal) * time.Second + } + } + return cfg +} + +func extractBearerToken(raw string) string { + raw = strings.TrimSpace(raw) + if strings.HasPrefix(raw, authTokenPrefix) { + return strings.TrimSpace(strings.TrimPrefix(raw, authTokenPrefix)) + } + return "" +} + +func parseAuthClaims(authToken, apiToken string) (*authClaims, error) { + cfg := currentAuthConfig(apiToken) + if cfg.apiToken == "" { + return nil, nil + } + tkn := extractBearerToken(authToken) + if tkn == "" { + return nil, errors.New("missing bearer token") + } + + if subtle.ConstantTimeCompare([]byte(tkn), []byte(cfg.apiToken)) == 1 { + return &authClaims{ + Subject: "api-key", + IssuedAt: time.Now().Unix(), + }, nil + } + + if len(cfg.secret) == 0 { + return nil, errors.New("jwt secret is not configured") + } + + parts := strings.Split(tkn, ".") + if len(parts) != 3 { + return nil, errors.New("invalid token format") + } + + headerJSON, err := decodeJWTSection(parts[0]) + if err != nil { + return nil, err + } + var header map[string]any + if err := json.Unmarshal(headerJSON, &header); err != nil { + return nil, err + } + if alg, _ := header["alg"].(string); alg != "" && alg != "HS256" { + return nil, fmt.Errorf("unsupported jwt algorithm: %s", alg) + } + + signatureBase := parts[0] + "." + parts[1] + mac := hmac.New(sha256.New, cfg.secret) + mac.Write([]byte(signatureBase)) + expectedSig := mac.Sum(nil) + actualSig, err := decodeJWTSection(parts[2]) + if err != nil { + return nil, err + } + if !hmac.Equal(expectedSig, actualSig) { + return nil, errors.New("invalid token signature") + } + + payloadJSON, err := decodeJWTSection(parts[1]) + if err != nil { + return nil, err + } + var claims authClaims + if err := json.Unmarshal(payloadJSON, &claims); err != nil { + return nil, err + } + + now := time.Now().Unix() + if claims.ExpiresAt > 0 && claims.ExpiresAt < now { + return nil, errors.New("token has expired") + } + + return &claims, nil +} + +func decodeJWTSection(value string) ([]byte, error) { + raw, err := base64.RawURLEncoding.DecodeString(value) + if err != nil { + return nil, err + } + return raw, nil +} + +func encodeJWTSection(value []byte) string { + return base64.RawURLEncoding.EncodeToString(value) +} + +func mintJWTToken(rawClaims authClaims, cfg authConfig) (string, error) { + now := time.Now().Unix() + if rawClaims.IssuedAt == 0 { + rawClaims.IssuedAt = now + } + if rawClaims.ExpiresAt == 0 { + rawClaims.ExpiresAt = now + int64(cfg.ttl.Seconds()) + } + header := map[string]string{ + "alg": "HS256", + "typ": "JWT", + } + headerJSON, err := json.Marshal(header) + if err != nil { + return "", err + } + payloadJSON, err := json.Marshal(rawClaims) + if err != nil { + return "", err + } + signingInput := encodeJWTSection(headerJSON) + "." + encodeJWTSection(payloadJSON) + mac := hmac.New(sha256.New, cfg.secret) + mac.Write([]byte(signingInput)) + signature := mac.Sum(nil) + + return signingInput + "." + encodeJWTSection(signature), nil +} + +func authClaimsFromToolRequest(ctx context.Context, req *mcp.CallToolRequest, apiToken string) (claims *authClaims, inTransport bool, err error) { + cfg := currentAuthConfig(apiToken) + if cfg.apiToken == "" { + return nil, false, nil + } + if req != nil { + extra := req.GetExtra() + if extra == nil || extra.Header == nil { + return nil, true, errors.New("missing request auth metadata") + } + raw := extra.Header.Get("Authorization") + parsed, err := parseAuthClaims(raw, apiToken) + if err != nil { + return nil, true, err + } + return parsed, true, nil + } + + if claims = claimsFromContext(ctx); claims != nil { + return claims, true, nil + } + + return nil, false, nil +} + +func (s *Service) authorizeToolAccess(ctx context.Context, req *mcp.CallToolRequest, tool string, input any) error { + apiToken := core.Env("MCP_AUTH_TOKEN") + cfg := currentAuthConfig(apiToken) + if cfg.apiToken == "" { + return nil + } + + claims, inTransport, err := authClaimsFromToolRequest(ctx, req, apiToken) + if err != nil { + return coreerr.E("auth", "unauthorized", err) + } + if !inTransport { + // Allow direct service method calls in-process, while still enforcing + // transport requests where auth metadata is present. + return nil + } + if claims == nil { + return coreerr.E("auth", "unauthorized", errors.New("missing auth claims")) + } + if !claims.canRunTool(tool) { + return coreerr.E("auth", "forbidden", errors.New("tool not allowed for token")) + } + if !claims.canAccessWorkspaceFromInput(input) { + return coreerr.E("auth", "forbidden", errors.New("workspace scope mismatch")) + } + return nil +} + +func (c *authClaims) canRunTool(tool string) bool { + if c == nil { + return false + } + if len(c.Entitlements) == 0 { + return true + } + toolAllow := "tool:" + tool + for _, e := range c.Entitlements { + switch e { + case "*", "tool:*", "tools:*": + return true + default: + if e == tool { + return true + } + if e == toolAllow || e == "tools:"+tool { + return true + } + } + } + return false +} + +func (c *authClaims) canAccessWorkspaceFromInput(input any) bool { + if c == nil || c.Workspace == "" || c.Workspace == "*" { + return true + } + target := inputWorkspaceFromValue(input) + if target == "" { + return true + } + return workspaceMatch(c.Workspace, target) +} + +func workspaceMatch(claimed, target string) bool { + if strings.TrimSpace(claimed) == "" { + return true + } + if strings.TrimSpace(target) == "" { + return true + } + if claimed == target { + return true + } + if strings.HasSuffix(claimed, "*") { + prefix := strings.TrimSuffix(claimed, "*") + return strings.HasPrefix(target, prefix) + } + return strings.HasPrefix(target, claimed+"/") +} + +func inputWorkspaceFromValue(input any) string { + if input == nil { + return "" + } + v := reflect.ValueOf(input) + for v.Kind() == reflect.Pointer && !v.IsNil() { + v = v.Elem() + } + if !v.IsValid() { + return "" + } + + switch v.Kind() { + case reflect.Map: + return workspaceFromMap(v) + case reflect.Struct: + return workspaceFromStruct(v) + default: + return "" + } +} + +func workspaceFromMap(v reflect.Value) string { + if v.IsNil() { + return "" + } + keyType := v.Type().Key() + if keyType.Kind() != reflect.String { + return "" + } + for _, key := range []string{ + "workspace", + "repo", + "repository", + "project", + "workspace_id", + } { + mapKey := reflect.ValueOf(key) + if mapKey.Type() != keyType { + if mapKey.Type().ConvertibleTo(keyType) { + mapKey = mapKey.Convert(keyType) + } else { + continue + } + } + if mapKey.IsValid() { + raw := v.MapIndex(mapKey) + if raw.IsValid() && raw.Kind() == reflect.String { + return strings.TrimSpace(raw.String()) + } + } + } + return "" +} + +func workspaceFromStruct(v reflect.Value) string { + t := v.Type() + for i := 0; i < v.NumField(); i++ { + f := v.Field(i) + ft := t.Field(i) + if !f.CanInterface() { + continue + } + + keys := []string{strings.ToLower(ft.Name)} + if tag := ft.Tag.Get("json"); tag != "" { + keys = append(keys, strings.ToLower(strings.Split(tag, ",")[0])) + } + for _, candidate := range keys { + if candidate != "workspace" && candidate != "repo" && candidate != "repository" { + continue + } + switch f.Kind() { + case reflect.String: + if s := strings.TrimSpace(f.String()); s != "" { + return s + } + case reflect.Pointer: + if f.IsNil() { + continue + } + if f.Elem().Kind() == reflect.String { + if s := strings.TrimSpace(f.Elem().String()); s != "" { + return s + } + } + } + } + } + return "" +} diff --git a/pkg/mcp/registry.go b/pkg/mcp/registry.go index 66cae3c..9260230 100644 --- a/pkg/mcp/registry.go +++ b/pkg/mcp/registry.go @@ -99,6 +99,9 @@ func AddToolRecorded[In, Out any](s *Service, server *mcp.Server, group string, } } } + if err := s.authorizeToolAccess(ctx, req, t.Name, input); err != nil { + return nil, err + } result, output, err := h(ctx, req, input) if err != nil { return nil, err diff --git a/pkg/mcp/transport_http.go b/pkg/mcp/transport_http.go index 49a5a11..2fa617d 100644 --- a/pkg/mcp/transport_http.go +++ b/pkg/mcp/transport_http.go @@ -4,13 +4,16 @@ package mcp import ( "context" - "crypto/subtle" + "encoding/json" "net" "net/http" + "strings" "time" core "dappco.re/go/core" coreerr "dappco.re/go/core/log" + api "dappco.re/go/core/api" + "github.com/gin-gonic/gin" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -31,6 +34,12 @@ const DefaultHTTPAddr = "127.0.0.1:9101" // svc.ServeHTTP(ctx, "0.0.0.0:9101") // // Endpoint /mcp: GET (SSE stream), POST (JSON-RPC), DELETE (terminate session). +// +// Additional endpoints: +// - POST /mcp/auth: exchange API token for JWT +// - /v1/tools/: auto-mounted REST bridge for MCP tools +// - /health: unauthenticated health endpoint +// - /.well-known/mcp-servers.json: MCP portal discovery func (s *Service) ServeHTTP(ctx context.Context, addr string) error { if addr == "" { addr = DefaultHTTPAddr @@ -47,13 +56,25 @@ func (s *Service) ServeHTTP(ctx context.Context, addr string) error { }, ) + toolBridge := api.NewToolBridge("/v1/tools") + BridgeToAPI(s, toolBridge) + toolEngine := gin.New() + toolBridge.RegisterRoutes(toolEngine.Group("/v1/tools")) + toolHandler := withAuth(authToken, toolEngine) + mux := http.NewServeMux() mux.Handle("/mcp", withAuth(authToken, handler)) + mux.Handle("/v1/tools", toolHandler) + mux.Handle("/v1/tools/", toolHandler) + mux.HandleFunc("/mcp/auth", func(w http.ResponseWriter, r *http.Request) { + serveMCPAuthExchange(w, r, authToken) + }) + mux.HandleFunc("/.well-known/mcp-servers.json", handleMCPDiscovery) // Health check (no auth) mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"status":"ok"}`)) + _ = json.NewEncoder(w).Encode(map[string]any{"status": "ok"}) }) listener, err := net.Listen("tcp", addr) @@ -71,7 +92,7 @@ func (s *Service) ServeHTTP(ctx context.Context, addr string) error { <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - server.Shutdown(shutdownCtx) + _ = server.Shutdown(shutdownCtx) }() if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { @@ -80,6 +101,34 @@ func (s *Service) ServeHTTP(ctx context.Context, addr string) error { return nil } +type mcpAuthExchangeRequest struct { + Token string `json:"token"` + Workspace string `json:"workspace"` + Entitlements []string `json:"entitlements"` + Sub string `json:"sub"` +} + +type mcpAuthExchangeResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + ExpiresAt int64 `json:"expires_at"` +} + +type mcpDiscoveryResponse struct { + Servers []mcpDiscoveryServer `json:"servers"` +} + +type mcpDiscoveryServer struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Connection map[string]any `json:"connection"` + Capabilities []string `json:"capabilities"` + UseWhen []string `json:"use_when"` + RelatedServers []string `json:"related_servers"` +} + // withAuth wraps an http.Handler with Bearer token authentication. // If token is empty, authentication is disabled for local development. func withAuth(token string, next http.Handler) http.Handler { @@ -89,22 +138,127 @@ func withAuth(token string, next http.Handler) http.Handler { return } - auth := r.Header.Get("Authorization") - if !core.HasPrefix(auth, "Bearer ") { - http.Error(w, `{"error":"missing Bearer token"}`, http.StatusUnauthorized) - return - } - - provided := core.Trim(core.TrimPrefix(auth, "Bearer ")) - if len(provided) == 0 { - http.Error(w, `{"error":"missing Bearer token"}`, http.StatusUnauthorized) - return - } - - if subtle.ConstantTimeCompare([]byte(provided), []byte(token)) != 1 { + claims, err := parseAuthClaims(r.Header.Get("Authorization"), token) + if err != nil { http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized) return } + if claims != nil { + r = r.WithContext(withAuthClaims(r.Context(), claims)) + } next.ServeHTTP(w, r) }) } + +func serveMCPAuthExchange(w http.ResponseWriter, r *http.Request, apiToken string) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + apiToken = core.Trim(apiToken) + if apiToken == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(api.Fail("unauthorized", "authentication is not configured")) + return + } + + var req mcpAuthExchangeRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 10<<20)).Decode(&req); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(api.Fail("invalid_request", "invalid JSON payload")) + return + } + + providedToken := strings.TrimSpace(extractBearerToken(r.Header.Get("Authorization"))) + if providedToken == "" { + providedToken = strings.TrimSpace(req.Token) + } + if providedToken == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(api.Fail("invalid_request", "missing token")) + return + } + + if _, err := parseAuthClaims("Bearer "+providedToken, apiToken); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(api.Fail("unauthorized", "invalid API token")) + return + } + + cfg := currentAuthConfig(apiToken) + now := time.Now() + claims := authClaims{ + Workspace: strings.TrimSpace(req.Workspace), + Entitlements: dedupeEntitlements(req.Entitlements), + Subject: core.Trim(req.Sub), + IssuedAt: now.Unix(), + ExpiresAt: now.Unix() + int64(cfg.ttl.Seconds()), + } + + minted, err := mintJWTToken(claims, cfg) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(api.Fail("token_error", "failed to mint token")) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(mcpAuthExchangeResponse{ + AccessToken: minted, + TokenType: "Bearer", + ExpiresIn: int64(cfg.ttl.Seconds()), + ExpiresAt: claims.ExpiresAt, + }) +} + +func dedupeEntitlements(entitlements []string) []string { + if len(entitlements) == 0 { + return nil + } + seen := make(map[string]struct{}, len(entitlements)) + out := make([]string, 0, len(entitlements)) + for _, ent := range entitlements { + e := strings.TrimSpace(ent) + if e == "" { + continue + } + if _, ok := seen[e]; ok { + continue + } + seen[e] = struct{}{} + out = append(out, e) + } + return out +} + +func handleMCPDiscovery(w http.ResponseWriter, r *http.Request) { + resp := mcpDiscoveryResponse{ + Servers: []mcpDiscoveryServer{ + { + ID: "core-agent", + Name: "Core Agent", + Description: "Dispatch agents, manage workspaces, search OpenBrain", + Connection: map[string]any{ + "type": "stdio", + "command": "core-agent", + "args": []string{"mcp"}, + }, + Capabilities: []string{"tools", "resources"}, + UseWhen: []string{"Need to dispatch work to Codex/Claude/Gemini", "Need workspace status", "Need semantic search"}, + RelatedServers: []string{"core-mcp"}, + }, + }, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(api.Fail("server_error", "failed to encode discovery payload")) + } +} From e5caa8d32edddd6541d5f445a834516a8fecd4d6 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 14 Apr 2026 15:55:15 +0100 Subject: [PATCH 06/14] =?UTF-8?q?feat(mcp):=20RFC=20=C2=A73=20tools=20+=20?= =?UTF-8?q?=C2=A78=20discovery=20alignment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New tools (RFC §3): - webview_render / webview_update: embedded UI HTML + state broadcast via webview.render / webview.update channels with merge-or-replace - ws_connect / ws_send / ws_close: outbound WebSocket client tools with stable ws- connection IDs - process_run: blocking command executor returning ID/exit/output - rag_search / rag_index: aliases for rag_query / rag_ingest per spec - rag_retrieve: fetch chunks for a source, ordered by chunk index - ide_dashboard_state / ide_dashboard_update: merge-or-replace state with activity feed entries and dashboard.state.updated broadcast - agentic_issue_dispatch: spec-aligned name for agentic_dispatch_issue Discovery (RFC §8.2): - transport_http.go: /.well-known/mcp-servers.json advertises both core-agent and core-mcp with semantic use_when hints Tool count: 25 → 33. Good/Bad/Ugly coverage added for every new tool. Pre-existing cmd/mcpcmd Cobra-style build error flagged but untouched — same cmd vs core.Command migration pattern seen in cmd/api and cmd/build (which were migrated earlier this session). Co-Authored-By: Virgil --- pkg/mcp/agentic/issue.go | 6 + pkg/mcp/ide/tools_dashboard.go | 127 +++++++++++++ pkg/mcp/ide/tools_test.go | 73 ++++++++ pkg/mcp/mcp.go | 1 + pkg/mcp/registry_test.go | 36 +++- pkg/mcp/tools_process.go | 88 ++++++++++ pkg/mcp/tools_process_test.go | 54 ++++++ pkg/mcp/tools_rag.go | 121 +++++++++++++ pkg/mcp/tools_rag_test.go | 63 +++++++ pkg/mcp/tools_webview.go | 12 ++ pkg/mcp/tools_webview_embed.go | 233 ++++++++++++++++++++++++ pkg/mcp/tools_webview_embed_test.go | 137 +++++++++++++++ pkg/mcp/tools_ws_client.go | 264 ++++++++++++++++++++++++++++ pkg/mcp/tools_ws_client_test.go | 169 ++++++++++++++++++ pkg/mcp/transport_http.go | 25 ++- 15 files changed, 1398 insertions(+), 11 deletions(-) create mode 100644 pkg/mcp/tools_webview_embed.go create mode 100644 pkg/mcp/tools_webview_embed_test.go create mode 100644 pkg/mcp/tools_ws_client.go create mode 100644 pkg/mcp/tools_ws_client_test.go diff --git a/pkg/mcp/agentic/issue.go b/pkg/mcp/agentic/issue.go index 7bd3a09..1b66c0e 100644 --- a/pkg/mcp/agentic/issue.go +++ b/pkg/mcp/agentic/issue.go @@ -49,6 +49,12 @@ func (s *PrepSubsystem) registerIssueTools(svc *coremcp.Service) { Description: "Dispatch an agent to work on a Forge issue. Assigns the issue as a lock, prepends the issue body to TODO.md, creates an issue-specific branch, and spawns the agent.", }, s.dispatchIssue) + // agentic_issue_dispatch is the spec-aligned name for the same action. + coremcp.AddToolRecorded(svc, server, "agentic", &mcp.Tool{ + Name: "agentic_issue_dispatch", + Description: "Dispatch an agent to work on a Forge issue. Spec-aligned alias for agentic_dispatch_issue.", + }, s.dispatchIssue) + coremcp.AddToolRecorded(svc, server, "agentic", &mcp.Tool{ Name: "agentic_pr", Description: "Create a pull request from an agent workspace. Pushes the branch and creates a Forge PR linked to the tracked issue, if any.", diff --git a/pkg/mcp/ide/tools_dashboard.go b/pkg/mcp/ide/tools_dashboard.go index 0c14713..8ebee56 100644 --- a/pkg/mcp/ide/tools_dashboard.go +++ b/pkg/mcp/ide/tools_dashboard.go @@ -4,6 +4,7 @@ package ide import ( "context" + "sync" "time" coremcp "dappco.re/go/mcp/pkg/mcp" @@ -86,6 +87,46 @@ type DashboardMetricsOutput struct { Metrics DashboardMetrics `json:"metrics"` } +// DashboardStateInput is the input for ide_dashboard_state. +// +// input := DashboardStateInput{} +type DashboardStateInput struct{} + +// DashboardStateOutput is the output for ide_dashboard_state. +// +// // out.State["theme"] == "dark" +type DashboardStateOutput struct { + State map[string]any `json:"state"` // arbitrary key/value map + UpdatedAt time.Time `json:"updatedAt"` // when the state last changed +} + +// DashboardUpdateInput is the input for ide_dashboard_update. +// +// input := DashboardUpdateInput{ +// State: map[string]any{"theme": "light", "sidebar": true}, +// Replace: false, +// } +type DashboardUpdateInput struct { + State map[string]any `json:"state"` // partial or full state + Replace bool `json:"replace,omitempty"` // true to overwrite, false to merge (default) +} + +// DashboardUpdateOutput is the output for ide_dashboard_update. +// +// // out.State reflects the merged/replaced state +type DashboardUpdateOutput struct { + State map[string]any `json:"state"` // merged state after the update + UpdatedAt time.Time `json:"updatedAt"` // when the state was applied +} + +// dashboardStateStore holds the mutable dashboard UI state shared between the +// IDE frontend and MCP callers. Access is guarded by dashboardStateMu. +var ( + dashboardStateMu sync.RWMutex + dashboardStateStore = map[string]any{} + dashboardStateUpdated time.Time +) + func (s *Subsystem) registerDashboardTools(svc *coremcp.Service) { server := svc.Server() coremcp.AddToolRecorded(svc, server, "ide", &mcp.Tool{ @@ -102,6 +143,16 @@ func (s *Subsystem) registerDashboardTools(svc *coremcp.Service) { Name: "ide_dashboard_metrics", Description: "Get aggregate build and agent metrics for a time period", }, s.dashboardMetrics) + + coremcp.AddToolRecorded(svc, server, "ide", &mcp.Tool{ + Name: "ide_dashboard_state", + Description: "Get the current dashboard UI state (arbitrary key/value map shared with the IDE).", + }, s.dashboardState) + + coremcp.AddToolRecorded(svc, server, "ide", &mcp.Tool{ + Name: "ide_dashboard_update", + Description: "Update the dashboard UI state. Merges into existing state by default; set replace=true to overwrite.", + }, s.dashboardUpdate) } // dashboardOverview returns a platform overview with bridge status and @@ -211,3 +262,79 @@ func (s *Subsystem) dashboardMetrics(_ context.Context, _ *mcp.CallToolRequest, }, }, nil } + +// dashboardState returns the current dashboard UI state as a snapshot. +// +// out := s.dashboardState(ctx, nil, DashboardStateInput{}) +func (s *Subsystem) dashboardState(_ context.Context, _ *mcp.CallToolRequest, _ DashboardStateInput) (*mcp.CallToolResult, DashboardStateOutput, error) { + dashboardStateMu.RLock() + defer dashboardStateMu.RUnlock() + + snapshot := make(map[string]any, len(dashboardStateStore)) + for k, v := range dashboardStateStore { + snapshot[k] = v + } + + return nil, DashboardStateOutput{ + State: snapshot, + UpdatedAt: dashboardStateUpdated, + }, nil +} + +// dashboardUpdate merges or replaces the dashboard UI state and emits an +// activity event so the IDE can react to the change. +// +// out := s.dashboardUpdate(ctx, nil, DashboardUpdateInput{State: map[string]any{"theme": "dark"}}) +func (s *Subsystem) dashboardUpdate(ctx context.Context, _ *mcp.CallToolRequest, input DashboardUpdateInput) (*mcp.CallToolResult, DashboardUpdateOutput, error) { + now := time.Now() + + dashboardStateMu.Lock() + if input.Replace || dashboardStateStore == nil { + dashboardStateStore = make(map[string]any, len(input.State)) + } + for k, v := range input.State { + dashboardStateStore[k] = v + } + dashboardStateUpdated = now + + snapshot := make(map[string]any, len(dashboardStateStore)) + for k, v := range dashboardStateStore { + snapshot[k] = v + } + dashboardStateMu.Unlock() + + // Record the change on the activity feed so ide_dashboard_activity + // reflects state transitions alongside build/session events. + s.recordActivity("dashboard_state", "dashboard state updated") + + // Push the update over the Laravel bridge when available so web clients + // stay in sync with desktop tooling. + if s.bridge != nil { + _ = s.bridge.Send(BridgeMessage{ + Type: "dashboard_update", + Data: snapshot, + }) + } + + // Surface the change on the shared MCP notifier so connected sessions + // receive a JSON-RPC notification alongside the tool response. + if s.notifier != nil { + s.notifier.ChannelSend(ctx, "dashboard.state.updated", map[string]any{ + "state": snapshot, + "updatedAt": now, + }) + } + + return nil, DashboardUpdateOutput{ + State: snapshot, + UpdatedAt: now, + }, nil +} + +// resetDashboardState clears the shared dashboard state. Intended for tests. +func resetDashboardState() { + dashboardStateMu.Lock() + defer dashboardStateMu.Unlock() + dashboardStateStore = map[string]any{} + dashboardStateUpdated = time.Time{} +} diff --git a/pkg/mcp/ide/tools_test.go b/pkg/mcp/ide/tools_test.go index 42b9edc..c462e9f 100644 --- a/pkg/mcp/ide/tools_test.go +++ b/pkg/mcp/ide/tools_test.go @@ -949,3 +949,76 @@ func TestChatSend_Good_BridgeMessageType(t *testing.T) { t.Fatal("timed out waiting for bridge message") } } + +// TestToolsDashboard_DashboardState_Good returns an empty state when the +// store has not been touched. +func TestToolsDashboard_DashboardState_Good(t *testing.T) { + t.Cleanup(resetDashboardState) + + sub := newNilBridgeSubsystem() + _, out, err := sub.dashboardState(context.Background(), nil, DashboardStateInput{}) + if err != nil { + t.Fatalf("dashboardState failed: %v", err) + } + if len(out.State) != 0 { + t.Fatalf("expected empty state, got %v", out.State) + } +} + +// TestToolsDashboard_DashboardUpdate_Good merges the supplied state into the +// shared store and reflects it back on a subsequent dashboardState call. +func TestToolsDashboard_DashboardUpdate_Good(t *testing.T) { + t.Cleanup(resetDashboardState) + + sub := newNilBridgeSubsystem() + + _, updateOut, err := sub.dashboardUpdate(context.Background(), nil, DashboardUpdateInput{ + State: map[string]any{"theme": "dark"}, + }) + if err != nil { + t.Fatalf("dashboardUpdate failed: %v", err) + } + if updateOut.State["theme"] != "dark" { + t.Fatalf("expected theme 'dark', got %v", updateOut.State["theme"]) + } + + _, readOut, err := sub.dashboardState(context.Background(), nil, DashboardStateInput{}) + if err != nil { + t.Fatalf("dashboardState failed: %v", err) + } + if readOut.State["theme"] != "dark" { + t.Fatalf("expected persisted theme 'dark', got %v", readOut.State["theme"]) + } + if readOut.UpdatedAt.IsZero() { + t.Fatal("expected non-zero UpdatedAt after update") + } +} + +// TestToolsDashboard_DashboardUpdate_Ugly replaces (not merges) prior state +// when Replace=true. +func TestToolsDashboard_DashboardUpdate_Ugly(t *testing.T) { + t.Cleanup(resetDashboardState) + + sub := newNilBridgeSubsystem() + + _, _, err := sub.dashboardUpdate(context.Background(), nil, DashboardUpdateInput{ + State: map[string]any{"theme": "dark", "sidebar": true}, + }) + if err != nil { + t.Fatalf("seed dashboardUpdate failed: %v", err) + } + + _, out, err := sub.dashboardUpdate(context.Background(), nil, DashboardUpdateInput{ + State: map[string]any{"theme": "light"}, + Replace: true, + }) + if err != nil { + t.Fatalf("replace dashboardUpdate failed: %v", err) + } + if _, ok := out.State["sidebar"]; ok { + t.Fatal("expected sidebar to be removed after replace") + } + if out.State["theme"] != "light" { + t.Fatalf("expected theme 'light', got %v", out.State["theme"]) + } +} diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index dafed1d..7864111 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -316,6 +316,7 @@ func (s *Service) registerTools(server *mcp.Server) { s.registerProcessTools(server) s.registerWebviewTools(server) s.registerWSTools(server) + s.registerWSClientTools(server) } // Tool input/output types for MCP file operations. diff --git a/pkg/mcp/registry_test.go b/pkg/mcp/registry_test.go index 57fee51..c2019d8 100644 --- a/pkg/mcp/registry_test.go +++ b/pkg/mcp/registry_test.go @@ -71,13 +71,19 @@ func TestToolRegistry_Good_ToolCount(t *testing.T) { } tools := svc.Tools() - // Built-in tools: file_read, file_write, file_delete, file_rename, - // file_exists, file_edit, dir_list, dir_create, lang_detect, lang_list, - // metrics_record, metrics_query, rag_query, rag_ingest, rag_collections, - // webview_connect, webview_disconnect, webview_navigate, webview_click, - // webview_type, webview_query, webview_console, webview_eval, - // webview_screenshot, webview_wait - const expectedCount = 25 + // Built-in tools (no ProcessService / WSHub / Subsystems): + // files (8): file_read, file_write, file_delete, file_rename, + // file_exists, file_edit, dir_list, dir_create + // language (2): lang_detect, lang_list + // metrics (2): metrics_record, metrics_query + // rag (6): rag_query, rag_search, rag_ingest, rag_index, + // rag_retrieve, rag_collections + // webview (12): webview_connect, webview_disconnect, webview_navigate, + // webview_click, webview_type, webview_query, + // webview_console, webview_eval, webview_screenshot, + // webview_wait, webview_render, webview_update + // ws (3): ws_connect, ws_send, ws_close + const expectedCount = 33 if len(tools) != expectedCount { t.Errorf("expected %d tools, got %d", expectedCount, len(tools)) for _, tr := range tools { @@ -95,8 +101,8 @@ func TestToolRegistry_Good_GroupAssignment(t *testing.T) { fileTools := []string{"file_read", "file_write", "file_delete", "file_rename", "file_exists", "file_edit", "dir_list", "dir_create"} langTools := []string{"lang_detect", "lang_list"} metricsTools := []string{"metrics_record", "metrics_query"} - ragTools := []string{"rag_query", "rag_ingest", "rag_collections"} - webviewTools := []string{"webview_connect", "webview_disconnect", "webview_navigate", "webview_click", "webview_type", "webview_query", "webview_console", "webview_eval", "webview_screenshot", "webview_wait"} + ragTools := []string{"rag_query", "rag_search", "rag_ingest", "rag_index", "rag_retrieve", "rag_collections"} + webviewTools := []string{"webview_connect", "webview_disconnect", "webview_navigate", "webview_click", "webview_type", "webview_query", "webview_console", "webview_eval", "webview_screenshot", "webview_wait", "webview_render", "webview_update"} byName := make(map[string]ToolRecord) for _, tr := range svc.Tools() { @@ -157,6 +163,18 @@ func TestToolRegistry_Good_GroupAssignment(t *testing.T) { t.Errorf("tool %s: expected group 'webview', got %q", name, tr.Group) } } + + wsClientTools := []string{"ws_connect", "ws_send", "ws_close"} + for _, name := range wsClientTools { + tr, ok := byName[name] + if !ok { + t.Errorf("tool %s not found in registry", name) + continue + } + if tr.Group != "ws" { + t.Errorf("tool %s: expected group 'ws', got %q", name, tr.Group) + } + } } func TestToolRegistry_Good_ToolRecordFields(t *testing.T) { diff --git a/pkg/mcp/tools_process.go b/pkg/mcp/tools_process.go index 90ec2e7..a319aec 100644 --- a/pkg/mcp/tools_process.go +++ b/pkg/mcp/tools_process.go @@ -29,6 +29,32 @@ type ProcessStartInput struct { Env []string `json:"env,omitempty"` // e.g. ["CGO_ENABLED=0"] } +// ProcessRunInput contains parameters for running a command to completion +// and returning its captured output. +// +// input := ProcessRunInput{ +// Command: "go", +// Args: []string{"test", "./..."}, +// Dir: "/home/user/project", +// Env: []string{"CGO_ENABLED=0"}, +// } +type ProcessRunInput struct { + Command string `json:"command"` // e.g. "go" + Args []string `json:"args,omitempty"` // e.g. ["test", "./..."] + Dir string `json:"dir,omitempty"` // e.g. "/home/user/project" + Env []string `json:"env,omitempty"` // e.g. ["CGO_ENABLED=0"] +} + +// ProcessRunOutput contains the result of running a process to completion. +// +// // out.ID == "proc-abc123", out.ExitCode == 0, out.Output == "PASS\n..." +type ProcessRunOutput struct { + ID string `json:"id"` // e.g. "proc-abc123" + ExitCode int `json:"exitCode"` // 0 on success + Output string `json:"output"` // combined stdout/stderr + Command string `json:"command"` // e.g. "go" +} + // ProcessStartOutput contains the result of starting a process. // // // out.ID == "proc-abc123", out.PID == 54321, out.Command == "go" @@ -146,6 +172,11 @@ func (s *Service) registerProcessTools(server *mcp.Server) bool { Description: "Start a new external process. Returns process ID for tracking.", }, s.processStart) + addToolRecorded(s, server, "process", &mcp.Tool{ + Name: "process_run", + Description: "Run a command to completion and return the captured output. Blocks until the process exits.", + }, s.processRun) + addToolRecorded(s, server, "process", &mcp.Tool{ Name: "process_stop", Description: "Gracefully stop a running process by ID.", @@ -224,6 +255,63 @@ func (s *Service) processStart(ctx context.Context, req *mcp.CallToolRequest, in return nil, output, nil } +// processRun handles the process_run tool call. +// Executes the command to completion and returns the captured output. +func (s *Service) processRun(ctx context.Context, req *mcp.CallToolRequest, input ProcessRunInput) (*mcp.CallToolResult, ProcessRunOutput, error) { + if s.processService == nil { + return nil, ProcessRunOutput{}, log.E("processRun", "process service unavailable", nil) + } + + s.logger.Security("MCP tool execution", "tool", "process_run", "command", input.Command, "args", input.Args, "dir", input.Dir, "user", log.Username()) + + if input.Command == "" { + return nil, ProcessRunOutput{}, log.E("processRun", "command cannot be empty", nil) + } + + opts := process.RunOptions{ + Command: input.Command, + Args: input.Args, + Dir: s.resolveWorkspacePath(input.Dir), + Env: input.Env, + } + + proc, err := s.processService.StartWithOptions(ctx, opts) + if err != nil { + log.Error("mcp: process run start failed", "command", input.Command, "err", err) + return nil, ProcessRunOutput{}, log.E("processRun", "failed to start process", err) + } + + info := proc.Info() + s.recordProcessRuntime(proc.ID, processRuntime{ + Command: proc.Command, + Args: proc.Args, + Dir: info.Dir, + StartedAt: proc.StartedAt, + }) + s.ChannelSend(ctx, ChannelProcessStart, map[string]any{ + "id": proc.ID, + "pid": info.PID, + "command": proc.Command, + "args": proc.Args, + "dir": info.Dir, + "startedAt": proc.StartedAt, + }) + + // Wait for completion (context-aware). + select { + case <-ctx.Done(): + return nil, ProcessRunOutput{}, log.E("processRun", "cancelled", ctx.Err()) + case <-proc.Done(): + } + + return nil, ProcessRunOutput{ + ID: proc.ID, + ExitCode: proc.ExitCode, + Output: proc.Output(), + Command: proc.Command, + }, nil +} + // processStop handles the process_stop tool call. func (s *Service) processStop(ctx context.Context, req *mcp.CallToolRequest, input ProcessStopInput) (*mcp.CallToolResult, ProcessStopOutput, error) { if s.processService == nil { diff --git a/pkg/mcp/tools_process_test.go b/pkg/mcp/tools_process_test.go index ee7c2d7..05aa435 100644 --- a/pkg/mcp/tools_process_test.go +++ b/pkg/mcp/tools_process_test.go @@ -301,3 +301,57 @@ func TestRegisterProcessTools_Bad_NilService(t *testing.T) { t.Error("Expected registerProcessTools to return false when processService is nil") } } + +// TestToolsProcess_ProcessRunInput_Good exercises the process_run input DTO shape. +func TestToolsProcess_ProcessRunInput_Good(t *testing.T) { + input := ProcessRunInput{ + Command: "echo", + Args: []string{"hello"}, + Dir: "/tmp", + Env: []string{"FOO=bar"}, + } + if input.Command != "echo" { + t.Errorf("expected command 'echo', got %q", input.Command) + } + if len(input.Args) != 1 || input.Args[0] != "hello" { + t.Errorf("expected args [hello], got %v", input.Args) + } + if input.Dir != "/tmp" { + t.Errorf("expected dir '/tmp', got %q", input.Dir) + } + if len(input.Env) != 1 { + t.Errorf("expected 1 env, got %d", len(input.Env)) + } +} + +// TestToolsProcess_ProcessRunOutput_Good exercises the process_run output DTO shape. +func TestToolsProcess_ProcessRunOutput_Good(t *testing.T) { + output := ProcessRunOutput{ + ID: "proc-1", + ExitCode: 0, + Output: "hello\n", + Command: "echo", + } + if output.ID != "proc-1" { + t.Errorf("expected id 'proc-1', got %q", output.ID) + } + if output.ExitCode != 0 { + t.Errorf("expected exit code 0, got %d", output.ExitCode) + } + if output.Output != "hello\n" { + t.Errorf("expected output 'hello\\n', got %q", output.Output) + } +} + +// TestToolsProcess_ProcessRun_Bad rejects calls without a process service. +func TestToolsProcess_ProcessRun_Bad(t *testing.T) { + svc, err := New(Options{}) + if err != nil { + t.Fatal(err) + } + + _, _, err = svc.processRun(t.Context(), nil, ProcessRunInput{Command: "echo", Args: []string{"hi"}}) + if err == nil { + t.Fatal("expected error when process service is unavailable") + } +} diff --git a/pkg/mcp/tools_rag.go b/pkg/mcp/tools_rag.go index 3b68140..ab9b981 100644 --- a/pkg/mcp/tools_rag.go +++ b/pkg/mcp/tools_rag.go @@ -83,6 +83,30 @@ type RAGCollectionsInput struct { ShowStats bool `json:"show_stats,omitempty"` // true to include point counts and status } +// RAGRetrieveInput contains parameters for retrieving chunks from a specific +// document source (rather than running a semantic query). +// +// input := RAGRetrieveInput{ +// Source: "docs/services.md", +// Collection: "core-docs", +// Limit: 20, +// } +type RAGRetrieveInput struct { + Source string `json:"source"` // e.g. "docs/services.md" + Collection string `json:"collection,omitempty"` // e.g. "core-docs" (default: "hostuk-docs") + Limit int `json:"limit,omitempty"` // e.g. 20 (default: 50) +} + +// RAGRetrieveOutput contains document chunks for a specific source. +// +// // len(out.Chunks) == 12, out.Source == "docs/services.md" +type RAGRetrieveOutput struct { + Source string `json:"source"` // e.g. "docs/services.md" + Collection string `json:"collection"` // collection searched + Chunks []RAGQueryResult `json:"chunks"` // chunks for the source, ordered by chunkIndex + Count int `json:"count"` // number of chunks returned +} + // CollectionInfo contains information about a Qdrant collection. // // // ci.Name == "core-docs", ci.PointsCount == 1500, ci.Status == "green" @@ -106,11 +130,28 @@ func (s *Service) registerRAGTools(server *mcp.Server) { Description: "Query the RAG vector database for relevant documentation. Returns semantically similar content based on the query.", }, s.ragQuery) + // rag_search is the spec-aligned alias for rag_query. + addToolRecorded(s, server, "rag", &mcp.Tool{ + Name: "rag_search", + Description: "Semantic search across documents in the RAG vector database. Returns chunks ranked by similarity.", + }, s.ragQuery) + addToolRecorded(s, server, "rag", &mcp.Tool{ Name: "rag_ingest", Description: "Ingest documents into the RAG vector database. Supports both single files and directories.", }, s.ragIngest) + // rag_index is the spec-aligned alias for rag_ingest. + addToolRecorded(s, server, "rag", &mcp.Tool{ + Name: "rag_index", + Description: "Index a document or directory into the RAG vector database.", + }, s.ragIngest) + + addToolRecorded(s, server, "rag", &mcp.Tool{ + Name: "rag_retrieve", + Description: "Retrieve chunks for a specific document source from the RAG vector database.", + }, s.ragRetrieve) + addToolRecorded(s, server, "rag", &mcp.Tool{ Name: "rag_collections", Description: "List all available collections in the RAG vector database.", @@ -216,6 +257,86 @@ func (s *Service) ragIngest(ctx context.Context, req *mcp.CallToolRequest, input }, nil } +// ragRetrieve handles the rag_retrieve tool call. +// Returns chunks for a specific source path by querying the collection with +// the source path as the query text and then filtering results down to the +// matching source. This preserves the transport abstraction that the rest of +// the RAG tools use while producing the document-scoped view callers expect. +func (s *Service) ragRetrieve(ctx context.Context, req *mcp.CallToolRequest, input RAGRetrieveInput) (*mcp.CallToolResult, RAGRetrieveOutput, error) { + collection := input.Collection + if collection == "" { + collection = DefaultRAGCollection + } + limit := input.Limit + if limit <= 0 { + limit = 50 + } + + s.logger.Info("MCP tool execution", "tool", "rag_retrieve", "source", input.Source, "collection", collection, "limit", limit, "user", log.Username()) + + if input.Source == "" { + return nil, RAGRetrieveOutput{}, log.E("ragRetrieve", "source cannot be empty", nil) + } + + // Use the source path as the query text — semantically related chunks + // will rank highly, and we then keep only chunks whose Source matches. + // Over-fetch by an order of magnitude so document-level limits are met + // even when the source appears beyond the top-K of the raw query. + overfetch := limit * 10 + if overfetch < 100 { + overfetch = 100 + } + + results, err := rag.QueryDocs(ctx, input.Source, collection, overfetch) + if err != nil { + log.Error("mcp: rag retrieve query failed", "source", input.Source, "collection", collection, "err", err) + return nil, RAGRetrieveOutput{}, log.E("ragRetrieve", "failed to retrieve chunks", err) + } + + chunks := make([]RAGQueryResult, 0, limit) + for _, r := range results { + if r.Source != input.Source { + continue + } + chunks = append(chunks, RAGQueryResult{ + Content: r.Text, + Source: r.Source, + Section: r.Section, + Category: r.Category, + ChunkIndex: r.ChunkIndex, + Score: r.Score, + }) + if len(chunks) >= limit { + break + } + } + sortChunksByIndex(chunks) + + return nil, RAGRetrieveOutput{ + Source: input.Source, + Collection: collection, + Chunks: chunks, + Count: len(chunks), + }, nil +} + +// sortChunksByIndex sorts chunks in ascending order of chunk index. +// Stable ordering keeps ties by their original position. +func sortChunksByIndex(chunks []RAGQueryResult) { + if len(chunks) <= 1 { + return + } + // Insertion sort keeps the code dependency-free and is fast enough + // for the small result sets rag_retrieve is designed for. + for i := 1; i < len(chunks); i++ { + j := i + for j > 0 && chunks[j-1].ChunkIndex > chunks[j].ChunkIndex { + chunks[j-1], chunks[j] = chunks[j], chunks[j-1] + j-- + } + } +} + // ragCollections handles the rag_collections tool call. func (s *Service) ragCollections(ctx context.Context, req *mcp.CallToolRequest, input RAGCollectionsInput) (*mcp.CallToolResult, RAGCollectionsOutput, error) { s.logger.Info("MCP tool execution", "tool", "rag_collections", "show_stats", input.ShowStats, "user", log.Username()) diff --git a/pkg/mcp/tools_rag_test.go b/pkg/mcp/tools_rag_test.go index 281dbf0..57e6b05 100644 --- a/pkg/mcp/tools_rag_test.go +++ b/pkg/mcp/tools_rag_test.go @@ -171,3 +171,66 @@ func TestRAGCollectionsInput_ShowStats(t *testing.T) { t.Error("Expected ShowStats to be true") } } + +// TestToolsRag_RAGRetrieveInput_Good exercises the rag_retrieve DTO defaults. +func TestToolsRag_RAGRetrieveInput_Good(t *testing.T) { + input := RAGRetrieveInput{ + Source: "docs/index.md", + Collection: "core-docs", + Limit: 20, + } + + if input.Source != "docs/index.md" { + t.Errorf("expected source docs/index.md, got %q", input.Source) + } + if input.Limit != 20 { + t.Errorf("expected limit 20, got %d", input.Limit) + } +} + +// TestToolsRag_RAGRetrieveOutput_Good exercises the rag_retrieve output shape. +func TestToolsRag_RAGRetrieveOutput_Good(t *testing.T) { + output := RAGRetrieveOutput{ + Source: "docs/index.md", + Collection: "core-docs", + Chunks: []RAGQueryResult{ + {Content: "first", ChunkIndex: 0}, + {Content: "second", ChunkIndex: 1}, + }, + Count: 2, + } + if output.Count != 2 { + t.Fatalf("expected count 2, got %d", output.Count) + } + if output.Chunks[1].ChunkIndex != 1 { + t.Fatalf("expected chunk 1, got %d", output.Chunks[1].ChunkIndex) + } +} + +// TestToolsRag_SortChunksByIndex_Good verifies sort orders by chunk index ascending. +func TestToolsRag_SortChunksByIndex_Good(t *testing.T) { + chunks := []RAGQueryResult{ + {ChunkIndex: 3}, + {ChunkIndex: 1}, + {ChunkIndex: 2}, + } + sortChunksByIndex(chunks) + for i, want := range []int{1, 2, 3} { + if chunks[i].ChunkIndex != want { + t.Fatalf("index %d: expected chunk %d, got %d", i, want, chunks[i].ChunkIndex) + } + } +} + +// TestToolsRag_RagRetrieve_Bad rejects empty source paths. +func TestToolsRag_RagRetrieve_Bad(t *testing.T) { + svc, err := New(Options{WorkspaceRoot: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + + _, _, err = svc.ragRetrieve(t.Context(), nil, RAGRetrieveInput{}) + if err == nil { + t.Fatal("expected error for empty source") + } +} diff --git a/pkg/mcp/tools_webview.go b/pkg/mcp/tools_webview.go index a0d6a8b..734c223 100644 --- a/pkg/mcp/tools_webview.go +++ b/pkg/mcp/tools_webview.go @@ -270,6 +270,18 @@ func (s *Service) registerWebviewTools(server *mcp.Server) { Name: "webview_wait", Description: "Wait for an element to appear by CSS selector.", }, s.webviewWait) + + // Embedded UI rendering — for pushing HTML/state to connected clients + // without requiring a Chrome DevTools connection. + addToolRecorded(s, server, "webview", &mcp.Tool{ + Name: "webview_render", + Description: "Render HTML in an embedded webview by ID. Broadcasts to connected clients via the webview.render channel.", + }, s.webviewRender) + + addToolRecorded(s, server, "webview", &mcp.Tool{ + Name: "webview_update", + Description: "Update the HTML, title, or state of an embedded webview by ID. Broadcasts to connected clients via the webview.update channel.", + }, s.webviewUpdate) } // webviewConnect handles the webview_connect tool call. diff --git a/pkg/mcp/tools_webview_embed.go b/pkg/mcp/tools_webview_embed.go new file mode 100644 index 0000000..ff6d336 --- /dev/null +++ b/pkg/mcp/tools_webview_embed.go @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package mcp + +import ( + "context" + "sync" + "time" + + core "dappco.re/go/core" + "dappco.re/go/core/log" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// WebviewRenderInput contains parameters for rendering an embedded +// HTML view. The named view is stored and broadcast so connected clients +// (Claude Code sessions, CoreGUI windows, HTTP/SSE subscribers) can +// display the content. +// +// input := WebviewRenderInput{ +// ViewID: "dashboard", +// HTML: "
Loading...
", +// Title: "Agent Dashboard", +// Width: 1024, +// Height: 768, +// State: map[string]any{"theme": "dark"}, +// } +type WebviewRenderInput struct { + ViewID string `json:"view_id"` // e.g. "dashboard" + HTML string `json:"html"` // rendered markup + Title string `json:"title,omitempty"` // e.g. "Agent Dashboard" + Width int `json:"width,omitempty"` // preferred width in pixels + Height int `json:"height,omitempty"` // preferred height in pixels + State map[string]any `json:"state,omitempty"` // initial view state +} + +// WebviewRenderOutput reports the result of rendering an embedded view. +// +// // out.Success == true, out.ViewID == "dashboard" +type WebviewRenderOutput struct { + Success bool `json:"success"` // true when the view was stored and broadcast + ViewID string `json:"view_id"` // echoed view identifier + UpdatedAt time.Time `json:"updatedAt"` // when the view was rendered +} + +// WebviewUpdateInput contains parameters for updating the state of an +// existing embedded view. Callers may provide HTML to replace the markup, +// patch fields in the view state, or do both. +// +// input := WebviewUpdateInput{ +// ViewID: "dashboard", +// HTML: "
Ready
", +// State: map[string]any{"count": 42}, +// Merge: true, +// } +type WebviewUpdateInput struct { + ViewID string `json:"view_id"` // e.g. "dashboard" + HTML string `json:"html,omitempty"` // replacement markup (optional) + Title string `json:"title,omitempty"` // e.g. "Agent Dashboard" + State map[string]any `json:"state,omitempty"` // partial state update + Merge bool `json:"merge,omitempty"` // merge state (default) or replace when false +} + +// WebviewUpdateOutput reports the result of updating an embedded view. +// +// // out.Success == true, out.ViewID == "dashboard" +type WebviewUpdateOutput struct { + Success bool `json:"success"` // true when the view was updated and broadcast + ViewID string `json:"view_id"` // echoed view identifier + UpdatedAt time.Time `json:"updatedAt"` // when the view was last updated +} + +// embeddedView captures the live state of a rendered UI view. Instances +// are kept per ViewID inside embeddedViewRegistry. +type embeddedView struct { + ViewID string + Title string + HTML string + Width int + Height int + State map[string]any + UpdatedAt time.Time +} + +// embeddedViewRegistry stores the most recent render/update state for each +// view so new subscribers can pick up the current UI on connection. +// Operations are guarded by embeddedViewMu. +var ( + embeddedViewMu sync.RWMutex + embeddedViewRegistry = map[string]*embeddedView{} +) + +// ChannelWebviewRender is the channel used to broadcast webview_render events. +const ChannelWebviewRender = "webview.render" + +// ChannelWebviewUpdate is the channel used to broadcast webview_update events. +const ChannelWebviewUpdate = "webview.update" + +// webviewRender handles the webview_render tool call. +func (s *Service) webviewRender(ctx context.Context, req *mcp.CallToolRequest, input WebviewRenderInput) (*mcp.CallToolResult, WebviewRenderOutput, error) { + s.logger.Info("MCP tool execution", "tool", "webview_render", "view", input.ViewID, "user", log.Username()) + + if core.Trim(input.ViewID) == "" { + return nil, WebviewRenderOutput{}, log.E("webviewRender", "view_id is required", nil) + } + + now := time.Now() + view := &embeddedView{ + ViewID: input.ViewID, + Title: input.Title, + HTML: input.HTML, + Width: input.Width, + Height: input.Height, + State: cloneStateMap(input.State), + UpdatedAt: now, + } + + embeddedViewMu.Lock() + embeddedViewRegistry[input.ViewID] = view + embeddedViewMu.Unlock() + + s.ChannelSend(ctx, ChannelWebviewRender, map[string]any{ + "view_id": view.ViewID, + "title": view.Title, + "html": view.HTML, + "width": view.Width, + "height": view.Height, + "state": cloneStateMap(view.State), + "updatedAt": view.UpdatedAt, + }) + + return nil, WebviewRenderOutput{ + Success: true, + ViewID: view.ViewID, + UpdatedAt: view.UpdatedAt, + }, nil +} + +// webviewUpdate handles the webview_update tool call. +func (s *Service) webviewUpdate(ctx context.Context, req *mcp.CallToolRequest, input WebviewUpdateInput) (*mcp.CallToolResult, WebviewUpdateOutput, error) { + s.logger.Info("MCP tool execution", "tool", "webview_update", "view", input.ViewID, "user", log.Username()) + + if core.Trim(input.ViewID) == "" { + return nil, WebviewUpdateOutput{}, log.E("webviewUpdate", "view_id is required", nil) + } + + now := time.Now() + + embeddedViewMu.Lock() + view, ok := embeddedViewRegistry[input.ViewID] + if !ok { + // Updating a view that was never rendered creates one lazily so + // clients that reconnect mid-session get a consistent snapshot. + view = &embeddedView{ViewID: input.ViewID, State: map[string]any{}} + embeddedViewRegistry[input.ViewID] = view + } + + if input.HTML != "" { + view.HTML = input.HTML + } + if input.Title != "" { + view.Title = input.Title + } + if input.State != nil { + merge := input.Merge || len(view.State) == 0 + if merge { + if view.State == nil { + view.State = map[string]any{} + } + for k, v := range input.State { + view.State[k] = v + } + } else { + view.State = cloneStateMap(input.State) + } + } + view.UpdatedAt = now + snapshot := *view + snapshot.State = cloneStateMap(view.State) + embeddedViewMu.Unlock() + + s.ChannelSend(ctx, ChannelWebviewUpdate, map[string]any{ + "view_id": snapshot.ViewID, + "title": snapshot.Title, + "html": snapshot.HTML, + "width": snapshot.Width, + "height": snapshot.Height, + "state": snapshot.State, + "updatedAt": snapshot.UpdatedAt, + }) + + return nil, WebviewUpdateOutput{ + Success: true, + ViewID: snapshot.ViewID, + UpdatedAt: snapshot.UpdatedAt, + }, nil +} + +// cloneStateMap returns a shallow copy of a state map. +// +// cloned := cloneStateMap(map[string]any{"a": 1}) // cloned["a"] == 1 +func cloneStateMap(in map[string]any) map[string]any { + if in == nil { + return nil + } + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +// lookupEmbeddedView returns the current snapshot of an embedded view, if any. +// +// view, ok := lookupEmbeddedView("dashboard") +func lookupEmbeddedView(id string) (*embeddedView, bool) { + embeddedViewMu.RLock() + defer embeddedViewMu.RUnlock() + view, ok := embeddedViewRegistry[id] + if !ok { + return nil, false + } + snapshot := *view + snapshot.State = cloneStateMap(view.State) + return &snapshot, true +} + +// resetEmbeddedViews clears the registry. Intended for tests. +func resetEmbeddedViews() { + embeddedViewMu.Lock() + defer embeddedViewMu.Unlock() + embeddedViewRegistry = map[string]*embeddedView{} +} diff --git a/pkg/mcp/tools_webview_embed_test.go b/pkg/mcp/tools_webview_embed_test.go new file mode 100644 index 0000000..79266b7 --- /dev/null +++ b/pkg/mcp/tools_webview_embed_test.go @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package mcp + +import ( + "context" + "testing" +) + +// TestToolsWebviewEmbed_WebviewRender_Good registers a view and verifies the +// registry keeps the rendered HTML and state. +func TestToolsWebviewEmbed_WebviewRender_Good(t *testing.T) { + t.Cleanup(resetEmbeddedViews) + + svc, err := New(Options{WorkspaceRoot: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + + _, out, err := svc.webviewRender(context.Background(), nil, WebviewRenderInput{ + ViewID: "dashboard", + HTML: "

hello

", + Title: "Demo", + State: map[string]any{"count": 1}, + }) + if err != nil { + t.Fatalf("webviewRender returned error: %v", err) + } + if !out.Success { + t.Fatal("expected Success=true") + } + if out.ViewID != "dashboard" { + t.Fatalf("expected view id 'dashboard', got %q", out.ViewID) + } + if out.UpdatedAt.IsZero() { + t.Fatal("expected non-zero UpdatedAt") + } + + view, ok := lookupEmbeddedView("dashboard") + if !ok { + t.Fatal("expected view to be stored in registry") + } + if view.HTML != "

hello

" { + t.Fatalf("expected HTML '

hello

', got %q", view.HTML) + } + if view.State["count"] != 1 { + t.Fatalf("expected state.count=1, got %v", view.State["count"]) + } +} + +// TestToolsWebviewEmbed_WebviewRender_Bad ensures empty view IDs are rejected. +func TestToolsWebviewEmbed_WebviewRender_Bad(t *testing.T) { + t.Cleanup(resetEmbeddedViews) + + svc, err := New(Options{WorkspaceRoot: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + + _, _, err = svc.webviewRender(context.Background(), nil, WebviewRenderInput{}) + if err == nil { + t.Fatal("expected error for empty view_id") + } +} + +// TestToolsWebviewEmbed_WebviewUpdate_Good merges a state patch into the +// previously rendered view. +func TestToolsWebviewEmbed_WebviewUpdate_Good(t *testing.T) { + t.Cleanup(resetEmbeddedViews) + + svc, err := New(Options{WorkspaceRoot: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + + _, _, err = svc.webviewRender(context.Background(), nil, WebviewRenderInput{ + ViewID: "dashboard", + HTML: "

hello

", + State: map[string]any{"count": 1}, + }) + if err != nil { + t.Fatalf("seed render failed: %v", err) + } + + _, out, err := svc.webviewUpdate(context.Background(), nil, WebviewUpdateInput{ + ViewID: "dashboard", + State: map[string]any{"theme": "dark"}, + Merge: true, + }) + if err != nil { + t.Fatalf("webviewUpdate returned error: %v", err) + } + if !out.Success { + t.Fatal("expected Success=true") + } + + view, ok := lookupEmbeddedView("dashboard") + if !ok { + t.Fatal("expected view to exist after update") + } + if view.State["count"] != 1 { + t.Fatalf("expected count to persist after merge, got %v", view.State["count"]) + } + if view.State["theme"] != "dark" { + t.Fatalf("expected theme 'dark' after merge, got %v", view.State["theme"]) + } +} + +// TestToolsWebviewEmbed_WebviewUpdate_Ugly updates a view that was never +// rendered and verifies a fresh registry entry is created. +func TestToolsWebviewEmbed_WebviewUpdate_Ugly(t *testing.T) { + t.Cleanup(resetEmbeddedViews) + + svc, err := New(Options{WorkspaceRoot: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + + _, out, err := svc.webviewUpdate(context.Background(), nil, WebviewUpdateInput{ + ViewID: "ghost", + HTML: "

new

", + }) + if err != nil { + t.Fatalf("webviewUpdate returned error: %v", err) + } + if !out.Success { + t.Fatal("expected Success=true for lazy-create update") + } + + view, ok := lookupEmbeddedView("ghost") + if !ok { + t.Fatal("expected ghost view to be created lazily") + } + if view.HTML != "

new

" { + t.Fatalf("expected HTML '

new

', got %q", view.HTML) + } +} diff --git a/pkg/mcp/tools_ws_client.go b/pkg/mcp/tools_ws_client.go new file mode 100644 index 0000000..1895d1a --- /dev/null +++ b/pkg/mcp/tools_ws_client.go @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package mcp + +import ( + "context" + "crypto/rand" + "encoding/hex" + "net/http" + "sync" + "time" + + core "dappco.re/go/core" + "dappco.re/go/core/log" + "github.com/gorilla/websocket" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// WSConnectInput contains parameters for opening an outbound WebSocket +// connection from the MCP server. Each connection is given a stable ID that +// subsequent ws_send and ws_close calls use to address it. +// +// input := WSConnectInput{URL: "wss://example.com/ws", Timeout: 10} +type WSConnectInput struct { + URL string `json:"url"` // e.g. "wss://example.com/ws" + Headers map[string]string `json:"headers,omitempty"` // custom request headers + Timeout int `json:"timeout,omitempty"` // handshake timeout in seconds (default: 30) +} + +// WSConnectOutput contains the result of opening a WebSocket connection. +// +// // out.Success == true, out.ID == "ws-0af3…" +type WSConnectOutput struct { + Success bool `json:"success"` // true when the handshake completed + ID string `json:"id"` // e.g. "ws-0af3…" + URL string `json:"url"` // the URL that was dialled +} + +// WSSendInput contains parameters for sending a message on an open +// WebSocket connection. +// +// input := WSSendInput{ID: "ws-0af3…", Message: "ping"} +type WSSendInput struct { + ID string `json:"id"` // e.g. "ws-0af3…" + Message string `json:"message"` // payload to send + Binary bool `json:"binary,omitempty"` // true to send a binary frame (payload is base64 text) +} + +// WSSendOutput contains the result of sending a message. +// +// // out.Success == true, out.ID == "ws-0af3…" +type WSSendOutput struct { + Success bool `json:"success"` // true when the message was written + ID string `json:"id"` // e.g. "ws-0af3…" + Bytes int `json:"bytes"` // number of bytes written +} + +// WSCloseInput contains parameters for closing a WebSocket connection. +// +// input := WSCloseInput{ID: "ws-0af3…", Reason: "done"} +type WSCloseInput struct { + ID string `json:"id"` // e.g. "ws-0af3…" + Code int `json:"code,omitempty"` // close code (default: 1000 - normal closure) + Reason string `json:"reason,omitempty"` // human-readable reason +} + +// WSCloseOutput contains the result of closing a WebSocket connection. +// +// // out.Success == true, out.ID == "ws-0af3…" +type WSCloseOutput struct { + Success bool `json:"success"` // true when the connection was closed + ID string `json:"id"` // e.g. "ws-0af3…" + Message string `json:"message,omitempty"` // e.g. "connection closed" +} + +// wsClientConn tracks an outbound WebSocket connection tied to a stable ID. +type wsClientConn struct { + ID string + URL string + conn *websocket.Conn + writeMu sync.Mutex + CreatedAt time.Time +} + +// wsClientRegistry holds all live outbound WebSocket connections keyed by ID. +// Access is guarded by wsClientMu. +var ( + wsClientMu sync.Mutex + wsClientRegistry = map[string]*wsClientConn{} +) + +// registerWSClientTools registers the outbound WebSocket client tools. +func (s *Service) registerWSClientTools(server *mcp.Server) { + addToolRecorded(s, server, "ws", &mcp.Tool{ + Name: "ws_connect", + Description: "Open an outbound WebSocket connection. Returns a connection ID for subsequent ws_send and ws_close calls.", + }, s.wsConnect) + + addToolRecorded(s, server, "ws", &mcp.Tool{ + Name: "ws_send", + Description: "Send a text or binary message on an open WebSocket connection identified by ID.", + }, s.wsSend) + + addToolRecorded(s, server, "ws", &mcp.Tool{ + Name: "ws_close", + Description: "Close an open WebSocket connection identified by ID.", + }, s.wsClose) +} + +// wsConnect handles the ws_connect tool call. +func (s *Service) wsConnect(ctx context.Context, req *mcp.CallToolRequest, input WSConnectInput) (*mcp.CallToolResult, WSConnectOutput, error) { + s.logger.Security("MCP tool execution", "tool", "ws_connect", "url", input.URL, "user", log.Username()) + + if core.Trim(input.URL) == "" { + return nil, WSConnectOutput{}, log.E("wsConnect", "url is required", nil) + } + + timeout := time.Duration(input.Timeout) * time.Second + if timeout <= 0 { + timeout = 30 * time.Second + } + + dialer := websocket.Dialer{ + HandshakeTimeout: timeout, + } + + headers := http.Header{} + for k, v := range input.Headers { + headers.Set(k, v) + } + + dialCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + conn, _, err := dialer.DialContext(dialCtx, input.URL, headers) + if err != nil { + log.Error("mcp: ws connect failed", "url", input.URL, "err", err) + return nil, WSConnectOutput{}, log.E("wsConnect", "failed to connect", err) + } + + id := newWSClientID() + client := &wsClientConn{ + ID: id, + URL: input.URL, + conn: conn, + CreatedAt: time.Now(), + } + + wsClientMu.Lock() + wsClientRegistry[id] = client + wsClientMu.Unlock() + + return nil, WSConnectOutput{ + Success: true, + ID: id, + URL: input.URL, + }, nil +} + +// wsSend handles the ws_send tool call. +func (s *Service) wsSend(ctx context.Context, req *mcp.CallToolRequest, input WSSendInput) (*mcp.CallToolResult, WSSendOutput, error) { + s.logger.Info("MCP tool execution", "tool", "ws_send", "id", input.ID, "binary", input.Binary, "user", log.Username()) + + if core.Trim(input.ID) == "" { + return nil, WSSendOutput{}, log.E("wsSend", "id is required", nil) + } + + client, ok := getWSClient(input.ID) + if !ok { + return nil, WSSendOutput{}, log.E("wsSend", "connection not found", nil) + } + + messageType := websocket.TextMessage + if input.Binary { + messageType = websocket.BinaryMessage + } + + client.writeMu.Lock() + err := client.conn.WriteMessage(messageType, []byte(input.Message)) + client.writeMu.Unlock() + if err != nil { + log.Error("mcp: ws send failed", "id", input.ID, "err", err) + return nil, WSSendOutput{}, log.E("wsSend", "failed to send message", err) + } + + return nil, WSSendOutput{ + Success: true, + ID: input.ID, + Bytes: len(input.Message), + }, nil +} + +// wsClose handles the ws_close tool call. +func (s *Service) wsClose(ctx context.Context, req *mcp.CallToolRequest, input WSCloseInput) (*mcp.CallToolResult, WSCloseOutput, error) { + s.logger.Info("MCP tool execution", "tool", "ws_close", "id", input.ID, "user", log.Username()) + + if core.Trim(input.ID) == "" { + return nil, WSCloseOutput{}, log.E("wsClose", "id is required", nil) + } + + wsClientMu.Lock() + client, ok := wsClientRegistry[input.ID] + if ok { + delete(wsClientRegistry, input.ID) + } + wsClientMu.Unlock() + + if !ok { + return nil, WSCloseOutput{}, log.E("wsClose", "connection not found", nil) + } + + code := input.Code + if code == 0 { + code = websocket.CloseNormalClosure + } + reason := input.Reason + if reason == "" { + reason = "closed" + } + + client.writeMu.Lock() + _ = client.conn.WriteControl( + websocket.CloseMessage, + websocket.FormatCloseMessage(code, reason), + time.Now().Add(5*time.Second), + ) + client.writeMu.Unlock() + _ = client.conn.Close() + + return nil, WSCloseOutput{ + Success: true, + ID: input.ID, + Message: "connection closed", + }, nil +} + +// newWSClientID returns a fresh identifier for an outbound WebSocket client. +// +// id := newWSClientID() // "ws-0af3…" +func newWSClientID() string { + var buf [8]byte + _, _ = rand.Read(buf[:]) + return "ws-" + hex.EncodeToString(buf[:]) +} + +// getWSClient returns a tracked outbound WebSocket client by ID, if any. +// +// client, ok := getWSClient("ws-0af3…") +func getWSClient(id string) (*wsClientConn, bool) { + wsClientMu.Lock() + defer wsClientMu.Unlock() + client, ok := wsClientRegistry[id] + return client, ok +} + +// resetWSClients drops all tracked outbound WebSocket clients. Intended for tests. +func resetWSClients() { + wsClientMu.Lock() + defer wsClientMu.Unlock() + for id, client := range wsClientRegistry { + _ = client.conn.Close() + delete(wsClientRegistry, id) + } +} diff --git a/pkg/mcp/tools_ws_client_test.go b/pkg/mcp/tools_ws_client_test.go new file mode 100644 index 0000000..3c3d178 --- /dev/null +++ b/pkg/mcp/tools_ws_client_test.go @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package mcp + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +// TestToolsWSClient_WSConnect_Good dials a test WebSocket server and verifies +// the handshake completes and a client ID is assigned. +func TestToolsWSClient_WSConnect_Good(t *testing.T) { + t.Cleanup(resetWSClients) + + server := startTestWSServer(t) + defer server.Close() + + svc, err := New(Options{WorkspaceRoot: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + + _, out, err := svc.wsConnect(context.Background(), nil, WSConnectInput{ + URL: "ws" + strings.TrimPrefix(server.URL, "http") + "/ws", + Timeout: 5, + }) + if err != nil { + t.Fatalf("wsConnect failed: %v", err) + } + if !out.Success { + t.Fatal("expected Success=true") + } + if !strings.HasPrefix(out.ID, "ws-") { + t.Fatalf("expected ID prefix 'ws-', got %q", out.ID) + } + + _, _, err = svc.wsClose(context.Background(), nil, WSCloseInput{ID: out.ID}) + if err != nil { + t.Fatalf("wsClose failed: %v", err) + } +} + +// TestToolsWSClient_WSConnect_Bad rejects empty URLs. +func TestToolsWSClient_WSConnect_Bad(t *testing.T) { + t.Cleanup(resetWSClients) + + svc, err := New(Options{WorkspaceRoot: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + + _, _, err = svc.wsConnect(context.Background(), nil, WSConnectInput{}) + if err == nil { + t.Fatal("expected error for empty URL") + } +} + +// TestToolsWSClient_WSSendClose_Good sends a message on an open connection +// and then closes it. +func TestToolsWSClient_WSSendClose_Good(t *testing.T) { + t.Cleanup(resetWSClients) + + server := startTestWSServer(t) + defer server.Close() + + svc, err := New(Options{WorkspaceRoot: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + + _, conn, err := svc.wsConnect(context.Background(), nil, WSConnectInput{ + URL: "ws" + strings.TrimPrefix(server.URL, "http") + "/ws", + Timeout: 5, + }) + if err != nil { + t.Fatalf("wsConnect failed: %v", err) + } + + _, sendOut, err := svc.wsSend(context.Background(), nil, WSSendInput{ + ID: conn.ID, + Message: "ping", + }) + if err != nil { + t.Fatalf("wsSend failed: %v", err) + } + if !sendOut.Success { + t.Fatal("expected Success=true for wsSend") + } + if sendOut.Bytes != 4 { + t.Fatalf("expected 4 bytes written, got %d", sendOut.Bytes) + } + + _, closeOut, err := svc.wsClose(context.Background(), nil, WSCloseInput{ID: conn.ID}) + if err != nil { + t.Fatalf("wsClose failed: %v", err) + } + if !closeOut.Success { + t.Fatal("expected Success=true for wsClose") + } + + if _, ok := getWSClient(conn.ID); ok { + t.Fatal("expected connection to be removed after close") + } +} + +// TestToolsWSClient_WSSend_Bad rejects unknown connection IDs. +func TestToolsWSClient_WSSend_Bad(t *testing.T) { + t.Cleanup(resetWSClients) + + svc, err := New(Options{WorkspaceRoot: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + + _, _, err = svc.wsSend(context.Background(), nil, WSSendInput{ID: "ws-missing", Message: "x"}) + if err == nil { + t.Fatal("expected error for unknown connection ID") + } +} + +// TestToolsWSClient_WSClose_Bad rejects closes for unknown connection IDs. +func TestToolsWSClient_WSClose_Bad(t *testing.T) { + t.Cleanup(resetWSClients) + + svc, err := New(Options{WorkspaceRoot: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + + _, _, err = svc.wsClose(context.Background(), nil, WSCloseInput{ID: "ws-missing"}) + if err == nil { + t.Fatal("expected error for unknown connection ID") + } +} + +// startTestWSServer returns an httptest.Server running a minimal echo WebSocket +// handler used by the ws_connect/ws_send tests. +func startTestWSServer(t *testing.T) *httptest.Server { + t.Helper() + + upgrader := websocket.Upgrader{ + CheckOrigin: func(*http.Request) bool { return true }, + } + mux := http.NewServeMux() + mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + for { + _, msg, err := conn.ReadMessage() + if err != nil { + return + } + if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil { + return + } + } + }) + return httptest.NewServer(mux) +} diff --git a/pkg/mcp/transport_http.go b/pkg/mcp/transport_http.go index 2fa617d..c82b703 100644 --- a/pkg/mcp/transport_http.go +++ b/pkg/mcp/transport_http.go @@ -249,10 +249,31 @@ func handleMCPDiscovery(w http.ResponseWriter, r *http.Request) { "command": "core-agent", "args": []string{"mcp"}, }, - Capabilities: []string{"tools", "resources"}, - UseWhen: []string{"Need to dispatch work to Codex/Claude/Gemini", "Need workspace status", "Need semantic search"}, + Capabilities: []string{"tools", "resources"}, + UseWhen: []string{ + "Need to dispatch work to Codex/Claude/Gemini", + "Need workspace status", + "Need semantic search", + }, RelatedServers: []string{"core-mcp"}, }, + { + ID: "core-mcp", + Name: "Core MCP", + Description: "File ops, process and build tools, RAG search, webview, dashboards — the agent-facing MCP framework.", + Connection: map[string]any{ + "type": "stdio", + "command": "core-mcp", + }, + Capabilities: []string{"tools", "resources", "logging"}, + UseWhen: []string{ + "Need to read/write files inside a workspace", + "Need to start or monitor processes", + "Need to run RAG queries or index documents", + "Need to render or update an embedded dashboard view", + }, + RelatedServers: []string{"core-agent"}, + }, }, } From 62c194945842d4a0e9e9fce117f4e9869e593fe3 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 14 Apr 2026 18:01:17 +0100 Subject: [PATCH 07/14] fix(mcp): rewrite mcpcmd for new core/cli Command API + correct bridge test The mcpcmd package was using the removed Cobra-style cli.Command API (Use/Short/Long/RunE/StringFlag/AddCommand). Rewrites it to the current core.Command{Description, Action, Flags} path-routed pattern so the core-mcp binary compiles again. Registers both "mcp" and "mcp/serve" for parity with the existing OnStartup service-mode flow. Fixes the bridge DescribableGroup test that expected len == svc.Tools() but ToolBridge.Describe prepends the GET tool-listing entry, so the correct expectation is len + 1. Co-Authored-By: Virgil --- cmd/mcpcmd/cmd_mcp.go | 137 ++++++++++++++----------- cmd/mcpcmd/cmd_mcp_test.go | 203 ++++++++++++++++++++++++++++++++++--- pkg/mcp/bridge_test.go | 11 +- 3 files changed, 275 insertions(+), 76 deletions(-) diff --git a/cmd/mcpcmd/cmd_mcp.go b/cmd/mcpcmd/cmd_mcp.go index a57cdf4..6d3641e 100644 --- a/cmd/mcpcmd/cmd_mcp.go +++ b/cmd/mcpcmd/cmd_mcp.go @@ -1,7 +1,14 @@ -// Package mcpcmd provides the MCP server command. +// SPDX-License-Identifier: EUPL-1.2 + +// Package mcpcmd registers the `mcp` and `mcp serve` CLI commands. +// +// Wiring example: +// +// cli.Main(cli.WithCommands("mcp", mcpcmd.AddMCPCommands)) // // Commands: -// - mcp serve: Start the MCP server for AI tool integration +// - mcp Start the MCP server on stdio (default transport). +// - mcp serve Start the MCP server with auto-selected transport. package mcpcmd import ( @@ -10,75 +17,89 @@ import ( "os/signal" "syscall" + core "dappco.re/go/core" "dappco.re/go/mcp/pkg/mcp" "dappco.re/go/mcp/pkg/mcp/agentic" "dappco.re/go/mcp/pkg/mcp/brain" - "dappco.re/go/core/cli/pkg/cli" ) -var workspaceFlag string -var unrestrictedFlag bool - +// newMCPService is the service constructor, indirected for tests. var newMCPService = mcp.New + +// runMCPService starts the MCP server, indirected for tests. var runMCPService = func(svc *mcp.Service, ctx context.Context) error { return svc.Run(ctx) } + +// shutdownMCPService performs graceful shutdown, indirected for tests. var shutdownMCPService = func(svc *mcp.Service, ctx context.Context) error { return svc.Shutdown(ctx) } -var mcpCmd = &cli.Command{ - Use: "mcp", - Short: "MCP server for AI tool integration", - Long: "Model Context Protocol (MCP) server providing file operations, RAG, and metrics tools.", +// workspaceFlag mirrors the --workspace CLI flag value. +var workspaceFlag string + +// unrestrictedFlag mirrors the --unrestricted CLI flag value. +var unrestrictedFlag bool + +// AddMCPCommands registers the `mcp` command tree on the Core instance. +// +// cli.Main(cli.WithCommands("mcp", mcpcmd.AddMCPCommands)) +func AddMCPCommands(c *core.Core) { + c.Command("mcp", core.Command{ + Description: "Model Context Protocol server (stdio, TCP, Unix socket, HTTP).", + Action: runServeAction, + Flags: core.NewOptions( + core.Option{Key: "workspace", Value: ""}, + core.Option{Key: "w", Value: ""}, + core.Option{Key: "unrestricted", Value: false}, + ), + }) + + c.Command("mcp/serve", core.Command{ + Description: "Start the MCP server with auto-selected transport (stdio, TCP, Unix, or HTTP).", + Action: runServeAction, + Flags: core.NewOptions( + core.Option{Key: "workspace", Value: ""}, + core.Option{Key: "w", Value: ""}, + core.Option{Key: "unrestricted", Value: false}, + ), + }) } -var serveCmd = &cli.Command{ - Use: "serve", - Short: "Start the MCP server", - Long: `Start the MCP server on stdio (default), TCP, Unix socket, or HTTP. +// runServeAction is the CLI entrypoint for `mcp` and `mcp serve`. +// +// opts := core.NewOptions(core.Option{Key: "workspace", Value: "."}) +// result := runServeAction(opts) +func runServeAction(opts core.Options) core.Result { + workspaceFlag = core.Trim(firstNonEmpty(opts.String("workspace"), opts.String("w"))) + unrestrictedFlag = opts.Bool("unrestricted") -The server provides file operations plus the brain and agentic subsystems -registered by this command. - -Environment variables: - MCP_ADDR TCP address to listen on (e.g., "localhost:9999") - MCP_UNIX_SOCKET - Unix socket path to listen on (e.g., "/tmp/core-mcp.sock") - Selected after MCP_ADDR and before stdio. - MCP_HTTP_ADDR - HTTP address to listen on (e.g., "127.0.0.1:9101") - Selected before MCP_ADDR and stdio. - -Examples: - # Start with stdio transport (for Claude Code integration) - core mcp serve - - # Start with workspace restriction - core mcp serve --workspace /path/to/project - - # Start unrestricted (explicit opt-in) - core mcp serve --unrestricted - - # Start TCP server - MCP_ADDR=localhost:9999 core mcp serve`, - RunE: func(cmd *cli.Command, args []string) error { - return runServe() - }, + if err := runServe(); err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{OK: true} } -func initFlags() { - cli.StringFlag(serveCmd, &workspaceFlag, "workspace", "w", "", "Restrict file operations to this directory") - cli.BoolFlag(serveCmd, &unrestrictedFlag, "unrestricted", "", false, "Disable filesystem sandboxing entirely") -} - -// AddMCPCommands registers the 'mcp' command and all subcommands. -func AddMCPCommands(root *cli.Command) { - initFlags() - mcpCmd.AddCommand(serveCmd) - root.AddCommand(mcpCmd) +// firstNonEmpty returns the first non-empty string argument. +// +// firstNonEmpty("", "foo") == "foo" +// firstNonEmpty("bar", "baz") == "bar" +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" } +// runServe wires the MCP service together and blocks until the context is +// cancelled by SIGINT/SIGTERM or a transport error. +// +// if err := runServe(); err != nil { +// core.Error("mcp serve failed", "err", err) +// } func runServe() error { opts := mcp.Options{} @@ -88,22 +109,20 @@ func runServe() error { opts.WorkspaceRoot = workspaceFlag } - // Register OpenBrain and agentic subsystems + // Register OpenBrain and agentic subsystems. opts.Subsystems = []mcp.Subsystem{ brain.NewDirect(), agentic.NewPrep(), } - // Create the MCP service svc, err := newMCPService(opts) if err != nil { - return cli.Wrap(err, "create MCP service") + return core.E("mcpcmd.runServe", "create MCP service", err) } defer func() { _ = shutdownMCPService(svc, context.Background()) }() - // Set up signal handling for clean shutdown ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -111,10 +130,12 @@ func runServe() error { signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) go func() { - <-sigCh - cancel() + select { + case <-sigCh: + cancel() + case <-ctx.Done(): + } }() - // Run the server (blocks until context cancelled or error) return runMCPService(svc, ctx) } diff --git a/cmd/mcpcmd/cmd_mcp_test.go b/cmd/mcpcmd/cmd_mcp_test.go index 740e82a..2083adb 100644 --- a/cmd/mcpcmd/cmd_mcp_test.go +++ b/cmd/mcpcmd/cmd_mcp_test.go @@ -1,26 +1,18 @@ +// SPDX-License-Identifier: EUPL-1.2 + package mcpcmd import ( "context" "testing" + core "dappco.re/go/core" "dappco.re/go/mcp/pkg/mcp" ) -func TestRunServe_Good_ShutsDownService(t *testing.T) { - oldNew := newMCPService - oldRun := runMCPService - oldShutdown := shutdownMCPService - oldWorkspace := workspaceFlag - oldUnrestricted := unrestrictedFlag - - t.Cleanup(func() { - newMCPService = oldNew - runMCPService = oldRun - shutdownMCPService = oldShutdown - workspaceFlag = oldWorkspace - unrestrictedFlag = oldUnrestricted - }) +func TestCmdMCP_RunServe_Good_ShutsDownService(t *testing.T) { + restore := stubMCPService(t) + defer restore() workspaceFlag = "" unrestrictedFlag = false @@ -50,3 +42,186 @@ func TestRunServe_Good_ShutsDownService(t *testing.T) { t.Fatal("expected shutdownMCPService to be called") } } + +func TestCmdMCP_RunServeAction_Good_PropagatesFlags(t *testing.T) { + restore := stubMCPService(t) + defer restore() + + workspaceFlag = "" + unrestrictedFlag = false + + var gotOpts mcp.Options + newMCPService = func(opts mcp.Options) (*mcp.Service, error) { + gotOpts = opts + return mcp.New(mcp.Options{WorkspaceRoot: t.TempDir()}) + } + runMCPService = func(svc *mcp.Service, ctx context.Context) error { + return nil + } + shutdownMCPService = func(svc *mcp.Service, ctx context.Context) error { + return nil + } + + tmp := t.TempDir() + opts := core.NewOptions(core.Option{Key: "workspace", Value: tmp}) + + result := runServeAction(opts) + if !result.OK { + t.Fatalf("expected OK, got %+v", result) + } + if gotOpts.WorkspaceRoot != tmp { + t.Fatalf("expected workspace root %q, got %q", tmp, gotOpts.WorkspaceRoot) + } + if gotOpts.Unrestricted { + t.Fatal("expected Unrestricted=false when --workspace is set") + } +} + +func TestCmdMCP_RunServeAction_Good_UnrestrictedFlag(t *testing.T) { + restore := stubMCPService(t) + defer restore() + + workspaceFlag = "" + unrestrictedFlag = false + + var gotOpts mcp.Options + newMCPService = func(opts mcp.Options) (*mcp.Service, error) { + gotOpts = opts + return mcp.New(mcp.Options{Unrestricted: true}) + } + runMCPService = func(svc *mcp.Service, ctx context.Context) error { + return nil + } + shutdownMCPService = func(svc *mcp.Service, ctx context.Context) error { + return nil + } + + opts := core.NewOptions(core.Option{Key: "unrestricted", Value: true}) + + result := runServeAction(opts) + if !result.OK { + t.Fatalf("expected OK, got %+v", result) + } + if !gotOpts.Unrestricted { + t.Fatal("expected Unrestricted=true when --unrestricted is set") + } +} + +func TestCmdMCP_RunServe_Bad_CreateServiceFails(t *testing.T) { + restore := stubMCPService(t) + defer restore() + + workspaceFlag = "" + unrestrictedFlag = false + + sentinel := core.E("mcpcmd.test", "boom", nil) + newMCPService = func(opts mcp.Options) (*mcp.Service, error) { + return nil, sentinel + } + runMCPService = func(svc *mcp.Service, ctx context.Context) error { + t.Fatal("runMCPService should not be called when New fails") + return nil + } + shutdownMCPService = func(svc *mcp.Service, ctx context.Context) error { + t.Fatal("shutdownMCPService should not be called when New fails") + return nil + } + + err := runServe() + if err == nil { + t.Fatal("expected error when newMCPService fails") + } +} + +func TestCmdMCP_RunServeAction_Bad_PropagatesFailure(t *testing.T) { + restore := stubMCPService(t) + defer restore() + + workspaceFlag = "" + unrestrictedFlag = false + + newMCPService = func(opts mcp.Options) (*mcp.Service, error) { + return nil, core.E("mcpcmd.test", "construction failed", nil) + } + runMCPService = func(svc *mcp.Service, ctx context.Context) error { + return nil + } + shutdownMCPService = func(svc *mcp.Service, ctx context.Context) error { + return nil + } + + result := runServeAction(core.NewOptions()) + if result.OK { + t.Fatal("expected runServeAction to fail when service creation fails") + } + if result.Value == nil { + t.Fatal("expected error value on failure") + } +} + +func TestCmdMCP_FirstNonEmpty_Ugly_HandlesAllVariants(t *testing.T) { + tests := []struct { + name string + values []string + want string + }{ + {"no args", nil, ""}, + {"empty string", []string{""}, ""}, + {"all empty", []string{"", "", ""}, ""}, + {"first non-empty", []string{"foo", "bar"}, "foo"}, + {"skip empty", []string{"", "baz"}, "baz"}, + {"mixed", []string{"", "", "last"}, "last"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := firstNonEmpty(tc.values...) + if got != tc.want { + t.Fatalf("firstNonEmpty(%v) = %q, want %q", tc.values, got, tc.want) + } + }) + } +} + +func TestCmdMCP_AddMCPCommands_Good_RegistersMcpTree(t *testing.T) { + c := core.New() + AddMCPCommands(c) + + commands := c.Commands() + if len(commands) == 0 { + t.Fatal("expected at least one registered command") + } + + mustHave := map[string]bool{ + "mcp": false, + "mcp/serve": false, + } + for _, path := range commands { + if _, ok := mustHave[path]; ok { + mustHave[path] = true + } + } + for path, present := range mustHave { + if !present { + t.Fatalf("expected command %q to be registered", path) + } + } +} + +// stubMCPService captures the package-level function pointers and returns a +// restore hook so each test can mutate them without leaking into siblings. +func stubMCPService(t *testing.T) func() { + t.Helper() + oldNew := newMCPService + oldRun := runMCPService + oldShutdown := shutdownMCPService + oldWorkspace := workspaceFlag + oldUnrestricted := unrestrictedFlag + + return func() { + newMCPService = oldNew + runMCPService = oldRun + shutdownMCPService = oldShutdown + workspaceFlag = oldWorkspace + unrestrictedFlag = oldUnrestricted + } +} diff --git a/pkg/mcp/bridge_test.go b/pkg/mcp/bridge_test.go index c73fd6e..4bfd169 100644 --- a/pkg/mcp/bridge_test.go +++ b/pkg/mcp/bridge_test.go @@ -81,13 +81,16 @@ func TestBridgeToAPI_Good_DescribableGroup(t *testing.T) { var dg api.DescribableGroup = bridge descs := dg.Describe() - if len(descs) != len(svc.Tools()) { - t.Fatalf("expected %d descriptions, got %d", len(svc.Tools()), len(descs)) + // ToolBridge.Describe prepends a GET entry describing the tool listing + // endpoint, so the expected count is svc.Tools() + 1. + wantDescs := len(svc.Tools()) + 1 + if len(descs) != wantDescs { + t.Fatalf("expected %d descriptions, got %d", wantDescs, len(descs)) } for _, d := range descs { - if d.Method != "POST" { - t.Errorf("expected Method=POST for %s, got %q", d.Path, d.Method) + if d.Method != "POST" && d.Method != "GET" { + t.Errorf("expected Method=POST or GET for %s, got %q", d.Path, d.Method) } if d.Summary == "" { t.Errorf("expected non-empty Summary for %s", d.Path) From f26ae14222a1a26c71dfec6ac32b3b09dea8040c Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 23 Apr 2026 15:22:13 +0100 Subject: [PATCH 08/14] feat(mcp): add cmd/openbrain-mcp stdio wrapper for Claude Code Thin Go wrapper mounting pkg/mcp/brain tools onto Service.ServeStdio(). Proxies to the PHP BrainService via --brain-url so any Claude Code session gains OpenBrain recall/remember via `claude mcp add openbrain`. Closes tasks.lthn.sh/view.php?id=76 Co-authored-by: Codex Co-Authored-By: Virgil --- cmd/openbrain-mcp/README.md | 29 +++++++++++ cmd/openbrain-mcp/main.go | 95 +++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 cmd/openbrain-mcp/README.md create mode 100644 cmd/openbrain-mcp/main.go diff --git a/cmd/openbrain-mcp/README.md b/cmd/openbrain-mcp/README.md new file mode 100644 index 0000000..a20df72 --- /dev/null +++ b/cmd/openbrain-mcp/README.md @@ -0,0 +1,29 @@ +# openbrain-mcp + +`openbrain-mcp` is a thin stdio MCP wrapper for the OpenBrain tools registered in `pkg/mcp/brain`. + +Install: + +```sh +go install dappco.re/go/mcp/cmd/openbrain-mcp@latest +``` + +Add it to Claude Code: + +```sh +claude mcp add openbrain -- openbrain-mcp --brain-url=http://127.0.0.1:8000/v1/brain --api-key=$OPENBRAIN_API_KEY +``` + +The wrapper exposes: + +- `brain_remember` +- `brain_recall` +- `brain_forget` +- `brain_list` + +Flags: + +- `--brain-url`: OpenBrain BrainService URL. Defaults to `http://127.0.0.1:8000/v1/brain`. +- `--api-key`: OpenBrain API key. Defaults to `OPENBRAIN_API_KEY`. + +The process logs to stderr only. Stdout is reserved for MCP framing. diff --git a/cmd/openbrain-mcp/main.go b/cmd/openbrain-mcp/main.go new file mode 100644 index 0000000..fceba37 --- /dev/null +++ b/cmd/openbrain-mcp/main.go @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// openbrain-mcp exposes the OpenBrain MCP tools over stdio for Claude Code. +package main + +import ( + "context" + "flag" + "os" + "os/signal" + "syscall" + "time" + + core "dappco.re/go/core" + coreerr "dappco.re/go/core/log" + "dappco.re/go/mcp/pkg/mcp" + "dappco.re/go/mcp/pkg/mcp/brain" +) + +const defaultBrainURL = "http://127.0.0.1:8000/v1/brain" + +var ( + brainURLFlag = flag.String("brain-url", defaultBrainURL, "OpenBrain BrainService URL") + apiKeyFlag = flag.String("api-key", "", "OpenBrain API key (defaults to OPENBRAIN_API_KEY)") +) + +func main() { + if err := run(); err != nil { + coreerr.Error("openbrain-mcp failed", "err", err) + os.Exit(1) + } +} + +func run() error { + flag.Parse() + + if err := configureBrainEnv(*brainURLFlag, *apiKeyFlag); err != nil { + return err + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + svc, err := mcp.New(mcp.Options{ + Subsystems: []mcp.Subsystem{ + brain.NewDirect(), + }, + }) + if err != nil { + return core.E("openbrain-mcp.run", "create MCP service", err) + } + defer shutdownService(svc) + + if err := svc.ServeStdio(ctx); err != nil && !core.Is(err, context.Canceled) { + return core.E("openbrain-mcp.run", "serve stdio", err) + } + return nil +} + +func configureBrainEnv(brainURL, apiKey string) error { + baseURL := directBrainBaseURL(brainURL) + if baseURL == "" { + baseURL = directBrainBaseURL(defaultBrainURL) + } + if err := os.Setenv("CORE_BRAIN_URL", baseURL); err != nil { + return core.E("openbrain-mcp.configure", "set CORE_BRAIN_URL", err) + } + + key := core.Trim(apiKey) + if key == "" { + key = core.Trim(core.Env("OPENBRAIN_API_KEY")) + } + if key == "" { + return nil + } + if err := os.Setenv("CORE_BRAIN_KEY", key); err != nil { + return core.E("openbrain-mcp.configure", "set CORE_BRAIN_KEY", err) + } + return nil +} + +func directBrainBaseURL(brainURL string) string { + baseURL := core.Trim(brainURL) + baseURL = core.TrimSuffix(baseURL, "/") + baseURL = core.TrimSuffix(baseURL, "/v1/brain") + return core.TrimSuffix(baseURL, "/") +} + +func shutdownService(svc *mcp.Service) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := svc.Shutdown(ctx); err != nil { + coreerr.Error("openbrain-mcp shutdown failed", "err", err) + } +} From 09f786fb8084c7af6354fae9ddfedbff2eaca723 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Apr 2026 21:24:23 +0100 Subject: [PATCH 09/14] fix(mcp): purge/annotate banned imports in pkg/mcp/notify.go (AX-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fmt, errors, strings, encoding/json swapped to core.* equivalents. os retained with `// Note:` annotation — stdout stdio writer has no core.Fs/core.Env equivalent at this layer. Closes tasks.lthn.sh/view.php?id=192 Co-authored-by: Codex --- pkg/mcp/notify.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/mcp/notify.go b/pkg/mcp/notify.go index 17c9eeb..a916512 100644 --- a/pkg/mcp/notify.go +++ b/pkg/mcp/notify.go @@ -11,7 +11,7 @@ import ( "context" "io" "iter" - "os" + "os" // Note: required for process stdout; core Fs/Env do not expose a stdio writer. "reflect" "slices" "sync" From 633f295244b285604d1c18ce804d9043a33fe110 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Apr 2026 21:27:42 +0100 Subject: [PATCH 10/14] fix(mcp): purge banned stdlib imports from pkg/mcp/mcp.go (AX-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed banned os import; replaced: - default cwd lookup → core.Env("DIR_CWD") - os.DirEntry / os.ErrNotExist → annotated io/fs usage - cleaned comment examples so broad banned-name grep is also clean Verification: `grep -nE '"(fmt|os|errors|strings|encoding/json)"' pkg/mcp/mcp.go` empty. go vet passes with module drift (pre-existing). Closes tasks.lthn.sh/view.php?id=191 Co-authored-by: Codex --- pkg/mcp/mcp.go | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index 7864111..a022402 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -7,9 +7,9 @@ package mcp import ( "cmp" "context" + "io/fs" // Note: io.Medium exposes fs.DirEntry/FileInfo and fs.ErrNotExist. "iter" "net/http" - "os" "path/filepath" "slices" "sync" @@ -94,9 +94,9 @@ func New(opts Options) (*Service, error) { } else { root := opts.WorkspaceRoot if root == "" { - cwd, err := os.Getwd() - if err != nil { - return nil, core.E("mcp.New", "failed to get working directory", err) + cwd := core.Env("DIR_CWD") + if cwd == "" { + return nil, core.E("mcp.New", "failed to get working directory", nil) } root = cwd } @@ -139,7 +139,7 @@ func New(opts Options) (*Service, error) { // Subsystems returns the registered subsystems. // // for _, sub := range svc.Subsystems() { -// fmt.Println(sub.Name()) +// core.Println(sub.Name()) // } func (s *Service) Subsystems() []Subsystem { return slices.Clone(s.subsystems) @@ -148,7 +148,7 @@ func (s *Service) Subsystems() []Subsystem { // SubsystemsSeq returns an iterator over the registered subsystems. // // for sub := range svc.SubsystemsSeq() { -// fmt.Println(sub.Name()) +// core.Println(sub.Name()) // } func (s *Service) SubsystemsSeq() iter.Seq[Subsystem] { return slices.Values(slices.Clone(s.subsystems)) @@ -157,7 +157,7 @@ func (s *Service) SubsystemsSeq() iter.Seq[Subsystem] { // Tools returns all recorded tool metadata. // // for _, t := range svc.Tools() { -// fmt.Printf("%s (%s): %s\n", t.Name, t.Group, t.Description) +// core.Println(core.Sprintf("%s (%s): %s", t.Name, t.Group, t.Description)) // } func (s *Service) Tools() []ToolRecord { return slices.Clone(s.tools) @@ -166,7 +166,7 @@ func (s *Service) Tools() []ToolRecord { // ToolsSeq returns an iterator over all recorded tool metadata. // // for rec := range svc.ToolsSeq() { -// fmt.Println(rec.Name) +// core.Println(rec.Name) // } func (s *Service) ToolsSeq() iter.Seq[ToolRecord] { return slices.Values(slices.Clone(s.tools)) @@ -485,8 +485,8 @@ type LanguageInfo struct { // // input := EditDiffInput{ // Path: "main.go", -// OldString: "fmt.Println(\"hello\")", -// NewString: "fmt.Println(\"world\")", +// OldString: "core.Println(\"hello\")", +// NewString: "core.Println(\"world\")", // } type EditDiffInput struct { Path string `json:"path"` // e.g. "main.go" @@ -543,7 +543,7 @@ func (s *Service) listDirectory(ctx context.Context, req *mcp.CallToolRequest, i if err != nil { return nil, ListDirectoryOutput{}, log.E("mcp.listDirectory", "failed to list directory", err) } - slices.SortFunc(entries, func(a, b os.DirEntry) int { + slices.SortFunc(entries, func(a, b fs.DirEntry) int { return cmp.Compare(a.Name(), b.Name()) }) result := make([]DirectoryEntry, 0, len(entries)) @@ -615,7 +615,7 @@ func (s *Service) fileExists(ctx context.Context, req *mcp.CallToolRequest, inpu info, err := s.medium.Stat(input.Path) if err != nil { - if core.Is(err, os.ErrNotExist) { + if core.Is(err, fs.ErrNotExist) { return nil, FileExistsOutput{Exists: false, IsDir: false, Path: input.Path}, nil } return nil, FileExistsOutput{}, log.E("mcp.fileExists", "failed to stat path", err) @@ -760,15 +760,15 @@ func supportedLanguages() []LanguageInfo { // svc.Run(ctx) // // // TCP (set MCP_ADDR): -// os.Setenv("MCP_ADDR", "127.0.0.1:9100") +// // MCP_ADDR=127.0.0.1:9100 // svc.Run(ctx) // // // Unix socket (set MCP_UNIX_SOCKET): -// os.Setenv("MCP_UNIX_SOCKET", "/tmp/core-mcp.sock") +// // MCP_UNIX_SOCKET=/tmp/core-mcp.sock // svc.Run(ctx) // // // HTTP (set MCP_HTTP_ADDR): -// os.Setenv("MCP_HTTP_ADDR", "127.0.0.1:9101") +// // MCP_HTTP_ADDR=127.0.0.1:9101 // svc.Run(ctx) func (s *Service) Run(ctx context.Context) error { if httpAddr := core.Env("MCP_HTTP_ADDR"); httpAddr != "" { From 96fd169239bc37f50b46481866bebaa878b8f3dd Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Apr 2026 21:31:09 +0100 Subject: [PATCH 11/14] fix(mcp): rename tests to AX-10 TestFilename_Function_{Good,Bad,Ugly} (AX-10) Refactored pkg/mcp/mcp_test.go to match AX-10 naming: every test function now ends in _Good (happy path), _Bad (error path / expected failure), or _Ugly (edge case). Added missing variants across covered groups: New, GetSupportedLanguages, DetectLanguageFromPath, Medium, FileExists, ListDirectory, ResolveWorkspacePath. Closes tasks.lthn.sh/view.php?id=199 Co-authored-by: Codex --- pkg/mcp/mcp_test.go | 667 +++++++++++++++++++++++++++++--------------- 1 file changed, 446 insertions(+), 221 deletions(-) diff --git a/pkg/mcp/mcp_test.go b/pkg/mcp/mcp_test.go index cd67f32..cc0b4e4 100644 --- a/pkg/mcp/mcp_test.go +++ b/pkg/mcp/mcp_test.go @@ -6,100 +6,152 @@ import ( "testing" ) -func TestNew_Good_DefaultWorkspace(t *testing.T) { +func TestMCP_New_Good(t *testing.T) { cwd, err := os.Getwd() if err != nil { t.Fatalf("Failed to get working directory: %v", err) } - s, err := New(Options{}) - if err != nil { - t.Fatalf("Failed to create service: %v", err) - } - - if s.workspaceRoot != cwd { - t.Errorf("Expected default workspace root %s, got %s", cwd, s.workspaceRoot) - } - if s.medium == nil { - t.Error("Expected medium to be set") - } -} - -func TestNew_Good_CustomWorkspace(t *testing.T) { - tmpDir := t.TempDir() - - s, err := New(Options{WorkspaceRoot: tmpDir}) - if err != nil { - t.Fatalf("Failed to create service: %v", err) - } - - if s.workspaceRoot != tmpDir { - t.Errorf("Expected workspace root %s, got %s", tmpDir, s.workspaceRoot) - } - if s.medium == nil { - t.Error("Expected medium to be set") - } -} - -func TestNew_Good_NoRestriction(t *testing.T) { - s, err := New(Options{Unrestricted: true}) - if err != nil { - t.Fatalf("Failed to create service: %v", err) - } - - if s.workspaceRoot != "" { - t.Errorf("Expected empty workspace root, got %s", s.workspaceRoot) - } - if s.medium == nil { - t.Error("Expected medium to be set (unsandboxed)") - } -} - -func TestNew_Good_RegistersBuiltInTools(t *testing.T) { - s, err := New(Options{}) - if err != nil { - t.Fatalf("Failed to create service: %v", err) - } - - tools := map[string]bool{} - for _, rec := range s.Tools() { - tools[rec.Name] = true - } - - for _, name := range []string{ - "metrics_record", - "metrics_query", - "rag_query", - "rag_ingest", - "rag_collections", - "webview_connect", - "webview_disconnect", - "webview_navigate", - "webview_click", - "webview_type", - "webview_query", - "webview_console", - "webview_eval", - "webview_screenshot", - "webview_wait", - } { - if !tools[name] { - t.Fatalf("expected tool %q to be registered", name) + t.Run("default workspace", func(t *testing.T) { + s, err := New(Options{}) + if err != nil { + t.Fatalf("Failed to create service: %v", err) } - } - for _, name := range []string{"process_start", "ws_start"} { - if tools[name] { - t.Fatalf("did not expect tool %q to be registered without dependencies", name) + if s.workspaceRoot != cwd { + t.Errorf("Expected default workspace root %s, got %s", cwd, s.workspaceRoot) } + if s.medium == nil { + t.Error("Expected medium to be set") + } + }) + + t.Run("custom workspace", func(t *testing.T) { + tmpDir := t.TempDir() + + s, err := New(Options{WorkspaceRoot: tmpDir}) + if err != nil { + t.Fatalf("Failed to create service: %v", err) + } + + if s.workspaceRoot != tmpDir { + t.Errorf("Expected workspace root %s, got %s", tmpDir, s.workspaceRoot) + } + if s.medium == nil { + t.Error("Expected medium to be set") + } + }) + + t.Run("built in tools", func(t *testing.T) { + s, err := New(Options{}) + if err != nil { + t.Fatalf("Failed to create service: %v", err) + } + + tools := map[string]bool{} + for _, rec := range s.Tools() { + tools[rec.Name] = true + } + + for _, name := range []string{ + "file_read", + "file_write", + "file_delete", + "file_rename", + "file_exists", + "file_edit", + "dir_list", + "dir_create", + "lang_detect", + "lang_list", + "metrics_record", + "metrics_query", + "rag_query", + "rag_ingest", + "rag_collections", + "webview_connect", + "webview_disconnect", + "webview_navigate", + "webview_click", + "webview_type", + "webview_query", + "webview_console", + "webview_eval", + "webview_screenshot", + "webview_wait", + } { + if !tools[name] { + t.Fatalf("expected tool %q to be registered", name) + } + } + + for _, name := range []string{"process_start", "ws_start"} { + if tools[name] { + t.Fatalf("did not expect tool %q to be registered without dependencies", name) + } + } + }) +} + +func TestMCP_New_Bad(t *testing.T) { + s, err := New(Options{Subsystems: []Subsystem{nil}}) + if err != nil { + t.Fatalf("Failed to create service with nil subsystem entry: %v", err) + } + if got := len(s.Subsystems()); got != 0 { + t.Fatalf("expected nil subsystem entry to be ignored, got %d subsystem(s)", got) } } -func TestGetSupportedLanguages_Good_IncludesAllDetectedLanguages(t *testing.T) { - s, err := New(Options{}) - if err != nil { - t.Fatalf("Failed to create service: %v", err) - } +func TestMCP_New_Ugly(t *testing.T) { + t.Run("unrestricted ignores workspace root", func(t *testing.T) { + tmpDir := t.TempDir() + + s, err := New(Options{WorkspaceRoot: tmpDir, Unrestricted: true}) + if err != nil { + t.Fatalf("Failed to create unrestricted service: %v", err) + } + + if s.workspaceRoot != "" { + t.Errorf("Expected empty workspace root, got %s", s.workspaceRoot) + } + if s.medium == nil { + t.Error("Expected medium to be set") + } + }) + + t.Run("relative workspace root is made absolute", func(t *testing.T) { + oldWD, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + tmpDir := t.TempDir() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to chdir: %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(oldWD); err != nil { + t.Fatalf("Failed to restore working directory: %v", err) + } + }) + + s, err := New(Options{WorkspaceRoot: "."}) + if err != nil { + t.Fatalf("Failed to create service with relative workspace: %v", err) + } + + want, err := filepath.Abs(".") + if err != nil { + t.Fatalf("Failed to resolve expected workspace root: %v", err) + } + if s.workspaceRoot != want { + t.Fatalf("expected relative workspace root %q to resolve to %q", s.workspaceRoot, want) + } + }) +} + +func TestMCP_GetSupportedLanguages_Good(t *testing.T) { + s := newTestMCPService(t) _, out, err := s.getSupportedLanguages(nil, nil, GetSupportedLanguagesInput{}) if err != nil { @@ -146,7 +198,69 @@ func TestGetSupportedLanguages_Good_IncludesAllDetectedLanguages(t *testing.T) { } } -func TestDetectLanguageFromPath_Good_KnownExtensions(t *testing.T) { +func TestMCP_GetSupportedLanguages_Bad(t *testing.T) { + s := newTestMCPService(t) + + _, out, err := s.getSupportedLanguages(nil, nil, GetSupportedLanguagesInput{}) + if err != nil { + t.Fatalf("getSupportedLanguages failed: %v", err) + } + + ids := map[string]bool{} + extensions := map[string]string{} + for _, lang := range out.Languages { + if lang.ID == "" { + t.Fatal("supported language has empty ID") + } + if lang.Name == "" { + t.Fatalf("supported language %q has empty display name", lang.ID) + } + if ids[lang.ID] { + t.Fatalf("duplicate supported language ID %q", lang.ID) + } + ids[lang.ID] = true + + for _, ext := range lang.Extensions { + if ext == "" { + t.Fatalf("language %q has empty extension", lang.ID) + } + if ext[0] != '.' { + t.Fatalf("language %q has extension %q without dot prefix", lang.ID, ext) + } + if got := languageByExtension[ext]; got != lang.ID { + t.Fatalf("extension %q maps to %q, want %q", ext, got, lang.ID) + } + if owner, ok := extensions[ext]; ok { + t.Fatalf("extension %q is registered for both %q and %q", ext, owner, lang.ID) + } + extensions[ext] = lang.ID + } + } +} + +func TestMCP_GetSupportedLanguages_Ugly(t *testing.T) { + s := newTestMCPService(t) + + _, out, err := s.getSupportedLanguages(nil, nil, GetSupportedLanguagesInput{}) + if err != nil { + t.Fatalf("getSupportedLanguages failed: %v", err) + } + out.Languages[0].ID = "mutated" + out.Languages[0].Extensions[0] = ".mutated" + + _, fresh, err := s.getSupportedLanguages(nil, nil, GetSupportedLanguagesInput{}) + if err != nil { + t.Fatalf("getSupportedLanguages failed after caller mutation: %v", err) + } + if fresh.Languages[0].ID != "typescript" { + t.Fatalf("caller mutation leaked into fresh language list: %q", fresh.Languages[0].ID) + } + if fresh.Languages[0].Extensions[0] != ".ts" { + t.Fatalf("caller mutation leaked into fresh extension list: %q", fresh.Languages[0].Extensions[0]) + } +} + +func TestMCP_DetectLanguageFromPath_Good(t *testing.T) { cases := map[string]string{ "main.go": "go", "index.tsx": "typescript", @@ -163,66 +277,150 @@ func TestDetectLanguageFromPath_Good_KnownExtensions(t *testing.T) { } } -func TestMedium_Good_ReadWrite(t *testing.T) { - tmpDir := t.TempDir() - s, err := New(Options{WorkspaceRoot: tmpDir}) - if err != nil { - t.Fatalf("Failed to create service: %v", err) - } - - // Write a file - testContent := "hello world" - err = s.medium.Write("test.txt", testContent) - if err != nil { - t.Fatalf("Failed to write file: %v", err) - } - - // Read it back - content, err := s.medium.Read("test.txt") - if err != nil { - t.Fatalf("Failed to read file: %v", err) - } - if content != testContent { - t.Errorf("Expected content %q, got %q", testContent, content) - } - - // Verify file exists on disk - diskPath := filepath.Join(tmpDir, "test.txt") - if _, err := os.Stat(diskPath); os.IsNotExist(err) { - t.Error("File should exist on disk") +func TestMCP_DetectLanguageFromPath_Bad(t *testing.T) { + for _, path := range []string{"notes.unknown", "Makefile", "dockerfile"} { + if got := detectLanguageFromPath(path); got != "plaintext" { + t.Fatalf("detectLanguageFromPath(%q) = %q, want plaintext", path, got) + } } } -func TestMedium_Good_EnsureDir(t *testing.T) { - tmpDir := t.TempDir() - s, err := New(Options{WorkspaceRoot: tmpDir}) - if err != nil { - t.Fatalf("Failed to create service: %v", err) +func TestMCP_DetectLanguageFromPath_Ugly(t *testing.T) { + cases := map[string]string{ + "": "plaintext", + ".gitignore": "plaintext", + "archive.tar.gz": "plaintext", + "nested/.config/app.yaml": "yaml", } - err = s.medium.EnsureDir("subdir/nested") - if err != nil { - t.Fatalf("Failed to create directory: %v", err) - } - - // Verify directory exists - diskPath := filepath.Join(tmpDir, "subdir", "nested") - info, err := os.Stat(diskPath) - if os.IsNotExist(err) { - t.Error("Directory should exist on disk") - } - if err == nil && !info.IsDir() { - t.Error("Path should be a directory") + for path, want := range cases { + if got := detectLanguageFromPath(path); got != want { + t.Fatalf("detectLanguageFromPath(%q) = %q, want %q", path, got, want) + } } } -func TestFileExists_Good_FileAndDirectory(t *testing.T) { +func TestMCP_Medium_Good(t *testing.T) { + t.Run("read write", func(t *testing.T) { + tmpDir := t.TempDir() + s := newTestMCPServiceWithRoot(t, tmpDir) + + testContent := "hello world" + if err := s.medium.Write("test.txt", testContent); err != nil { + t.Fatalf("Failed to write file: %v", err) + } + + content, err := s.medium.Read("test.txt") + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + if content != testContent { + t.Errorf("Expected content %q, got %q", testContent, content) + } + + diskPath := filepath.Join(tmpDir, "test.txt") + if _, err := os.Stat(diskPath); os.IsNotExist(err) { + t.Error("File should exist on disk") + } + }) + + t.Run("ensure dir", func(t *testing.T) { + tmpDir := t.TempDir() + s := newTestMCPServiceWithRoot(t, tmpDir) + + if err := s.medium.EnsureDir("subdir/nested"); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + + diskPath := filepath.Join(tmpDir, "subdir", "nested") + info, err := os.Stat(diskPath) + if os.IsNotExist(err) { + t.Error("Directory should exist on disk") + } + if err == nil && !info.IsDir() { + t.Error("Path should be a directory") + } + }) + + t.Run("is file", func(t *testing.T) { + s := newTestMCPService(t) + + if s.medium.IsFile("test.txt") { + t.Error("File should not exist yet") + } + + if err := s.medium.Write("test.txt", "content"); err != nil { + t.Fatalf("Failed to write file: %v", err) + } + + if !s.medium.IsFile("test.txt") { + t.Error("File should exist after write") + } + }) +} + +func TestMCP_Medium_Bad(t *testing.T) { + t.Run("missing read", func(t *testing.T) { + s := newTestMCPService(t) + + if _, err := s.medium.Read("missing.txt"); err == nil { + t.Fatal("expected missing file read to fail") + } + }) + + t.Run("file blocks directory creation", func(t *testing.T) { + s := newTestMCPService(t) + + if err := s.medium.Write("already-file", "content"); err != nil { + t.Fatalf("Failed to write file: %v", err) + } + if err := s.medium.EnsureDir("already-file"); err == nil { + t.Fatal("expected directory creation over a file to fail") + } + }) + + t.Run("symlink escape blocked", func(t *testing.T) { + tmpDir := t.TempDir() + outsideDir := t.TempDir() + + targetFile := filepath.Join(outsideDir, "secret.txt") + if err := os.WriteFile(targetFile, []byte("secret"), 0644); err != nil { + t.Fatalf("Failed to create target file: %v", err) + } + + symlinkPath := filepath.Join(tmpDir, "link") + if err := os.Symlink(targetFile, symlinkPath); err != nil { + t.Skipf("Symlinks not supported: %v", err) + } + + s := newTestMCPServiceWithRoot(t, tmpDir) + + if _, err := s.medium.Read("link"); err == nil { + t.Error("Expected permission denied for symlink escaping sandbox, but read succeeded") + } + }) +} + +func TestMCP_Medium_Ugly(t *testing.T) { tmpDir := t.TempDir() - s, err := New(Options{WorkspaceRoot: tmpDir}) - if err != nil { - t.Fatalf("Failed to create service: %v", err) + s := newTestMCPServiceWithRoot(t, tmpDir) + + if err := s.medium.Write("../notes.txt", "inside workspace"); err != nil { + t.Fatalf("Failed to write traversal path: %v", err) } + content, err := os.ReadFile(filepath.Join(tmpDir, "notes.txt")) + if err != nil { + t.Fatalf("expected traversal path to be sanitized inside workspace: %v", err) + } + if string(content) != "inside workspace" { + t.Fatalf("expected sanitized traversal content, got %q", content) + } +} + +func TestMCP_FileExists_Good(t *testing.T) { + s := newTestMCPService(t) + if err := s.medium.EnsureDir("nested"); err != nil { t.Fatalf("Failed to create directory: %v", err) } @@ -253,12 +451,38 @@ func TestFileExists_Good_FileAndDirectory(t *testing.T) { } } -func TestListDirectory_Good_ReturnsDocumentedEntryPaths(t *testing.T) { - tmpDir := t.TempDir() - s, err := New(Options{WorkspaceRoot: tmpDir}) +func TestMCP_FileExists_Bad(t *testing.T) { + s := newTestMCPService(t) + + _, out, err := s.fileExists(nil, nil, FileExistsInput{Path: "missing.txt"}) if err != nil { - t.Fatalf("Failed to create service: %v", err) + t.Fatalf("fileExists(missing) failed: %v", err) } + if out.Exists { + t.Fatal("expected missing file to not exist") + } + if out.IsDir { + t.Fatal("expected missing file to not be reported as a directory") + } +} + +func TestMCP_FileExists_Ugly(t *testing.T) { + s := newTestMCPService(t) + + _, out, err := s.fileExists(nil, nil, FileExistsInput{Path: ""}) + if err != nil { + t.Fatalf("fileExists(empty path) failed: %v", err) + } + if !out.Exists { + t.Fatal("expected empty path to resolve to existing workspace root") + } + if !out.IsDir { + t.Fatal("expected empty path to report workspace root as a directory") + } +} + +func TestMCP_ListDirectory_Good(t *testing.T) { + s := newTestMCPService(t) if err := s.medium.EnsureDir("nested"); err != nil { t.Fatalf("Failed to create directory: %v", err) @@ -281,39 +505,54 @@ func TestListDirectory_Good_ReturnsDocumentedEntryPaths(t *testing.T) { } } -func TestMedium_Good_IsFile(t *testing.T) { - tmpDir := t.TempDir() - s, err := New(Options{WorkspaceRoot: tmpDir}) - if err != nil { - t.Fatalf("Failed to create service: %v", err) - } +func TestMCP_ListDirectory_Bad(t *testing.T) { + s := newTestMCPService(t) - // File doesn't exist yet - if s.medium.IsFile("test.txt") { - t.Error("File should not exist yet") - } - - // Create the file - _ = s.medium.Write("test.txt", "content") - - // Now it should exist - if !s.medium.IsFile("test.txt") { - t.Error("File should exist after write") + if _, _, err := s.listDirectory(nil, nil, ListDirectoryInput{Path: "missing"}); err == nil { + t.Fatal("expected missing directory list to fail") } } -func TestResolveWorkspacePath_Good(t *testing.T) { - tmpDir := t.TempDir() - s, err := New(Options{WorkspaceRoot: tmpDir}) - if err != nil { - t.Fatalf("Failed to create service: %v", err) +func TestMCP_ListDirectory_Ugly(t *testing.T) { + s := newTestMCPService(t) + + if err := s.medium.Write("z.txt", "z"); err != nil { + t.Fatalf("Failed to write z.txt: %v", err) + } + if err := s.medium.Write("a.txt", "a"); err != nil { + t.Fatalf("Failed to write a.txt: %v", err) + } + if err := s.medium.EnsureDir("dir"); err != nil { + t.Fatalf("Failed to create directory: %v", err) } + _, out, err := s.listDirectory(nil, nil, ListDirectoryInput{Path: ""}) + if err != nil { + t.Fatalf("listDirectory(root) failed: %v", err) + } + + got := make([]string, 0, len(out.Entries)) + for _, entry := range out.Entries { + got = append(got, entry.Path) + } + want := []string{"a.txt", "dir", "z.txt"} + if len(got) != len(want) { + t.Fatalf("expected entries %v, got %v", want, got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("expected entries %v, got %v", want, got) + } + } +} + +func TestMCP_ResolveWorkspacePath_Good(t *testing.T) { + tmpDir := t.TempDir() + s := newTestMCPServiceWithRoot(t, tmpDir) + cases := map[string]string{ - "docs/readme.md": filepath.Join(tmpDir, "docs", "readme.md"), - "/docs/readme.md": filepath.Join(tmpDir, "docs", "readme.md"), - "../escape/notes.md": filepath.Join(tmpDir, "escape", "notes.md"), - "": "", + "docs/readme.md": filepath.Join(tmpDir, "docs", "readme.md"), + "/docs/readme.md": filepath.Join(tmpDir, "docs", "readme.md"), } for input, want := range cases { if got := s.resolveWorkspacePath(input); got != want { @@ -322,66 +561,52 @@ func TestResolveWorkspacePath_Good(t *testing.T) { } } -func TestResolveWorkspacePath_Good_Unrestricted(t *testing.T) { - s, err := New(Options{Unrestricted: true}) - if err != nil { - t.Fatalf("Failed to create service: %v", err) - } - - if got, want := s.resolveWorkspacePath("docs/readme.md"), filepath.Clean("docs/readme.md"); got != want { - t.Fatalf("resolveWorkspacePath(relative) = %q, want %q", got, want) - } - if got, want := s.resolveWorkspacePath("/tmp/readme.md"), filepath.Clean("/tmp/readme.md"); got != want { - t.Fatalf("resolveWorkspacePath(absolute) = %q, want %q", got, want) - } -} - -func TestSandboxing_Traversal_Sanitized(t *testing.T) { +func TestMCP_ResolveWorkspacePath_Bad(t *testing.T) { tmpDir := t.TempDir() - s, err := New(Options{WorkspaceRoot: tmpDir}) + s := newTestMCPServiceWithRoot(t, tmpDir) + + got := s.resolveWorkspacePath("../escape/notes.md") + want := filepath.Join(tmpDir, "escape", "notes.md") + if got != want { + t.Fatalf("resolveWorkspacePath(traversal) = %q, want %q", got, want) + } +} + +func TestMCP_ResolveWorkspacePath_Ugly(t *testing.T) { + t.Run("empty path", func(t *testing.T) { + s := newTestMCPService(t) + + if got := s.resolveWorkspacePath(""); got != "" { + t.Fatalf("resolveWorkspacePath(empty) = %q, want empty", got) + } + }) + + t.Run("unrestricted", func(t *testing.T) { + s, err := New(Options{Unrestricted: true}) + if err != nil { + t.Fatalf("Failed to create service: %v", err) + } + + if got, want := s.resolveWorkspacePath("docs/readme.md"), filepath.Clean("docs/readme.md"); got != want { + t.Fatalf("resolveWorkspacePath(relative) = %q, want %q", got, want) + } + if got, want := s.resolveWorkspacePath("/tmp/readme.md"), filepath.Clean("/tmp/readme.md"); got != want { + t.Fatalf("resolveWorkspacePath(absolute) = %q, want %q", got, want) + } + }) +} + +func newTestMCPService(t *testing.T) *Service { + t.Helper() + return newTestMCPServiceWithRoot(t, t.TempDir()) +} + +func newTestMCPServiceWithRoot(t *testing.T, root string) *Service { + t.Helper() + + s, err := New(Options{WorkspaceRoot: root}) if err != nil { t.Fatalf("Failed to create service: %v", err) } - - // Path traversal is sanitized (.. becomes .), so ../secret.txt becomes - // ./secret.txt in the workspace. Since that file doesn't exist, we get - // a file not found error (not a traversal error). - _, err = s.medium.Read("../secret.txt") - if err == nil { - t.Error("Expected error (file not found)") - } - - // Absolute paths are allowed through - they access the real filesystem. - // This is intentional for full filesystem access. Callers wanting sandboxing - // should validate inputs before calling Medium. -} - -func TestSandboxing_Symlinks_Blocked(t *testing.T) { - tmpDir := t.TempDir() - outsideDir := t.TempDir() - - // Create a target file outside workspace - targetFile := filepath.Join(outsideDir, "secret.txt") - if err := os.WriteFile(targetFile, []byte("secret"), 0644); err != nil { - t.Fatalf("Failed to create target file: %v", err) - } - - // Create symlink inside workspace pointing outside - symlinkPath := filepath.Join(tmpDir, "link") - if err := os.Symlink(targetFile, symlinkPath); err != nil { - t.Skipf("Symlinks not supported: %v", err) - } - - s, err := New(Options{WorkspaceRoot: tmpDir}) - if err != nil { - t.Fatalf("Failed to create service: %v", err) - } - - // Symlinks pointing outside the sandbox root are blocked (security feature). - // The sandbox resolves the symlink target and rejects it because it escapes - // the workspace boundary. - _, err = s.medium.Read("link") - if err == nil { - t.Error("Expected permission denied for symlink escaping sandbox, but read succeeded") - } + return s } From 903aba4695adb4f84e5af013338fffd7fe5ec239 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Apr 2026 21:41:40 +0100 Subject: [PATCH 12/14] =?UTF-8?q?feat(mcp):=20implement=20HandleIPCEvents?= =?UTF-8?q?=20ChannelPush=20path=20(RFC=20=C2=A75.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added handleChannelPushIPC in new pkg/mcp/ipc.go with Core-style validation (rejects empty channel). Wired through from register.go's HandleIPCEvents switch — ChannelPush now routes to the new handler. Added AX-10 Good/Bad/Ugly tests in pkg/mcp/ipc_test.go covering forwarding, empty-channel error, nil data payload. Pre-existing unrelated failure TestBridgeToAPI_Good_DescribableGroup (expects 34 descriptions, gets 33) is out of this ticket's scope. Closes tasks.lthn.sh/view.php?id=193 Co-authored-by: Codex --- pkg/mcp/ipc.go | 18 +++++++ pkg/mcp/ipc_test.go | 111 ++++++++++++++++++++++++++++++++++++++++++++ pkg/mcp/register.go | 3 +- 3 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 pkg/mcp/ipc.go create mode 100644 pkg/mcp/ipc_test.go diff --git a/pkg/mcp/ipc.go b/pkg/mcp/ipc.go new file mode 100644 index 0000000..34ea691 --- /dev/null +++ b/pkg/mcp/ipc.go @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package mcp + +import ( + "context" + + core "dappco.re/go/core" +) + +func (s *Service) handleChannelPushIPC(ctx context.Context, ev ChannelPush) core.Result { + if core.Trim(ev.Channel) == "" { + return core.Result{Value: core.E("mcp.HandleIPCEvents", "channel is required", nil), OK: false} + } + + s.ChannelSend(ctx, ev.Channel, ev.Data) + return core.Result{OK: true} +} diff --git a/pkg/mcp/ipc_test.go b/pkg/mcp/ipc_test.go new file mode 100644 index 0000000..b19dca2 --- /dev/null +++ b/pkg/mcp/ipc_test.go @@ -0,0 +1,111 @@ +package mcp + +import ( + "testing" + "time" +) + +func TestIPC_HandleIPCEvents_Good(t *testing.T) { + svc, err := New(Options{}) + if err != nil { + t.Fatalf("New() failed: %v", err) + } + + cancel, session, clientConn := connectNotificationSession(t, svc) + defer cancel() + defer session.Close() + defer clientConn.Close() + + clientConn.SetDeadline(time.Now().Add(5 * time.Second)) + read := readNotificationMessageUntil(t, clientConn, func(msg map[string]any) bool { + return msg["method"] == ChannelNotificationMethod + }) + + result := svc.HandleIPCEvents(nil, ChannelPush{ + Channel: "agent.completed", + Data: map[string]any{ + "repo": "core/mcp", + "ok": true, + }, + }) + if !result.OK { + t.Fatalf("HandleIPCEvents() returned non-OK result: %#v", result.Value) + } + + res := <-read + if res.err != nil { + t.Fatalf("failed to read channel notification: %v", res.err) + } + + params, ok := res.msg["params"].(map[string]any) + if !ok { + t.Fatalf("expected params object, got %T", res.msg["params"]) + } + if params["channel"] != "agent.completed" { + t.Fatalf("expected channel agent.completed, got %#v", params["channel"]) + } + + payload, ok := params["data"].(map[string]any) + if !ok { + t.Fatalf("expected data object, got %T", params["data"]) + } + if payload["repo"] != "core/mcp" || payload["ok"] != true { + t.Fatalf("unexpected payload: %#v", payload) + } +} + +func TestIPC_HandleIPCEvents_Bad(t *testing.T) { + svc, err := New(Options{}) + if err != nil { + t.Fatalf("New() failed: %v", err) + } + + result := svc.HandleIPCEvents(nil, ChannelPush{ + Channel: " \t ", + Data: map[string]any{"ok": false}, + }) + if result.OK { + t.Fatal("expected empty ChannelPush channel to fail") + } + if _, ok := result.Value.(error); !ok { + t.Fatalf("expected error result value, got %T", result.Value) + } +} + +func TestIPC_HandleIPCEvents_Ugly(t *testing.T) { + svc, err := New(Options{}) + if err != nil { + t.Fatalf("New() failed: %v", err) + } + + cancel, session, clientConn := connectNotificationSession(t, svc) + defer cancel() + defer session.Close() + defer clientConn.Close() + + clientConn.SetDeadline(time.Now().Add(5 * time.Second)) + read := readNotificationMessageUntil(t, clientConn, func(msg map[string]any) bool { + params, ok := msg["params"].(map[string]any) + return msg["method"] == ChannelNotificationMethod && ok && params["channel"] == "agent.edge" + }) + + result := svc.HandleIPCEvents(nil, ChannelPush{Channel: "agent.edge"}) + if !result.OK { + t.Fatalf("HandleIPCEvents() returned non-OK result: %#v", result.Value) + } + + res := <-read + if res.err != nil { + t.Fatalf("failed to read edge notification: %v", res.err) + } + params, ok := res.msg["params"].(map[string]any) + if !ok { + t.Fatalf("expected params object, got %T", res.msg["params"]) + } + if _, ok := params["data"]; !ok { + t.Fatalf("expected data key for nil ChannelPush data: %#v", params) + } + if params["data"] != nil { + t.Fatalf("expected nil data, got %#v", params["data"]) + } +} diff --git a/pkg/mcp/register.go b/pkg/mcp/register.go index e3c6dc1..db38351 100644 --- a/pkg/mcp/register.go +++ b/pkg/mcp/register.go @@ -98,6 +98,7 @@ func (s *Service) OnStartup(ctx context.Context) core.Result { // HandleIPCEvents implements Core's IPC handler interface. // // c.ACTION(mcp.ChannelPush{Channel: "agent.status", Data: statusMap}) +// // Catches ChannelPush messages from other services and pushes them to Claude Code sessions. func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) core.Result { ctx := context.Background() @@ -109,7 +110,7 @@ func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) core.Result { switch ev := msg.(type) { case ChannelPush: - s.ChannelSend(ctx, ev.Channel, ev.Data) + return s.handleChannelPushIPC(ctx, ev) case process.ActionProcessStarted: startedAt := time.Now() s.recordProcessRuntime(ev.ID, processRuntime{ From b2ed228b3fab885bdc74883f2741cdc10a6f2c55 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 24 Apr 2026 23:35:37 +0100 Subject: [PATCH 13/14] feat(ax-10): bring mcp to v0.8.0-alpha.1 + CLI test scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migrate go.mod direct + indirect deps from dappco.re/go/core/X (pre-migration paths) to dappco.re/go/X at v0.8.0-alpha.1 - Update all Go source imports across 49 files: dappco.re/go/core/{ai,api,cli,io,log,process,rag,webview,ws,i18n,inference} -> dappco.re/go/{ai,api,cli,io,log,process,rag,webview,ws,i18n,inference} - Add tests/cli/mcp/Taskfile.yaml AX-10 scaffold (build / vet / test under default deps), per RFC-CORE-008-AGENT-EXPERIENCE.md §10 - mcp is library + 4 binaries (brain-seed, core-mcp, mcpcmd, openbrain-mcp); the build target validates all of them Closes tasks.lthn.sh/view.php?id=198 Co-Authored-By: Athena --- cmd/brain-seed/main.go | 4 ++-- cmd/core-mcp/main.go | 2 +- cmd/openbrain-mcp/main.go | 2 +- go.mod | 22 +++++++++++----------- pkg/mcp/agentic/dispatch.go | 4 ++-- pkg/mcp/agentic/epic.go | 2 +- pkg/mcp/agentic/ingest.go | 2 +- pkg/mcp/agentic/issue.go | 2 +- pkg/mcp/agentic/mirror.go | 2 +- pkg/mcp/agentic/plan.go | 4 ++-- pkg/mcp/agentic/pr.go | 4 ++-- pkg/mcp/agentic/prep.go | 4 ++-- pkg/mcp/agentic/queue.go | 2 +- pkg/mcp/agentic/repo_helpers.go | 4 ++-- pkg/mcp/agentic/resume.go | 4 ++-- pkg/mcp/agentic/review_queue.go | 2 +- pkg/mcp/agentic/scan.go | 2 +- pkg/mcp/agentic/status.go | 4 ++-- pkg/mcp/agentic/watch.go | 2 +- pkg/mcp/agentic/write_atomic.go | 2 +- pkg/mcp/authz.go | 2 +- pkg/mcp/brain/brain.go | 2 +- pkg/mcp/brain/direct.go | 4 ++-- pkg/mcp/brain/provider.go | 6 +++--- pkg/mcp/brain/tools.go | 2 +- pkg/mcp/bridge.go | 2 +- pkg/mcp/bridge_test.go | 2 +- pkg/mcp/ide/bridge.go | 4 ++-- pkg/mcp/ide/bridge_test.go | 2 +- pkg/mcp/ide/ide.go | 4 ++-- pkg/mcp/ide/tools_chat.go | 2 +- pkg/mcp/ide/tools_test.go | 2 +- pkg/mcp/mcp.go | 8 ++++---- pkg/mcp/register.go | 4 ++-- pkg/mcp/register_test.go | 4 ++-- pkg/mcp/registry_test.go | 2 +- pkg/mcp/tools_metrics.go | 4 ++-- pkg/mcp/tools_process.go | 4 ++-- pkg/mcp/tools_process_ci_test.go | 2 +- pkg/mcp/tools_rag.go | 4 ++-- pkg/mcp/tools_webview.go | 4 ++-- pkg/mcp/tools_webview_embed.go | 2 +- pkg/mcp/tools_webview_test.go | 2 +- pkg/mcp/tools_ws.go | 4 ++-- pkg/mcp/tools_ws_client.go | 2 +- pkg/mcp/tools_ws_test.go | 2 +- pkg/mcp/transport_http.go | 4 ++-- pkg/mcp/transport_stdio.go | 2 +- pkg/mcp/transport_unix.go | 4 ++-- tests/cli/mcp/Taskfile.yaml | 26 ++++++++++++++++++++++++++ 50 files changed, 110 insertions(+), 84 deletions(-) create mode 100644 tests/cli/mcp/Taskfile.yaml diff --git a/cmd/brain-seed/main.go b/cmd/brain-seed/main.go index 726e97d..ac0d22a 100644 --- a/cmd/brain-seed/main.go +++ b/cmd/brain-seed/main.go @@ -27,8 +27,8 @@ import ( "strings" "time" - coreio "dappco.re/go/core/io" - coreerr "dappco.re/go/core/log" + coreio "dappco.re/go/io" + coreerr "dappco.re/go/log" ) var ( diff --git a/cmd/core-mcp/main.go b/cmd/core-mcp/main.go index d64557e..7a7ca87 100644 --- a/cmd/core-mcp/main.go +++ b/cmd/core-mcp/main.go @@ -1,7 +1,7 @@ package main import ( - "dappco.re/go/core/cli/pkg/cli" + "dappco.re/go/cli/pkg/cli" mcpcmd "dappco.re/go/mcp/cmd/mcpcmd" ) diff --git a/cmd/openbrain-mcp/main.go b/cmd/openbrain-mcp/main.go index fceba37..cc73da5 100644 --- a/cmd/openbrain-mcp/main.go +++ b/cmd/openbrain-mcp/main.go @@ -12,7 +12,7 @@ import ( "time" core "dappco.re/go/core" - coreerr "dappco.re/go/core/log" + coreerr "dappco.re/go/log" "dappco.re/go/mcp/pkg/mcp" "dappco.re/go/mcp/pkg/mcp/brain" ) diff --git a/go.mod b/go.mod index 295c21c..9da690b 100644 --- a/go.mod +++ b/go.mod @@ -4,15 +4,15 @@ go 1.26.0 require ( dappco.re/go/core v0.8.0-alpha.1 - dappco.re/go/core/ai v0.2.2 - dappco.re/go/core/api v0.3.0 - dappco.re/go/core/cli v0.5.2 - dappco.re/go/core/io v0.4.1 - dappco.re/go/core/log v0.1.2 - dappco.re/go/core/process v0.5.0 - dappco.re/go/core/rag v0.1.13 - dappco.re/go/core/webview v0.2.1 - dappco.re/go/core/ws v0.4.0 + dappco.re/go/ai v0.8.0-alpha.1 + dappco.re/go/api v0.8.0-alpha.1 + dappco.re/go/cli v0.8.0-alpha.1 + dappco.re/go/io v0.8.0-alpha.1 + dappco.re/go/log v0.8.0-alpha.1 + dappco.re/go/process v0.8.0-alpha.1 + dappco.re/go/rag v0.8.0-alpha.1 + dappco.re/go/webview v0.8.0-alpha.1 + dappco.re/go/ws v0.8.0-alpha.1 github.com/gin-gonic/gin v1.12.0 github.com/gorilla/websocket v1.5.3 github.com/modelcontextprotocol/go-sdk v1.5.0 @@ -21,8 +21,8 @@ require ( ) require ( - dappco.re/go/core/i18n v0.2.3 // indirect - dappco.re/go/core/inference v0.3.0 // indirect + dappco.re/go/i18n v0.8.0-alpha.1 // indirect + dappco.re/go/inference v0.8.0-alpha.1 // indirect github.com/99designs/gqlgen v0.17.88 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect diff --git a/pkg/mcp/agentic/dispatch.go b/pkg/mcp/agentic/dispatch.go index 0ed8f08..f4d6a88 100644 --- a/pkg/mcp/agentic/dispatch.go +++ b/pkg/mcp/agentic/dispatch.go @@ -10,8 +10,8 @@ import ( "time" core "dappco.re/go/core" - coreio "dappco.re/go/core/io" - coreerr "dappco.re/go/core/log" + coreio "dappco.re/go/io" + coreerr "dappco.re/go/log" coremcp "dappco.re/go/mcp/pkg/mcp" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/agentic/epic.go b/pkg/mcp/agentic/epic.go index 6f8dd4d..256f25b 100644 --- a/pkg/mcp/agentic/epic.go +++ b/pkg/mcp/agentic/epic.go @@ -9,7 +9,7 @@ import ( "net/http" core "dappco.re/go/core" - coreerr "dappco.re/go/core/log" + coreerr "dappco.re/go/log" coremcp "dappco.re/go/mcp/pkg/mcp" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/agentic/ingest.go b/pkg/mcp/agentic/ingest.go index ca87761..3a3c23a 100644 --- a/pkg/mcp/agentic/ingest.go +++ b/pkg/mcp/agentic/ingest.go @@ -7,7 +7,7 @@ import ( "net/http" core "dappco.re/go/core" - coreio "dappco.re/go/core/io" + coreio "dappco.re/go/io" coremcp "dappco.re/go/mcp/pkg/mcp" ) diff --git a/pkg/mcp/agentic/issue.go b/pkg/mcp/agentic/issue.go index 1b66c0e..bfae689 100644 --- a/pkg/mcp/agentic/issue.go +++ b/pkg/mcp/agentic/issue.go @@ -9,7 +9,7 @@ import ( "net/http" core "dappco.re/go/core" - coreerr "dappco.re/go/core/log" + coreerr "dappco.re/go/log" coremcp "dappco.re/go/mcp/pkg/mcp" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/agentic/mirror.go b/pkg/mcp/agentic/mirror.go index 708c99c..1849693 100644 --- a/pkg/mcp/agentic/mirror.go +++ b/pkg/mcp/agentic/mirror.go @@ -7,7 +7,7 @@ import ( "os/exec" core "dappco.re/go/core" - coreerr "dappco.re/go/core/log" + coreerr "dappco.re/go/log" coremcp "dappco.re/go/mcp/pkg/mcp" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/agentic/plan.go b/pkg/mcp/agentic/plan.go index e872069..cc7d23e 100644 --- a/pkg/mcp/agentic/plan.go +++ b/pkg/mcp/agentic/plan.go @@ -10,8 +10,8 @@ import ( "time" core "dappco.re/go/core" - coreio "dappco.re/go/core/io" - coreerr "dappco.re/go/core/log" + coreio "dappco.re/go/io" + coreerr "dappco.re/go/log" coremcp "dappco.re/go/mcp/pkg/mcp" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/agentic/pr.go b/pkg/mcp/agentic/pr.go index 9f624b0..f766c80 100644 --- a/pkg/mcp/agentic/pr.go +++ b/pkg/mcp/agentic/pr.go @@ -10,8 +10,8 @@ import ( "os/exec" core "dappco.re/go/core" - coreio "dappco.re/go/core/io" - coreerr "dappco.re/go/core/log" + coreio "dappco.re/go/io" + coreerr "dappco.re/go/log" coremcp "dappco.re/go/mcp/pkg/mcp" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/agentic/prep.go b/pkg/mcp/agentic/prep.go index 71ca64e..4c4f859 100644 --- a/pkg/mcp/agentic/prep.go +++ b/pkg/mcp/agentic/prep.go @@ -13,8 +13,8 @@ import ( "time" core "dappco.re/go/core" - coreio "dappco.re/go/core/io" - coreerr "dappco.re/go/core/log" + coreio "dappco.re/go/io" + coreerr "dappco.re/go/log" coremcp "dappco.re/go/mcp/pkg/mcp" "github.com/modelcontextprotocol/go-sdk/mcp" "gopkg.in/yaml.v3" diff --git a/pkg/mcp/agentic/queue.go b/pkg/mcp/agentic/queue.go index a02c7b9..5eaf772 100644 --- a/pkg/mcp/agentic/queue.go +++ b/pkg/mcp/agentic/queue.go @@ -9,7 +9,7 @@ import ( "time" core "dappco.re/go/core" - coreio "dappco.re/go/core/io" + coreio "dappco.re/go/io" "gopkg.in/yaml.v3" ) diff --git a/pkg/mcp/agentic/repo_helpers.go b/pkg/mcp/agentic/repo_helpers.go index cb03de0..1930cac 100644 --- a/pkg/mcp/agentic/repo_helpers.go +++ b/pkg/mcp/agentic/repo_helpers.go @@ -11,8 +11,8 @@ import ( "time" core "dappco.re/go/core" - coreio "dappco.re/go/core/io" - coreerr "dappco.re/go/core/log" + coreio "dappco.re/go/io" + coreerr "dappco.re/go/log" ) func listLocalRepos(basePath string) []string { diff --git a/pkg/mcp/agentic/resume.go b/pkg/mcp/agentic/resume.go index ac5160f..a70976b 100644 --- a/pkg/mcp/agentic/resume.go +++ b/pkg/mcp/agentic/resume.go @@ -9,8 +9,8 @@ import ( "syscall" core "dappco.re/go/core" - coreio "dappco.re/go/core/io" - coreerr "dappco.re/go/core/log" + coreio "dappco.re/go/io" + coreerr "dappco.re/go/log" coremcp "dappco.re/go/mcp/pkg/mcp" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/agentic/review_queue.go b/pkg/mcp/agentic/review_queue.go index 050df04..832f022 100644 --- a/pkg/mcp/agentic/review_queue.go +++ b/pkg/mcp/agentic/review_queue.go @@ -11,7 +11,7 @@ import ( "time" core "dappco.re/go/core" - coreio "dappco.re/go/core/io" + coreio "dappco.re/go/io" coremcp "dappco.re/go/mcp/pkg/mcp" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/agentic/scan.go b/pkg/mcp/agentic/scan.go index 7e40f47..0817df5 100644 --- a/pkg/mcp/agentic/scan.go +++ b/pkg/mcp/agentic/scan.go @@ -8,7 +8,7 @@ import ( "net/http" core "dappco.re/go/core" - coreerr "dappco.re/go/core/log" + coreerr "dappco.re/go/log" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/agentic/status.go b/pkg/mcp/agentic/status.go index d789ece..3a300c5 100644 --- a/pkg/mcp/agentic/status.go +++ b/pkg/mcp/agentic/status.go @@ -9,8 +9,8 @@ import ( "time" core "dappco.re/go/core" - coreio "dappco.re/go/core/io" - coreerr "dappco.re/go/core/log" + coreio "dappco.re/go/io" + coreerr "dappco.re/go/log" coremcp "dappco.re/go/mcp/pkg/mcp" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/agentic/watch.go b/pkg/mcp/agentic/watch.go index 63a8b03..f286184 100644 --- a/pkg/mcp/agentic/watch.go +++ b/pkg/mcp/agentic/watch.go @@ -7,7 +7,7 @@ import ( "time" core "dappco.re/go/core" - coreerr "dappco.re/go/core/log" + coreerr "dappco.re/go/log" coremcp "dappco.re/go/mcp/pkg/mcp" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/agentic/write_atomic.go b/pkg/mcp/agentic/write_atomic.go index 8f46118..c323788 100644 --- a/pkg/mcp/agentic/write_atomic.go +++ b/pkg/mcp/agentic/write_atomic.go @@ -6,7 +6,7 @@ import ( "os" core "dappco.re/go/core" - coreio "dappco.re/go/core/io" + coreio "dappco.re/go/io" ) // os.CreateTemp, os.Remove, os.Rename are framework-boundary calls for diff --git a/pkg/mcp/authz.go b/pkg/mcp/authz.go index 57fe9d0..a88412f 100644 --- a/pkg/mcp/authz.go +++ b/pkg/mcp/authz.go @@ -17,7 +17,7 @@ import ( "time" core "dappco.re/go/core" - coreerr "dappco.re/go/core/log" + coreerr "dappco.re/go/log" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/brain/brain.go b/pkg/mcp/brain/brain.go index b6ca73d..a0817c9 100644 --- a/pkg/mcp/brain/brain.go +++ b/pkg/mcp/brain/brain.go @@ -9,7 +9,7 @@ import ( coremcp "dappco.re/go/mcp/pkg/mcp" "dappco.re/go/mcp/pkg/mcp/ide" - coreerr "dappco.re/go/core/log" + coreerr "dappco.re/go/log" ) // errBridgeNotAvailable is returned when a tool requires the Laravel bridge diff --git a/pkg/mcp/brain/direct.go b/pkg/mcp/brain/direct.go index cb59d3a..56a3271 100644 --- a/pkg/mcp/brain/direct.go +++ b/pkg/mcp/brain/direct.go @@ -9,8 +9,8 @@ import ( "time" core "dappco.re/go/core" - coreio "dappco.re/go/core/io" - coreerr "dappco.re/go/core/log" + coreio "dappco.re/go/io" + coreerr "dappco.re/go/log" coremcp "dappco.re/go/mcp/pkg/mcp" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/brain/provider.go b/pkg/mcp/brain/provider.go index 3518e0c..ea3041f 100644 --- a/pkg/mcp/brain/provider.go +++ b/pkg/mcp/brain/provider.go @@ -7,9 +7,9 @@ import ( coremcp "dappco.re/go/mcp/pkg/mcp" "dappco.re/go/mcp/pkg/mcp/ide" - "dappco.re/go/core/api" - "dappco.re/go/core/api/pkg/provider" - "dappco.re/go/core/ws" + "dappco.re/go/api" + "dappco.re/go/api/pkg/provider" + "dappco.re/go/ws" "github.com/gin-gonic/gin" ) diff --git a/pkg/mcp/brain/tools.go b/pkg/mcp/brain/tools.go index 92e131f..c091f01 100644 --- a/pkg/mcp/brain/tools.go +++ b/pkg/mcp/brain/tools.go @@ -8,7 +8,7 @@ import ( coremcp "dappco.re/go/mcp/pkg/mcp" "dappco.re/go/mcp/pkg/mcp/ide" - coreerr "dappco.re/go/core/log" + coreerr "dappco.re/go/log" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/bridge.go b/pkg/mcp/bridge.go index 22a1e8a..fda799b 100644 --- a/pkg/mcp/bridge.go +++ b/pkg/mcp/bridge.go @@ -8,7 +8,7 @@ import ( core "dappco.re/go/core" "github.com/gin-gonic/gin" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) // maxBodySize is the maximum request body size accepted by bridged tool endpoints. diff --git a/pkg/mcp/bridge_test.go b/pkg/mcp/bridge_test.go index 4bfd169..1dfee19 100644 --- a/pkg/mcp/bridge_test.go +++ b/pkg/mcp/bridge_test.go @@ -17,7 +17,7 @@ import ( "dappco.re/go/mcp/pkg/mcp/agentic" "dappco.re/go/mcp/pkg/mcp/brain" "dappco.re/go/mcp/pkg/mcp/ide" - api "dappco.re/go/core/api" + api "dappco.re/go/api" ) func init() { diff --git a/pkg/mcp/ide/bridge.go b/pkg/mcp/ide/bridge.go index 928eb8a..67aceaa 100644 --- a/pkg/mcp/ide/bridge.go +++ b/pkg/mcp/ide/bridge.go @@ -9,8 +9,8 @@ import ( "sync" "time" - coreerr "dappco.re/go/core/log" - "dappco.re/go/core/ws" + coreerr "dappco.re/go/log" + "dappco.re/go/ws" "github.com/gorilla/websocket" ) diff --git a/pkg/mcp/ide/bridge_test.go b/pkg/mcp/ide/bridge_test.go index 41f9ae1..823a1e2 100644 --- a/pkg/mcp/ide/bridge_test.go +++ b/pkg/mcp/ide/bridge_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "dappco.re/go/core/ws" + "dappco.re/go/ws" "github.com/gorilla/websocket" ) diff --git a/pkg/mcp/ide/ide.go b/pkg/mcp/ide/ide.go index 1832ee9..fcbf21f 100644 --- a/pkg/mcp/ide/ide.go +++ b/pkg/mcp/ide/ide.go @@ -9,8 +9,8 @@ import ( core "dappco.re/go/core" coremcp "dappco.re/go/mcp/pkg/mcp" - coreerr "dappco.re/go/core/log" - "dappco.re/go/core/ws" + coreerr "dappco.re/go/log" + "dappco.re/go/ws" ) // errBridgeNotAvailable is returned when a tool requires the Laravel bridge diff --git a/pkg/mcp/ide/tools_chat.go b/pkg/mcp/ide/tools_chat.go index 068ac36..3133eed 100644 --- a/pkg/mcp/ide/tools_chat.go +++ b/pkg/mcp/ide/tools_chat.go @@ -7,7 +7,7 @@ import ( "time" coremcp "dappco.re/go/mcp/pkg/mcp" - coreerr "dappco.re/go/core/log" + coreerr "dappco.re/go/log" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/ide/tools_test.go b/pkg/mcp/ide/tools_test.go index c462e9f..59c5855 100644 --- a/pkg/mcp/ide/tools_test.go +++ b/pkg/mcp/ide/tools_test.go @@ -9,7 +9,7 @@ import ( "time" coremcp "dappco.re/go/mcp/pkg/mcp" - "dappco.re/go/core/ws" + "dappco.re/go/ws" ) // --- Helpers --- diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index a022402..29f724b 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -15,10 +15,10 @@ import ( "sync" core "dappco.re/go/core" - "dappco.re/go/core/io" - "dappco.re/go/core/log" - "dappco.re/go/core/process" - "dappco.re/go/core/ws" + "dappco.re/go/io" + "dappco.re/go/log" + "dappco.re/go/process" + "dappco.re/go/ws" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/register.go b/pkg/mcp/register.go index db38351..51ab889 100644 --- a/pkg/mcp/register.go +++ b/pkg/mcp/register.go @@ -7,8 +7,8 @@ import ( "time" core "dappco.re/go/core" - "dappco.re/go/core/process" - "dappco.re/go/core/ws" + "dappco.re/go/process" + "dappco.re/go/ws" ) // Register is the service factory for core.WithService. diff --git a/pkg/mcp/register_test.go b/pkg/mcp/register_test.go index b907e66..f1bf904 100644 --- a/pkg/mcp/register_test.go +++ b/pkg/mcp/register_test.go @@ -9,8 +9,8 @@ import ( "time" "dappco.re/go/core" - "dappco.re/go/core/process" - "dappco.re/go/core/ws" + "dappco.re/go/process" + "dappco.re/go/ws" ) func TestRegister_Good_WiresOptionalServices(t *testing.T) { diff --git a/pkg/mcp/registry_test.go b/pkg/mcp/registry_test.go index c2019d8..4a1a807 100644 --- a/pkg/mcp/registry_test.go +++ b/pkg/mcp/registry_test.go @@ -7,7 +7,7 @@ import ( "errors" "testing" - "dappco.re/go/core/process" + "dappco.re/go/process" ) func TestToolRegistry_Good_RecordsTools(t *testing.T) { diff --git a/pkg/mcp/tools_metrics.go b/pkg/mcp/tools_metrics.go index 6214f5b..3979e62 100644 --- a/pkg/mcp/tools_metrics.go +++ b/pkg/mcp/tools_metrics.go @@ -8,8 +8,8 @@ import ( "time" core "dappco.re/go/core" - "dappco.re/go/core/ai/ai" - "dappco.re/go/core/log" + "dappco.re/go/ai/ai" + "dappco.re/go/log" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/tools_process.go b/pkg/mcp/tools_process.go index a319aec..9760bed 100644 --- a/pkg/mcp/tools_process.go +++ b/pkg/mcp/tools_process.go @@ -6,8 +6,8 @@ import ( "context" "time" - "dappco.re/go/core/log" - "dappco.re/go/core/process" + "dappco.re/go/log" + "dappco.re/go/process" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/tools_process_ci_test.go b/pkg/mcp/tools_process_ci_test.go index 2e7dfe9..5c81e84 100644 --- a/pkg/mcp/tools_process_ci_test.go +++ b/pkg/mcp/tools_process_ci_test.go @@ -9,7 +9,7 @@ import ( "time" "dappco.re/go/core" - "dappco.re/go/core/process" + "dappco.re/go/process" ) // newTestProcessService creates a real process.Service backed by a core.Core for CI tests. diff --git a/pkg/mcp/tools_rag.go b/pkg/mcp/tools_rag.go index ab9b981..3397175 100644 --- a/pkg/mcp/tools_rag.go +++ b/pkg/mcp/tools_rag.go @@ -6,8 +6,8 @@ import ( "context" core "dappco.re/go/core" - "dappco.re/go/core/log" - "dappco.re/go/core/rag" + "dappco.re/go/log" + "dappco.re/go/rag" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/tools_webview.go b/pkg/mcp/tools_webview.go index 734c223..f60e6c1 100644 --- a/pkg/mcp/tools_webview.go +++ b/pkg/mcp/tools_webview.go @@ -13,8 +13,8 @@ import ( "time" core "dappco.re/go/core" - "dappco.re/go/core/log" - "dappco.re/go/core/webview" + "dappco.re/go/log" + "dappco.re/go/webview" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/tools_webview_embed.go b/pkg/mcp/tools_webview_embed.go index ff6d336..8d91abb 100644 --- a/pkg/mcp/tools_webview_embed.go +++ b/pkg/mcp/tools_webview_embed.go @@ -8,7 +8,7 @@ import ( "time" core "dappco.re/go/core" - "dappco.re/go/core/log" + "dappco.re/go/log" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/tools_webview_test.go b/pkg/mcp/tools_webview_test.go index 43b3661..53b2b3a 100644 --- a/pkg/mcp/tools_webview_test.go +++ b/pkg/mcp/tools_webview_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "dappco.re/go/core/webview" + "dappco.re/go/webview" ) // skipIfShort skips webview tests in short mode (go test -short). diff --git a/pkg/mcp/tools_ws.go b/pkg/mcp/tools_ws.go index 4d46c17..e8d810b 100644 --- a/pkg/mcp/tools_ws.go +++ b/pkg/mcp/tools_ws.go @@ -8,8 +8,8 @@ import ( "net/http" core "dappco.re/go/core" - "dappco.re/go/core/log" - "dappco.re/go/core/ws" + "dappco.re/go/log" + "dappco.re/go/ws" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/tools_ws_client.go b/pkg/mcp/tools_ws_client.go index 1895d1a..c333d63 100644 --- a/pkg/mcp/tools_ws_client.go +++ b/pkg/mcp/tools_ws_client.go @@ -11,7 +11,7 @@ import ( "time" core "dappco.re/go/core" - "dappco.re/go/core/log" + "dappco.re/go/log" "github.com/gorilla/websocket" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/tools_ws_test.go b/pkg/mcp/tools_ws_test.go index 831022b..6d775fd 100644 --- a/pkg/mcp/tools_ws_test.go +++ b/pkg/mcp/tools_ws_test.go @@ -3,7 +3,7 @@ package mcp import ( "testing" - "dappco.re/go/core/ws" + "dappco.re/go/ws" ) // TestWSToolsRegistered_Good verifies that WebSocket tools are registered when hub is available. diff --git a/pkg/mcp/transport_http.go b/pkg/mcp/transport_http.go index c82b703..8841de7 100644 --- a/pkg/mcp/transport_http.go +++ b/pkg/mcp/transport_http.go @@ -11,8 +11,8 @@ import ( "time" core "dappco.re/go/core" - coreerr "dappco.re/go/core/log" - api "dappco.re/go/core/api" + coreerr "dappco.re/go/log" + api "dappco.re/go/api" "github.com/gin-gonic/gin" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/transport_stdio.go b/pkg/mcp/transport_stdio.go index db5bed8..d72d3fb 100644 --- a/pkg/mcp/transport_stdio.go +++ b/pkg/mcp/transport_stdio.go @@ -6,7 +6,7 @@ import ( "context" "os" - "dappco.re/go/core/log" + "dappco.re/go/log" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/transport_unix.go b/pkg/mcp/transport_unix.go index 1889183..f1d2493 100644 --- a/pkg/mcp/transport_unix.go +++ b/pkg/mcp/transport_unix.go @@ -6,8 +6,8 @@ import ( "context" "net" - "dappco.re/go/core/io" - "dappco.re/go/core/log" + "dappco.re/go/io" + "dappco.re/go/log" ) // ServeUnix starts a Unix domain socket server for the MCP service. diff --git a/tests/cli/mcp/Taskfile.yaml b/tests/cli/mcp/Taskfile.yaml new file mode 100644 index 0000000..3d61778 --- /dev/null +++ b/tests/cli/mcp/Taskfile.yaml @@ -0,0 +1,26 @@ +version: "3" + +tasks: + default: + deps: + - build + - vet + - test + + build: + desc: Compile every package + binary in mcp. + dir: ../../.. + cmds: + - GOWORK=off go build ./... + + vet: + desc: Run go vet across the module. + dir: ../../.. + cmds: + - GOWORK=off go vet ./... + + test: + desc: Run unit tests. + dir: ../../.. + cmds: + - GOWORK=off go test -count=1 ./... From 95f8ad387cc1c51f5f8684fee0ab476fc0d59abb Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 25 Apr 2026 01:40:43 +0100 Subject: [PATCH 14/14] docs(security): document accepted ollama CVEs + operator runbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes Mantis #323. All 9 CVEs filed in #323 (govulncheck against the github.com/ollama/ollama indirect dep) are unfixed upstream as of 2026-04-25. We are on v0.18.1 indirect via go-rag; ollama upstream is at v0.21.2 (3 days old). Pin-bump resolves none of them. Documents: - CVE-by-CVE reachability assessment in our call graph - 7 server-side CVEs (GZIP DoS, OOB, divzero, nullderef, server DoS) → unreachable; we are a client, not a server - 1 conditional (GO-2025-3824 token exposure) → watch flag, reachable IF we ever add auth tokens - 1 operator-side (GO-2025-4251 missing auth) → operator runbook required Operator runbook covers: - Network-level isolation (localhost-only or private-network binding) - Reverse-proxy + auth for shared deployments - CI-side govulncheck filter scoped to just these 9 CVE IDs Surface in use: 3 symbols only (api.NewClient, api.Client, api.EmbedRequest) imported from one file (go-rag/ollama.go). Vendor-fork would be over-engineering for this scope; pin-bump is unavailable. Argus filed; athena reviewed + documented. Co-Authored-By: Argus Co-Authored-By: Athena Co-Authored-By: Virgil --- docs/security-vulnerabilities.md | 141 +++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 docs/security-vulnerabilities.md diff --git a/docs/security-vulnerabilities.md b/docs/security-vulnerabilities.md new file mode 100644 index 0000000..3462203 --- /dev/null +++ b/docs/security-vulnerabilities.md @@ -0,0 +1,141 @@ +# Security Vulnerabilities — Accepted Findings + Operator Mitigations + +This document records security findings (govulncheck, etc.) that have been +manually reviewed and **accepted with documented rationale** rather than +patched. Each entry names the CVE, what makes it not-applicable to our use +case, and any operator-side mitigations required to keep that not-applicable +status valid. + +Audit history: +- Mantis #323 — 9 ollama CVEs reviewed and documented (2026-04-25) + +--- + +## github.com/ollama/ollama (indirect via go-rag) + +**Status as of 2026-04-25:** all 9 CVEs filed in Mantis #323 are **UNFIXED +upstream** per [pkg.go.dev/vuln](https://pkg.go.dev/vuln/). Pin-bumping does +not resolve any of them. We are on `v0.18.1` indirect; ollama upstream is at +`v0.21.2` (2026-04-23). + +**Our usage scope:** the entire workspace imports `github.com/ollama/ollama/api` +from exactly ONE file (`go-rag/ollama.go`). The surface in use is **3 symbols +only**: +- `api.NewClient(baseURL, *http.Client)` — constructor +- `api.Client` — struct value (held as a field by `OllamaClient`) +- `api.EmbedRequest` — embedding-request DTO + +**We are a CLIENT** of someone else's Ollama server. We do NOT host an Ollama +server. Most CVEs in the list are server-side code paths that govulncheck's +reachability graph flags because the package is imported, but our actual call +sites do not traverse those paths. + +### CVE-by-CVE reachability assessment + +| CVE | Description | Reachable from our call graph? | Action | +|---|---|---|---| +| GO-2025-3548 (CVE-2024-12886) | DoS via crafted GZIP | NO — server-side parser | Accept | +| GO-2025-3557 (CVE-2025-0315) | Resource alloc without limits | NO — server-side dispatcher | Accept | +| GO-2025-3558 | Out-of-bounds read | NO — server-side inference | Accept | +| GO-2025-3559 | Divide by zero | NO — server-side inference | Accept | +| GO-2025-3582 | Null pointer deref DoS | NO — server-side handler | Accept | +| GO-2025-3689 | Divide by zero | NO — server-side inference | Accept | +| GO-2025-3695 | Server DoS | NO — server-side handler | Accept | +| GO-2025-3824 (CVE-2025-51471) | Cross-domain token exposure | **CONDITIONAL** — see below | Watch | +| GO-2025-4251 (CVE-2025-63389) | Missing auth on model-mgmt | **OPERATOR-SIDE** — see below | Runbook | + +### GO-2025-3824 — token-exposure watch flag + +This CVE concerns auth tokens leaking across domain boundaries when Ollama +clients pass authentication. Currently `NewOllamaClient(cfg)` constructs over +plain HTTP/HTTPS without auth headers — the embedding client connects to a +trusted local Ollama instance per the deployment runbook below. + +**If we ever add auth-token plumbing to the Ollama client** (e.g. for hosted +Ollama services), re-evaluate this CVE. The reachability flips from NO to YES +the moment we set an Authorization header on `api.NewClient`. + +### GO-2025-4251 — operator-side mitigation required + +This CVE is a missing authentication / authorization gap on Ollama's +model-management endpoints. The vulnerability is in the **Ollama server**, +not our client code. Our client doesn't expose model-management calls; +operators do via running an Ollama server. + +**Operator mitigation (REQUIRED):** see "Ollama deployment" section below. +Operators MUST front their Ollama instance with network-level access controls +or an authentication proxy. This is also Ollama upstream's own recommendation +in the advisory. + +### Watch flag + +If any of the 9 CVEs gets a fixed version released, re-evaluate: +- Bump `go-rag/go.mod` require for `github.com/ollama/ollama` to the fixed version +- Re-run govulncheck and prune entries from this document accordingly + +--- + +## Ollama deployment — operator runbook + +The Ollama instance the agent connects to runs OUTSIDE of our application +boundary. Operators are responsible for these mitigations: + +### 1. Network-level isolation (mandatory) + +Bind the Ollama server to a private interface or front it with a reverse proxy: + +```bash +# OPTION A — localhost-only binding (single-host deployments) +OLLAMA_HOST=127.0.0.1:11434 ollama serve + +# OPTION B — private network only (multi-host fleet) +# Bind to the wireguard / tailscale / private-VLAN interface, not 0.0.0.0 +OLLAMA_HOST=10.42.0.5:11434 ollama serve +``` + +**Never** expose Ollama directly to the public internet. GO-2025-4251 makes +model-management operations possible without auth. + +### 2. Reverse proxy with auth (recommended for shared deployments) + +If multiple agents share an Ollama server, front it with nginx/caddy/traefik +adding HTTP Basic Auth or an authentication proxy (oauth2-proxy, authentik): + +```nginx +location /ollama/ { + auth_basic "Ollama API"; + auth_basic_user_file /etc/nginx/ollama.htpasswd; + proxy_pass http://10.42.0.5:11434/; +} +``` + +Configure the agent's `OllamaConfig.Endpoint` to point at the reverse proxy +URL, and add an `Authorization` header to the http.Client passed to +`api.NewClient`. (When that change lands, re-evaluate GO-2025-3824 per +the watch-flag note above.) + +### 3. CI-side govulncheck filter + +Until upstream Ollama ships fixes for any of the 9 CVEs, CI should suppress +just these specific findings (not blanket-suppress all govulncheck output): + +```bash +govulncheck ./... 2>&1 | grep -vE 'GO-2025-(3548|3557|3558|3559|3582|3689|3695|3824|4251)\b' +``` + +When a CVE gets a fix and we bump past it, drop that CVE ID from the grep +filter so future regressions surface cleanly. + +--- + +## How to add to this document + +When a new accepted finding lands: + +1. Open a new H2 section named for the dependency +2. Document the reachability + rationale per CVE in a table +3. Add operator-side mitigations if any +4. Update the audit-history bullet at the top with a Mantis ticket reference + +**Do NOT add findings here without a Mantis ticket.** Every accepted finding +must have a tracker entry so the rationale is auditable + reviewable.