feat: initial core/mcp — Go MCP server + PHP Laravel MCP package

Moved from go-ai/mcp/ and php-mcp. Produces core-mcp binary.
49 MCP tools including brain, RAG, ML, IDE bridge.
This commit is contained in:
Snider 2026-03-09 18:34:17 +00:00
parent 7760b4089f
commit 9e2d77ed2a
250 changed files with 48744 additions and 0 deletions

3
.core/build.yaml Normal file
View file

@ -0,0 +1,3 @@
project:
name: core-mcp
binary: core-mcp

7
.gitattributes vendored Normal file
View file

@ -0,0 +1,7 @@
*.go export-ignore
go.mod export-ignore
go.sum export-ignore
cmd/ export-ignore
pkg/ export-ignore
.core/ export-ignore
src/php/tests/ export-ignore

502
cmd/brain-seed/main.go Normal file
View file

@ -0,0 +1,502 @@
// SPDX-License-Identifier: EUPL-1.2
// brain-seed imports Claude Code MEMORY.md files into the OpenBrain knowledge
// store via the MCP HTTP API (brain_remember tool). The Laravel app handles
// embedding, Qdrant storage, and MariaDB dual-write internally.
//
// Usage:
//
// go run ./cmd/brain-seed -api-key YOUR_KEY
// go run ./cmd/brain-seed -api-key YOUR_KEY -api https://lthn.sh/api/v1/mcp
// go run ./cmd/brain-seed -api-key YOUR_KEY -dry-run
// go run ./cmd/brain-seed -api-key YOUR_KEY -plans
// go run ./cmd/brain-seed -api-key YOUR_KEY -claude-md # Also import CLAUDE.md files
package main
import (
"bytes"
"crypto/tls"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
var (
apiURL = flag.String("api", "https://lthn.sh/api/v1/mcp", "MCP API base URL")
apiKey = flag.String("api-key", "", "MCP API key (Bearer token)")
server = flag.String("server", "hosthub-agent", "MCP server ID")
agent = flag.String("agent", "charon", "Agent ID for attribution")
dryRun = flag.Bool("dry-run", false, "Preview without storing")
plans = flag.Bool("plans", false, "Also import plan documents")
claudeMd = flag.Bool("claude-md", false, "Also import CLAUDE.md files")
memoryPath = flag.String("memory-path", "", "Override memory scan path (default: ~/.claude/projects/*/memory/)")
planPath = flag.String("plan-path", "", "Override plan scan path (default: ~/Code/*/docs/plans/)")
codePath = flag.String("code-path", "", "Override code root for CLAUDE.md scan (default: ~/Code)")
maxChars = flag.Int("max-chars", 3800, "Max chars per section (embeddinggemma limit ~4000)")
)
// httpClient with TLS skip for non-public TLDs (.lthn.sh has real certs, but
// allow .lan/.local if someone has legacy config).
var httpClient = &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
},
}
func main() {
flag.Parse()
fmt.Println("OpenBrain Seed — MCP API Client")
fmt.Println(strings.Repeat("=", 55))
if *apiKey == "" && !*dryRun {
fmt.Println("ERROR: -api-key is required (or use -dry-run)")
fmt.Println(" Generate one at: https://lthn.sh/admin/mcp/api-keys")
os.Exit(1)
}
if *dryRun {
fmt.Println("[DRY RUN] — no data will be stored")
}
fmt.Printf("API: %s\n", *apiURL)
fmt.Printf("Server: %s | Agent: %s\n", *server, *agent)
// Discover memory files
memPath := *memoryPath
if memPath == "" {
home, _ := os.UserHomeDir()
memPath = filepath.Join(home, ".claude", "projects", "*", "memory")
}
memFiles, _ := filepath.Glob(filepath.Join(memPath, "*.md"))
fmt.Printf("\nFound %d memory files\n", len(memFiles))
// Discover plan files
var planFiles []string
if *plans {
pPath := *planPath
if pPath == "" {
home, _ := os.UserHomeDir()
pPath = filepath.Join(home, "Code", "*", "docs", "plans")
}
planFiles, _ = filepath.Glob(filepath.Join(pPath, "*.md"))
// Also check nested dirs (completed/, etc.)
nested, _ := filepath.Glob(filepath.Join(pPath, "*", "*.md"))
planFiles = append(planFiles, nested...)
// Also check host-uk nested repos
home, _ := os.UserHomeDir()
hostUkPath := filepath.Join(home, "Code", "host-uk", "*", "docs", "plans")
hostUkFiles, _ := filepath.Glob(filepath.Join(hostUkPath, "*.md"))
planFiles = append(planFiles, hostUkFiles...)
hostUkNested, _ := filepath.Glob(filepath.Join(hostUkPath, "*", "*.md"))
planFiles = append(planFiles, hostUkNested...)
fmt.Printf("Found %d plan files\n", len(planFiles))
}
// Discover CLAUDE.md files
var claudeFiles []string
if *claudeMd {
cPath := *codePath
if cPath == "" {
home, _ := os.UserHomeDir()
cPath = filepath.Join(home, "Code")
}
claudeFiles = discoverClaudeMdFiles(cPath)
fmt.Printf("Found %d CLAUDE.md files\n", len(claudeFiles))
}
imported := 0
skipped := 0
errors := 0
// Process memory files
fmt.Println("\n--- Memory Files ---")
for _, f := range memFiles {
project := extractProject(f)
sections := parseMarkdownSections(f)
filename := strings.TrimSuffix(filepath.Base(f), ".md")
if len(sections) == 0 {
fmt.Printf(" skip %s/%s (no sections)\n", project, filename)
skipped++
continue
}
for _, sec := range sections {
content := sec.heading + "\n\n" + sec.content
if strings.TrimSpace(sec.content) == "" {
skipped++
continue
}
memType := inferType(sec.heading, sec.content, "memory")
tags := buildTags(filename, "memory", project)
confidence := confidenceForSource("memory")
// Truncate to embedding model limit
content = truncate(content, *maxChars)
if *dryRun {
fmt.Printf(" [DRY] %s/%s :: %s (%s) — %d chars\n",
project, filename, sec.heading, memType, len(content))
imported++
continue
}
if err := callBrainRemember(content, memType, tags, project, confidence); err != nil {
fmt.Printf(" FAIL %s/%s :: %s — %v\n", project, filename, sec.heading, err)
errors++
continue
}
fmt.Printf(" ok %s/%s :: %s (%s)\n", project, filename, sec.heading, memType)
imported++
}
}
// Process plan files
if *plans && len(planFiles) > 0 {
fmt.Println("\n--- Plan Documents ---")
for _, f := range planFiles {
project := extractProjectFromPlan(f)
sections := parseMarkdownSections(f)
filename := strings.TrimSuffix(filepath.Base(f), ".md")
if len(sections) == 0 {
skipped++
continue
}
for _, sec := range sections {
content := sec.heading + "\n\n" + sec.content
if strings.TrimSpace(sec.content) == "" {
skipped++
continue
}
tags := buildTags(filename, "plans", project)
content = truncate(content, *maxChars)
if *dryRun {
fmt.Printf(" [DRY] %s :: %s / %s (plan) — %d chars\n",
project, filename, sec.heading, len(content))
imported++
continue
}
if err := callBrainRemember(content, "plan", tags, project, 0.6); err != nil {
fmt.Printf(" FAIL %s :: %s / %s — %v\n", project, filename, sec.heading, err)
errors++
continue
}
fmt.Printf(" ok %s :: %s / %s (plan)\n", project, filename, sec.heading)
imported++
}
}
}
// Process CLAUDE.md files
if *claudeMd && len(claudeFiles) > 0 {
fmt.Println("\n--- CLAUDE.md Files ---")
for _, f := range claudeFiles {
project := extractProjectFromClaudeMd(f)
sections := parseMarkdownSections(f)
if len(sections) == 0 {
skipped++
continue
}
for _, sec := range sections {
content := sec.heading + "\n\n" + sec.content
if strings.TrimSpace(sec.content) == "" {
skipped++
continue
}
tags := buildTags("CLAUDE", "claude-md", project)
content = truncate(content, *maxChars)
if *dryRun {
fmt.Printf(" [DRY] %s :: CLAUDE.md / %s (convention) — %d chars\n",
project, sec.heading, len(content))
imported++
continue
}
if err := callBrainRemember(content, "convention", tags, project, 0.9); err != nil {
fmt.Printf(" FAIL %s :: CLAUDE.md / %s — %v\n", project, sec.heading, err)
errors++
continue
}
fmt.Printf(" ok %s :: CLAUDE.md / %s (convention)\n", project, sec.heading)
imported++
}
}
}
fmt.Printf("\n%s\n", strings.Repeat("=", 55))
prefix := ""
if *dryRun {
prefix = "[DRY RUN] "
}
fmt.Printf("%sImported: %d | Skipped: %d | Errors: %d\n", prefix, imported, skipped, errors)
}
// callBrainRemember sends a memory to the MCP API via brain_remember tool.
func callBrainRemember(content, memType string, tags []string, project string, confidence float64) error {
args := map[string]any{
"content": content,
"type": memType,
"tags": tags,
"confidence": confidence,
}
if project != "" && project != "unknown" {
args["project"] = project
}
payload := map[string]any{
"server": *server,
"tool": "brain_remember",
"arguments": args,
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
req, err := http.NewRequest("POST", *apiURL+"/tools/call", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+*apiKey)
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("http: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
Success bool `json:"success"`
Error string `json:"error"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return fmt.Errorf("decode: %w", err)
}
if !result.Success {
return fmt.Errorf("API: %s", result.Error)
}
return nil
}
// truncate caps content to maxLen chars, appending an ellipsis if truncated.
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
// Find last space before limit to avoid splitting mid-word
cut := maxLen
if idx := strings.LastIndex(s[:maxLen], " "); idx > maxLen-200 {
cut = idx
}
return s[:cut] + "…"
}
// discoverClaudeMdFiles finds CLAUDE.md files across a code directory.
func discoverClaudeMdFiles(codePath string) []string {
var files []string
// Walk up to 4 levels deep, skip node_modules/vendor/.claude
_ = filepath.WalkDir(codePath, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
name := d.Name()
if name == "node_modules" || name == "vendor" || name == ".claude" {
return filepath.SkipDir
}
// Limit depth
rel, _ := filepath.Rel(codePath, path)
if strings.Count(rel, string(os.PathSeparator)) > 3 {
return filepath.SkipDir
}
return nil
}
if d.Name() == "CLAUDE.md" {
files = append(files, path)
}
return nil
})
return files
}
// section is a parsed markdown section.
type section struct {
heading string
content string
}
var headingRe = regexp.MustCompile(`^#{1,3}\s+(.+)$`)
// parseMarkdownSections splits a markdown file by headings.
func parseMarkdownSections(path string) []section {
data, err := os.ReadFile(path)
if err != nil || len(data) == 0 {
return nil
}
var sections []section
lines := strings.Split(string(data), "\n")
var curHeading string
var curContent []string
for _, line := range lines {
if m := headingRe.FindStringSubmatch(line); m != nil {
if curHeading != "" && len(curContent) > 0 {
text := strings.TrimSpace(strings.Join(curContent, "\n"))
if text != "" {
sections = append(sections, section{curHeading, text})
}
}
curHeading = strings.TrimSpace(m[1])
curContent = nil
} else {
curContent = append(curContent, line)
}
}
// Flush last section
if curHeading != "" && len(curContent) > 0 {
text := strings.TrimSpace(strings.Join(curContent, "\n"))
if text != "" {
sections = append(sections, section{curHeading, text})
}
}
// If no headings found, treat entire file as one section
if len(sections) == 0 && strings.TrimSpace(string(data)) != "" {
sections = append(sections, section{
heading: strings.TrimSuffix(filepath.Base(path), ".md"),
content: strings.TrimSpace(string(data)),
})
}
return sections
}
// extractProject derives a project name from a Claude memory path.
// ~/.claude/projects/-Users-snider-Code-eaas/memory/MEMORY.md → "eaas"
func extractProject(path string) string {
re := regexp.MustCompile(`projects/[^/]*-([^-/]+)/memory/`)
if m := re.FindStringSubmatch(path); m != nil {
return m[1]
}
return "unknown"
}
// extractProjectFromPlan derives a project name from a plan path.
// ~/Code/eaas/docs/plans/foo.md → "eaas"
// ~/Code/host-uk/core/docs/plans/foo.md → "core"
func extractProjectFromPlan(path string) string {
// Check host-uk nested repos first
re := regexp.MustCompile(`Code/host-uk/([^/]+)/docs/plans/`)
if m := re.FindStringSubmatch(path); m != nil {
return m[1]
}
re = regexp.MustCompile(`Code/([^/]+)/docs/plans/`)
if m := re.FindStringSubmatch(path); m != nil {
return m[1]
}
return "unknown"
}
// extractProjectFromClaudeMd derives a project name from a CLAUDE.md path.
// ~/Code/host-uk/core/CLAUDE.md → "core"
// ~/Code/eaas/CLAUDE.md → "eaas"
func extractProjectFromClaudeMd(path string) string {
re := regexp.MustCompile(`Code/host-uk/([^/]+)/`)
if m := re.FindStringSubmatch(path); m != nil {
return m[1]
}
re = regexp.MustCompile(`Code/([^/]+)/`)
if m := re.FindStringSubmatch(path); m != nil {
return m[1]
}
return "unknown"
}
// inferType guesses the memory type from heading + content keywords.
func inferType(heading, content, source string) string {
// Source-specific defaults (match PHP BrainIngestCommand behaviour)
if source == "plans" {
return "plan"
}
if source == "claude-md" {
return "convention"
}
lower := strings.ToLower(heading + " " + content)
patterns := map[string][]string{
"architecture": {"architecture", "stack", "infrastructure", "layer", "service mesh"},
"convention": {"convention", "standard", "naming", "pattern", "rule", "coding"},
"decision": {"decision", "chose", "strategy", "approach", "domain"},
"bug": {"bug", "fix", "broken", "error", "issue", "lesson"},
"plan": {"plan", "todo", "roadmap", "milestone", "phase", "task"},
"research": {"research", "finding", "discovery", "analysis", "rfc"},
}
for t, keywords := range patterns {
for _, kw := range keywords {
if strings.Contains(lower, kw) {
return t
}
}
}
return "observation"
}
// buildTags creates the tag list for a memory.
func buildTags(filename, source, project string) []string {
tags := []string{"source:" + source}
if project != "" && project != "unknown" {
tags = append(tags, "project:"+project)
}
if filename != "MEMORY" && filename != "CLAUDE" {
tags = append(tags, strings.ReplaceAll(strings.ReplaceAll(filename, "-", " "), "_", " "))
}
return tags
}
// confidenceForSource returns a default confidence level matching the PHP ingest command.
func confidenceForSource(source string) float64 {
switch source {
case "claude-md":
return 0.9
case "memory":
return 0.8
case "plans":
return 0.6
default:
return 0.5
}
}

12
cmd/core-mcp/main.go Normal file
View file

@ -0,0 +1,12 @@
package main
import (
"forge.lthn.ai/core/cli/pkg/cli"
mcpcmd "forge.lthn.ai/core/mcp/cmd/mcpcmd"
)
func main() {
cli.Main(
cli.WithCommands("mcp", mcpcmd.AddMCPCommands),
)
}

92
cmd/mcpcmd/cmd_mcp.go Normal file
View file

@ -0,0 +1,92 @@
// Package mcpcmd provides the MCP server command.
//
// Commands:
// - mcp serve: Start the MCP server for AI tool integration
package mcpcmd
import (
"context"
"os"
"os/signal"
"syscall"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/mcp/pkg/mcp"
)
var workspaceFlag string
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.",
}
var serveCmd = &cli.Command{
Use: "serve",
Short: "Start the MCP server",
Long: `Start the MCP server on stdio (default) or TCP.
The server provides file operations, RAG tools, and metrics tools for AI assistants.
Environment variables:
MCP_ADDR TCP address to listen on (e.g., "localhost:9999")
If not set, uses stdio transport.
Examples:
# Start with stdio transport (for Claude Code integration)
core mcp serve
# Start with workspace restriction
core mcp serve --workspace /path/to/project
# Start TCP server
MCP_ADDR=localhost:9999 core mcp serve`,
RunE: func(cmd *cli.Command, args []string) error {
return runServe()
},
}
func initFlags() {
cli.StringFlag(serveCmd, &workspaceFlag, "workspace", "w", "", "Restrict file operations to this directory (empty = unrestricted)")
}
// AddMCPCommands registers the 'mcp' command and all subcommands.
func AddMCPCommands(root *cli.Command) {
initFlags()
mcpCmd.AddCommand(serveCmd)
root.AddCommand(mcpCmd)
}
func runServe() error {
// Build MCP service options
var opts []mcp.Option
if workspaceFlag != "" {
opts = append(opts, mcp.WithWorkspaceRoot(workspaceFlag))
} else {
// Explicitly unrestricted when no workspace specified
opts = append(opts, mcp.WithWorkspaceRoot(""))
}
// Create the MCP service
svc, err := mcp.New(opts...)
if err != nil {
return cli.Wrap(err, "create MCP service")
}
// Set up signal handling for clean shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
cancel()
}()
// Run the server (blocks until context cancelled or error)
return svc.Run(ctx)
}

38
composer.json Normal file
View file

@ -0,0 +1,38 @@
{
"name": "lthn/mcp",
"description": "Model Context Protocol — Laravel MCP server + standalone Go binary",
"keywords": ["mcp", "ai", "laravel", "model-context-protocol"],
"license": "EUPL-1.2",
"require": {
"php": "^8.2",
"lthn/php": "*"
},
"autoload": {
"psr-4": {
"Core\\Mcp\\": "src/php/src/Mcp/",
"Core\\Website\\Mcp\\": "src/php/src/Website/Mcp/",
"Core\\Front\\Mcp\\": "src/php/src/Front/Mcp/"
}
},
"autoload-dev": {
"psr-4": {
"Core\\Mcp\\Tests\\": "src/php/tests/"
}
},
"extra": {
"laravel": {
"providers": [
"Core\\Front\\Mcp\\Boot"
]
}
},
"config": {
"sort-packages": true
},
"minimum-stability": "dev",
"prefer-stable": true,
"replace": {
"core/php-mcp": "self.version",
"lthn/php-mcp": "self.version"
}
}

162
go.mod Normal file
View file

@ -0,0 +1,162 @@
module forge.lthn.ai/core/mcp
go 1.26.0
require (
forge.lthn.ai/core/cli v0.1.0
forge.lthn.ai/core/go v0.1.0
forge.lthn.ai/core/go-ai v0.1.5
forge.lthn.ai/core/go-api v0.1.0
forge.lthn.ai/core/go-i18n v0.0.3
forge.lthn.ai/core/go-inference v0.1.0
forge.lthn.ai/core/go-io v0.0.1
forge.lthn.ai/core/go-log v0.0.1
forge.lthn.ai/core/go-ml v0.1.0
forge.lthn.ai/core/go-process v0.1.0
forge.lthn.ai/core/go-rag v0.1.0
forge.lthn.ai/core/go-webview v0.1.0
forge.lthn.ai/core/go-ws v0.1.0
github.com/gin-gonic/gin v1.11.0
github.com/gorilla/websocket v1.5.3
github.com/modelcontextprotocol/go-sdk v1.3.0
github.com/stretchr/testify v1.11.1
)
require (
forge.lthn.ai/core/go-crypt v0.1.0 // indirect
forge.lthn.ai/core/go-mlx v0.1.0 // indirect
github.com/99designs/gqlgen v0.17.87 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/agnivade/levenshtein v1.2.1 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/apache/arrow-go/v18 v18.5.1 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/casbin/casbin/v2 v2.135.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/coreos/go-oidc/v3 v3.17.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/authz v1.0.6 // indirect
github.com/gin-contrib/cors v1.7.6 // indirect
github.com/gin-contrib/expvar v1.0.3 // indirect
github.com/gin-contrib/gzip v1.2.5 // indirect
github.com/gin-contrib/httpsign v1.0.3 // indirect
github.com/gin-contrib/location/v2 v2.0.0 // indirect
github.com/gin-contrib/pprof v1.5.3 // indirect
github.com/gin-contrib/secure v1.1.2 // indirect
github.com/gin-contrib/sessions v1.0.4 // indirect
github.com/gin-contrib/slog v1.2.0 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/gin-contrib/static v1.1.5 // indirect
github.com/gin-contrib/timeout v1.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/jsonreference v0.21.2 // indirect
github.com/go-openapi/spec v0.22.0 // indirect
github.com/go-openapi/swag/conv v0.25.1 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
github.com/go-openapi/swag/loading v0.25.1 // indirect
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/flatbuffers v25.12.19+incompatible // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/marcboeker/go-duckdb v1.8.5 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/ollama/ollama v0.16.1 // indirect
github.com/parquet-go/bitpack v1.0.0 // indirect
github.com/parquet-go/jsonlite v1.4.0 // indirect
github.com/parquet-go/parquet-go v0.27.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pierrec/lz4/v4 v4.1.25 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/qdrant/go-client v1.16.2 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sosodev/duration v1.3.1 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/swaggo/gin-swagger v1.6.1 // indirect
github.com/swaggo/swag v1.16.6 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/twpayne/go-geom v1.6.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/vektah/gqlparser/v2 v2.5.32 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/telemetry v0.0.0-20260213145524-e0ab670178e1 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.79.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

423
go.sum Normal file
View file

@ -0,0 +1,423 @@
forge.lthn.ai/core/cli v0.1.0 h1:2XRiEMVzUElnQlZnHYDyfKIKQVPcCzGuYHlnz55GjsM=
forge.lthn.ai/core/cli v0.1.0/go.mod h1:mZ7dzccfzo0BP2dE7Mwuw9dXuIowiEd1G5ZGMoLuxVc=
forge.lthn.ai/core/go v0.1.0 h1:Ow/1NTajrrNPO0zgkskEyEGdx4SKpiNqTaqM0txNOYI=
forge.lthn.ai/core/go v0.1.0/go.mod h1:lwi0tccAlg5j3k6CfoNJEueBc5l9mUeSBX/x6uY8ZbQ=
forge.lthn.ai/core/go-api v0.1.0 h1:j4cn/cpDkSecN2TNcksmqtFKlA9R61VSkbIIwSiujaU=
forge.lthn.ai/core/go-api v0.1.0/go.mod h1:ECZtO2R6ZYF80tBBedhmkEUw6Oqjs5jM34+XpdeZUEw=
forge.lthn.ai/core/go-crypt v0.1.0 h1:92gwdQi7iAwktpvZhL/8Cu+QS6xKCtGP4FJfyInPGnw=
forge.lthn.ai/core/go-crypt v0.1.0/go.mod h1:zVAgx6ZiGtC+dbX4R/VKvEPqsEqjyuLl4gQZH9SXBUw=
forge.lthn.ai/core/go-i18n v0.0.3 h1:et3NkErxSIGxwj8rAK86UU56gYJWXSy66KZm/H4vld8=
forge.lthn.ai/core/go-i18n v0.0.3/go.mod h1:Q4xsrxuNCl/6NfMv1daria7t1RSiyy8ml+6jiPtUcBs=
forge.lthn.ai/core/go-inference v0.1.0 h1:pO7etYgqV8LMKFdpW8/2RWncuECZJCIcf8nnezeZ5R4=
forge.lthn.ai/core/go-inference v0.1.0/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
forge.lthn.ai/core/go-io v0.0.1 h1:N/GCl6Asusfr4gs53JZixJVtqcnerQ6GcxSN8F8iJXY=
forge.lthn.ai/core/go-io v0.0.1/go.mod h1:l+gG/G5TMIOTG8G7y0dg4fh1a7Suy8wCYVwsz4duV7M=
forge.lthn.ai/core/go-log v0.0.1 h1:x/E6EfF9vixzqiLHQOl2KT25HyBcMc9qiBkomqVlpPg=
forge.lthn.ai/core/go-log v0.0.1/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
forge.lthn.ai/core/go-ml v0.1.0 h1:nV/XHZMy9VaFhk2dCYW5Jnp5UqpYVsYg85bsKMqdu8o=
forge.lthn.ai/core/go-ml v0.1.0/go.mod h1:FPV9JhIUOZdLeJpX1ggC15BpmM740NPg6rycnOc5vss=
forge.lthn.ai/core/go-mlx v0.1.0 h1:nMDhMma3M9iSm2ymNyqMe+aAbJDasNnxgi/1dZ+Zq7c=
forge.lthn.ai/core/go-mlx v0.1.0/go.mod h1:b4BJX67nx9QZiyREl2lmYIPJ+Yp5amZug3y7vXaRy/Y=
forge.lthn.ai/core/go-process v0.1.0 h1:lRpliQuu/Omt+kAHMFoQYOb5PKEIKg8yTMchFhejpK8=
forge.lthn.ai/core/go-rag v0.1.0 h1:H5umiRryuq6J6l889s0OsxWpmq5P5c3A9Bkj0cQyO7k=
forge.lthn.ai/core/go-rag v0.1.0/go.mod h1:bB8Fy98G2zxVoe7k2B85gXvim6frJdbAMnDyW4peUVU=
github.com/99designs/gqlgen v0.17.87 h1:pSnCIMhBQezAE8bc1GNmfdLXFmnWtWl1GRDFEE/nHP8=
github.com/99designs/gqlgen v0.17.87/go.mod h1:fK05f1RqSNfQpd4CfW5qk/810Tqi4/56Wf6Nem0khAg=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY=
github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/apache/arrow-go/v18 v18.5.1 h1:yaQ6zxMGgf9YCYw4/oaeOU3AULySDlAYDOcnr4LdHdI=
github.com/apache/arrow-go/v18 v18.5.1/go.mod h1:OCCJsmdq8AsRm8FkBSSmYTwL/s4zHW9CqxeBxEytkNE=
github.com/apache/thrift v0.22.0 h1:r7mTJdj51TMDe6RtcmNdQxgn9XcyfGDOzegMDRg47uc=
github.com/apache/thrift v0.22.0/go.mod h1:1e7J/O1Ae6ZQMTYdy9xa3w9k+XHWPfRvdPyJeynQ+/g=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/authz v1.0.6 h1:qAO4sSSzOPCwYRZI6YtubC+h2tZVwhwSJeyEZn2W+5k=
github.com/gin-contrib/authz v1.0.6/go.mod h1:A2B5Im1M/HIoHPjLc31j3RlENSE6j8euJY9NFdzZeYo=
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
github.com/gin-contrib/expvar v1.0.3 h1:nIbUaokxZfUEC/35h+RyWCP1SMF/suV/ARbXL3H3jrw=
github.com/gin-contrib/expvar v1.0.3/go.mod h1:bwqqmhty1Zl2JYVLzBIL6CSHDWDbQoQoicalAnBvUnY=
github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=
github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw=
github.com/gin-contrib/httpsign v1.0.3 h1:NpeDQjmUV0qFjGCm/rkXSp3HH0hU7r84q1v+VtTiI5I=
github.com/gin-contrib/httpsign v1.0.3/go.mod h1:n4GC7StmHNBhIzWzuW2njKbZMeEWh4tDbmn3bD1ab+k=
github.com/gin-contrib/location/v2 v2.0.0 h1:iLx5RatHQHSxgC0tm2AG0sIuQKecI7FhREessVd6RWY=
github.com/gin-contrib/location/v2 v2.0.0/go.mod h1:276TDNr25NENBA/NQZUuEIlwxy/I5CYVFIr/d2TgOdU=
github.com/gin-contrib/pprof v1.5.3 h1:Bj5SxJ3kQDVez/s/+f9+meedJIqLS+xlkIVDe/lcvgM=
github.com/gin-contrib/pprof v1.5.3/go.mod h1:0+LQSZ4SLO0B6+2n6JBzaEygpTBxe/nI+YEYpfQQ6xY=
github.com/gin-contrib/secure v1.1.2 h1:6G8/NCOTSywWY7TeaH/0Yfaa6bfkE5ukkqtIm7lK11U=
github.com/gin-contrib/secure v1.1.2/go.mod h1:xI3jI5/BpOYMCBtjgmIVrMA3kI7y9LwCFxs+eLf5S3w=
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
github.com/gin-contrib/slog v1.2.0 h1:vAxZfr7knD1ZYK5+pMJLP52sZXIkJXkcRPa/0dx9hSk=
github.com/gin-contrib/slog v1.2.0/go.mod h1:vYK6YltmpsEFkO0zfRMLTKHrWS3DwUSn0TMpT+kMagI=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-contrib/static v1.1.5 h1:bAPqT4KTZN+4uDY1b90eSrD1t8iNzod7Jj8njwmnzz4=
github.com/gin-contrib/static v1.1.5/go.mod h1:8JSEXwZHcQ0uCrLPcsvnAJ4g+ODxeupP8Zetl9fd8wM=
github.com/gin-contrib/timeout v1.1.0 h1:WAmWseo5gfBUbMrMJu5hJxDclehfSJUmK2wGwCC/EFw=
github.com/gin-contrib/timeout v1.1.0/go.mod h1:NpRo4gd1Ad8ZQ4T6bQLVFDqiplCmPRs2nvfckxS2Fw4=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw=
github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg=
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/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.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
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=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs=
github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/marcboeker/go-duckdb v1.8.5 h1:tkYp+TANippy0DaIOP5OEfBEwbUINqiFqgwMQ44jME0=
github.com/marcboeker/go-duckdb v1.8.5/go.mod h1:6mK7+WQE4P4u5AFLvVBmhFxY5fvhymFptghgJX6B+/8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs=
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI=
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
github.com/modelcontextprotocol/go-sdk v1.3.0 h1:gMfZkv3DzQF5q/DcQePo5rahEY+sguyPfXDfNBcT0Zs=
github.com/modelcontextprotocol/go-sdk v1.3.0/go.mod h1:AnQ//Qc6+4nIyyrB4cxBU7UW9VibK4iOZBeyP/rF1IE=
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=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ollama/ollama v0.16.1 h1:DIxnLdS0om3hb7HheJqj6+ZnPCCMWmy/vyUxiQgRYoI=
github.com/ollama/ollama v0.16.1/go.mod h1:FEk95NbAJJZk+t7cLh+bPGTul72j1O3PLLlYNV3FVZ0=
github.com/parquet-go/bitpack v1.0.0 h1:AUqzlKzPPXf2bCdjfj4sTeacrUwsT7NlcYDMUQxPcQA=
github.com/parquet-go/bitpack v1.0.0/go.mod h1:XnVk9TH+O40eOOmvpAVZ7K2ocQFrQwysLMnc6M/8lgs=
github.com/parquet-go/jsonlite v1.4.0 h1:RTG7prqfO0HD5egejU8MUDBN8oToMj55cgSV1I0zNW4=
github.com/parquet-go/jsonlite v1.4.0/go.mod h1:nDjpkpL4EOtqs6NQugUsi0Rleq9sW/OtC1NnZEnxzF0=
github.com/parquet-go/parquet-go v0.27.0 h1:vHWK2xaHbj+v1DYps03yDRpEsdtOeKbhiXUaixoPb3g=
github.com/parquet-go/parquet-go v0.27.0/go.mod h1:navtkAYr2LGoJVp141oXPlO/sxLvaOe3la2JEoD8+rg=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/qdrant/go-client v1.16.2 h1:UUMJJfvXTByhwhH1DwWdbkhZ2cTdvSqVkXSIfBrVWSg=
github.com/qdrant/go-client v1.16.2/go.mod h1:I+EL3h4HRoRTeHtbfOd/4kDXwCukZfkd41j/9wryGkw=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=
github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/twpayne/go-geom v1.6.1 h1:iLE+Opv0Ihm/ABIcvQFGIiFBXd76oBIar9drAwHFhR4=
github.com/twpayne/go-geom v1.6.1/go.mod h1:Kr+Nly6BswFsKM5sd31YaoWS5PeDDH2NftJTK7Gd028=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc=
github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0 h1:LSJsvNqhj2sBNFb5NWHbyDK4QJ/skQ2ydjeOZ9OYNZ4=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0/go.mod h1:0Q5ocj6h/+C6KYq8cnl4tDFVd4I1HBdsJ440aeagHos=
go.opentelemetry.io/contrib/propagators/b3 v1.40.0 h1:xariChe8OOVF3rNlfzGFgQc61npQmXhzZj/i82mxMfg=
go.opentelemetry.io/contrib/propagators/b3 v1.40.0/go.mod h1:72WvbdxbOfXaELEQfonFfOL6osvcVjI7uJEE8C2nkrs=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20260213145524-e0ab670178e1 h1:QNaHp8YvpPswfDNxlCmJyeesxbGOgaKf41iT9/QrErY=
golang.org/x/telemetry v0.0.0-20260213145524-e0ab670178e1/go.mod h1:NuITXsA9cTiqnXtVk+/wrBT2Ja4X5hsfGOYRJ6kgYjs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

42
pkg/mcp/brain/brain.go Normal file
View file

@ -0,0 +1,42 @@
// SPDX-License-Identifier: EUPL-1.2
// Package brain provides an MCP subsystem that proxies OpenBrain knowledge
// store operations to the Laravel php-agentic backend via the IDE bridge.
package brain
import (
"context"
"errors"
"forge.lthn.ai/core/mcp/pkg/mcp/ide"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// errBridgeNotAvailable is returned when a tool requires the Laravel bridge
// but it has not been initialised (headless mode).
var errBridgeNotAvailable = errors.New("brain: bridge not available")
// Subsystem implements mcp.Subsystem for OpenBrain knowledge store operations.
// It proxies brain_* tool calls to the Laravel backend via the shared IDE bridge.
type Subsystem struct {
bridge *ide.Bridge
}
// New creates a brain subsystem that uses the given IDE bridge for Laravel communication.
// Pass nil if headless (tools will return errBridgeNotAvailable).
func New(bridge *ide.Bridge) *Subsystem {
return &Subsystem{bridge: bridge}
}
// Name implements mcp.Subsystem.
func (s *Subsystem) Name() string { return "brain" }
// RegisterTools implements mcp.Subsystem.
func (s *Subsystem) RegisterTools(server *mcp.Server) {
s.registerBrainTools(server)
}
// Shutdown implements mcp.SubsystemWithShutdown.
func (s *Subsystem) Shutdown(_ context.Context) error {
return nil
}

229
pkg/mcp/brain/brain_test.go Normal file
View file

@ -0,0 +1,229 @@
// SPDX-License-Identifier: EUPL-1.2
package brain
import (
"context"
"encoding/json"
"testing"
"time"
)
// --- Nil bridge tests (headless mode) ---
func TestBrainRemember_Bad_NilBridge(t *testing.T) {
sub := New(nil)
_, _, err := sub.brainRemember(context.Background(), nil, RememberInput{
Content: "test memory",
Type: "observation",
})
if err == nil {
t.Error("expected error when bridge is nil")
}
}
func TestBrainRecall_Bad_NilBridge(t *testing.T) {
sub := New(nil)
_, _, err := sub.brainRecall(context.Background(), nil, RecallInput{
Query: "how does scoring work?",
})
if err == nil {
t.Error("expected error when bridge is nil")
}
}
func TestBrainForget_Bad_NilBridge(t *testing.T) {
sub := New(nil)
_, _, err := sub.brainForget(context.Background(), nil, ForgetInput{
ID: "550e8400-e29b-41d4-a716-446655440000",
})
if err == nil {
t.Error("expected error when bridge is nil")
}
}
func TestBrainList_Bad_NilBridge(t *testing.T) {
sub := New(nil)
_, _, err := sub.brainList(context.Background(), nil, ListInput{
Project: "eaas",
})
if err == nil {
t.Error("expected error when bridge is nil")
}
}
// --- Subsystem interface tests ---
func TestSubsystem_Good_Name(t *testing.T) {
sub := New(nil)
if sub.Name() != "brain" {
t.Errorf("expected Name() = 'brain', got %q", sub.Name())
}
}
func TestSubsystem_Good_ShutdownNoop(t *testing.T) {
sub := New(nil)
if err := sub.Shutdown(context.Background()); err != nil {
t.Errorf("Shutdown failed: %v", err)
}
}
// --- Struct round-trip tests ---
func TestRememberInput_Good_RoundTrip(t *testing.T) {
in := RememberInput{
Content: "LEM scoring was blind to negative emotions",
Type: "bug",
Tags: []string{"scoring", "lem"},
Project: "eaas",
Confidence: 0.95,
Supersedes: "550e8400-e29b-41d4-a716-446655440000",
ExpiresIn: 24,
}
data, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var out RememberInput
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if out.Content != in.Content || out.Type != in.Type {
t.Errorf("round-trip mismatch: content or type")
}
if len(out.Tags) != 2 || out.Tags[0] != "scoring" {
t.Errorf("round-trip mismatch: tags")
}
if out.Confidence != 0.95 {
t.Errorf("round-trip mismatch: confidence %f != 0.95", out.Confidence)
}
}
func TestRememberOutput_Good_RoundTrip(t *testing.T) {
in := RememberOutput{
Success: true,
MemoryID: "550e8400-e29b-41d4-a716-446655440000",
Timestamp: time.Now().Truncate(time.Second),
}
data, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var out RememberOutput
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if !out.Success || out.MemoryID != in.MemoryID {
t.Errorf("round-trip mismatch: %+v != %+v", out, in)
}
}
func TestRecallInput_Good_RoundTrip(t *testing.T) {
in := RecallInput{
Query: "how does verdict classification work?",
TopK: 5,
Filter: RecallFilter{
Project: "eaas",
MinConfidence: 0.5,
},
}
data, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var out RecallInput
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if out.Query != in.Query || out.TopK != 5 {
t.Errorf("round-trip mismatch: query or topK")
}
if out.Filter.Project != "eaas" || out.Filter.MinConfidence != 0.5 {
t.Errorf("round-trip mismatch: filter")
}
}
func TestMemory_Good_RoundTrip(t *testing.T) {
in := Memory{
ID: "550e8400-e29b-41d4-a716-446655440000",
AgentID: "virgil",
Type: "decision",
Content: "Use Qdrant for vector search",
Tags: []string{"architecture", "openbrain"},
Project: "php-agentic",
Confidence: 0.9,
CreatedAt: "2026-03-03T12:00:00+00:00",
UpdatedAt: "2026-03-03T12:00:00+00:00",
}
data, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var out Memory
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if out.ID != in.ID || out.AgentID != "virgil" || out.Type != "decision" {
t.Errorf("round-trip mismatch: %+v", out)
}
}
func TestForgetInput_Good_RoundTrip(t *testing.T) {
in := ForgetInput{
ID: "550e8400-e29b-41d4-a716-446655440000",
Reason: "Superseded by new approach",
}
data, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var out ForgetInput
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if out.ID != in.ID || out.Reason != in.Reason {
t.Errorf("round-trip mismatch: %+v != %+v", out, in)
}
}
func TestListInput_Good_RoundTrip(t *testing.T) {
in := ListInput{
Project: "eaas",
Type: "decision",
AgentID: "charon",
Limit: 20,
}
data, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var out ListInput
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if out.Project != "eaas" || out.Type != "decision" || out.AgentID != "charon" || out.Limit != 20 {
t.Errorf("round-trip mismatch: %+v", out)
}
}
func TestListOutput_Good_RoundTrip(t *testing.T) {
in := ListOutput{
Success: true,
Count: 2,
Memories: []Memory{
{ID: "id-1", AgentID: "virgil", Type: "decision", Content: "memory 1", Confidence: 0.9, CreatedAt: "2026-03-03T12:00:00+00:00", UpdatedAt: "2026-03-03T12:00:00+00:00"},
{ID: "id-2", AgentID: "charon", Type: "bug", Content: "memory 2", Confidence: 0.8, CreatedAt: "2026-03-03T13:00:00+00:00", UpdatedAt: "2026-03-03T13:00:00+00:00"},
},
}
data, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var out ListOutput
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if !out.Success || out.Count != 2 || len(out.Memories) != 2 {
t.Errorf("round-trip mismatch: %+v", out)
}
}

220
pkg/mcp/brain/tools.go Normal file
View file

@ -0,0 +1,220 @@
// SPDX-License-Identifier: EUPL-1.2
package brain
import (
"context"
"fmt"
"time"
"forge.lthn.ai/core/mcp/pkg/mcp/ide"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// -- Input/Output types -------------------------------------------------------
// RememberInput is the input for brain_remember.
type RememberInput struct {
Content string `json:"content"`
Type string `json:"type"`
Tags []string `json:"tags,omitempty"`
Project string `json:"project,omitempty"`
Confidence float64 `json:"confidence,omitempty"`
Supersedes string `json:"supersedes,omitempty"`
ExpiresIn int `json:"expires_in,omitempty"`
}
// RememberOutput is the output for brain_remember.
type RememberOutput struct {
Success bool `json:"success"`
MemoryID string `json:"memoryId,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
// RecallInput is the input for brain_recall.
type RecallInput struct {
Query string `json:"query"`
TopK int `json:"top_k,omitempty"`
Filter RecallFilter `json:"filter,omitempty"`
}
// RecallFilter holds optional filter criteria for brain_recall.
type RecallFilter struct {
Project string `json:"project,omitempty"`
Type any `json:"type,omitempty"`
AgentID string `json:"agent_id,omitempty"`
MinConfidence float64 `json:"min_confidence,omitempty"`
}
// RecallOutput is the output for brain_recall.
type RecallOutput struct {
Success bool `json:"success"`
Count int `json:"count"`
Memories []Memory `json:"memories"`
}
// Memory is a single memory entry returned by recall or list.
type Memory struct {
ID string `json:"id"`
AgentID string `json:"agent_id"`
Type string `json:"type"`
Content string `json:"content"`
Tags []string `json:"tags,omitempty"`
Project string `json:"project,omitempty"`
Confidence float64 `json:"confidence"`
SupersedesID string `json:"supersedes_id,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// ForgetInput is the input for brain_forget.
type ForgetInput struct {
ID string `json:"id"`
Reason string `json:"reason,omitempty"`
}
// ForgetOutput is the output for brain_forget.
type ForgetOutput struct {
Success bool `json:"success"`
Forgotten string `json:"forgotten"`
Timestamp time.Time `json:"timestamp"`
}
// ListInput is the input for brain_list.
type ListInput struct {
Project string `json:"project,omitempty"`
Type string `json:"type,omitempty"`
AgentID string `json:"agent_id,omitempty"`
Limit int `json:"limit,omitempty"`
}
// ListOutput is the output for brain_list.
type ListOutput struct {
Success bool `json:"success"`
Count int `json:"count"`
Memories []Memory `json:"memories"`
}
// -- Tool registration --------------------------------------------------------
func (s *Subsystem) registerBrainTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "brain_remember",
Description: "Store a memory in the shared OpenBrain knowledge store. Persists decisions, observations, conventions, research, plans, bugs, or architecture knowledge for other agents.",
}, s.brainRemember)
mcp.AddTool(server, &mcp.Tool{
Name: "brain_recall",
Description: "Semantic search across the shared OpenBrain knowledge store. Returns memories ranked by similarity to your query, with optional filtering.",
}, s.brainRecall)
mcp.AddTool(server, &mcp.Tool{
Name: "brain_forget",
Description: "Remove a memory from the shared OpenBrain knowledge store. Permanently deletes from both database and vector index.",
}, s.brainForget)
mcp.AddTool(server, &mcp.Tool{
Name: "brain_list",
Description: "List memories in the shared OpenBrain knowledge store. Supports filtering by project, type, and agent. No vector search -- use brain_recall for semantic queries.",
}, s.brainList)
}
// -- Tool handlers ------------------------------------------------------------
func (s *Subsystem) brainRemember(_ context.Context, _ *mcp.CallToolRequest, input RememberInput) (*mcp.CallToolResult, RememberOutput, error) {
if s.bridge == nil {
return nil, RememberOutput{}, errBridgeNotAvailable
}
err := s.bridge.Send(ide.BridgeMessage{
Type: "brain_remember",
Data: map[string]any{
"content": input.Content,
"type": input.Type,
"tags": input.Tags,
"project": input.Project,
"confidence": input.Confidence,
"supersedes": input.Supersedes,
"expires_in": input.ExpiresIn,
},
})
if err != nil {
return nil, RememberOutput{}, fmt.Errorf("failed to send brain_remember: %w", err)
}
return nil, RememberOutput{
Success: true,
Timestamp: time.Now(),
}, nil
}
func (s *Subsystem) brainRecall(_ context.Context, _ *mcp.CallToolRequest, input RecallInput) (*mcp.CallToolResult, RecallOutput, error) {
if s.bridge == nil {
return nil, RecallOutput{}, errBridgeNotAvailable
}
err := s.bridge.Send(ide.BridgeMessage{
Type: "brain_recall",
Data: map[string]any{
"query": input.Query,
"top_k": input.TopK,
"filter": input.Filter,
},
})
if err != nil {
return nil, RecallOutput{}, fmt.Errorf("failed to send brain_recall: %w", err)
}
return nil, RecallOutput{
Success: true,
Memories: []Memory{},
}, nil
}
func (s *Subsystem) brainForget(_ context.Context, _ *mcp.CallToolRequest, input ForgetInput) (*mcp.CallToolResult, ForgetOutput, error) {
if s.bridge == nil {
return nil, ForgetOutput{}, errBridgeNotAvailable
}
err := s.bridge.Send(ide.BridgeMessage{
Type: "brain_forget",
Data: map[string]any{
"id": input.ID,
"reason": input.Reason,
},
})
if err != nil {
return nil, ForgetOutput{}, fmt.Errorf("failed to send brain_forget: %w", err)
}
return nil, ForgetOutput{
Success: true,
Forgotten: input.ID,
Timestamp: time.Now(),
}, nil
}
func (s *Subsystem) brainList(_ context.Context, _ *mcp.CallToolRequest, input ListInput) (*mcp.CallToolResult, ListOutput, error) {
if s.bridge == nil {
return nil, ListOutput{}, errBridgeNotAvailable
}
err := s.bridge.Send(ide.BridgeMessage{
Type: "brain_list",
Data: map[string]any{
"project": input.Project,
"type": input.Type,
"agent_id": input.AgentID,
"limit": input.Limit,
},
})
if err != nil {
return nil, ListOutput{}, fmt.Errorf("failed to send brain_list: %w", err)
}
return nil, ListOutput{
Success: true,
Memories: []Memory{},
}, nil
}

64
pkg/mcp/bridge.go Normal file
View file

@ -0,0 +1,64 @@
// SPDX-License-Identifier: EUPL-1.2
package mcp
import (
"encoding/json"
"errors"
"io"
"net/http"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/go-api"
)
// maxBodySize is the maximum request body size accepted by bridged tool endpoints.
const maxBodySize = 10 << 20 // 10 MB
// BridgeToAPI populates a go-api ToolBridge from recorded MCP tools.
// Each tool becomes a POST endpoint that reads a JSON body, dispatches
// to the tool's RESTHandler (which knows the concrete input type), and
// wraps the result in the standard api.Response envelope.
func BridgeToAPI(svc *Service, bridge *api.ToolBridge) {
for rec := range svc.ToolsSeq() {
desc := api.ToolDescriptor{
Name: rec.Name,
Description: rec.Description,
Group: rec.Group,
InputSchema: rec.InputSchema,
OutputSchema: rec.OutputSchema,
}
// Capture the handler for the closure.
handler := rec.RESTHandler
bridge.Add(desc, func(c *gin.Context) {
var body []byte
if c.Request.Body != nil {
var err error
body, err = io.ReadAll(io.LimitReader(c.Request.Body, maxBodySize))
if err != nil {
c.JSON(http.StatusBadRequest, api.Fail("invalid_request", "Failed to read request body"))
return
}
}
result, err := handler(c.Request.Context(), body)
if err != nil {
// Classify JSON parse errors as client errors (400),
// everything else as server errors (500).
var syntaxErr *json.SyntaxError
var typeErr *json.UnmarshalTypeError
if errors.As(err, &syntaxErr) || errors.As(err, &typeErr) {
c.JSON(http.StatusBadRequest, api.Fail("invalid_input", "Malformed JSON in request body"))
return
}
c.JSON(http.StatusInternalServerError, api.Fail("tool_error", "Tool execution failed"))
return
}
c.JSON(http.StatusOK, api.OK(result))
})
}
}

250
pkg/mcp/bridge_test.go Normal file
View file

@ -0,0 +1,250 @@
// SPDX-License-Identifier: EUPL-1.2
package mcp
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/go-api"
)
func init() {
gin.SetMode(gin.TestMode)
}
func TestBridgeToAPI_Good_AllTools(t *testing.T) {
svc, err := New(WithWorkspaceRoot(t.TempDir()))
if err != nil {
t.Fatal(err)
}
bridge := api.NewToolBridge("/tools")
BridgeToAPI(svc, bridge)
svcCount := len(svc.Tools())
bridgeCount := len(bridge.Tools())
if svcCount == 0 {
t.Fatal("expected non-zero tool count from service")
}
if bridgeCount != svcCount {
t.Fatalf("bridge tool count %d != service tool count %d", bridgeCount, svcCount)
}
// Verify names match.
svcNames := make(map[string]bool)
for _, tr := range svc.Tools() {
svcNames[tr.Name] = true
}
for _, td := range bridge.Tools() {
if !svcNames[td.Name] {
t.Errorf("bridge has tool %q not found in service", td.Name)
}
}
}
func TestBridgeToAPI_Good_DescribableGroup(t *testing.T) {
svc, err := New(WithWorkspaceRoot(t.TempDir()))
if err != nil {
t.Fatal(err)
}
bridge := api.NewToolBridge("/tools")
BridgeToAPI(svc, bridge)
// ToolBridge implements DescribableGroup.
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))
}
for _, d := range descs {
if d.Method != "POST" {
t.Errorf("expected Method=POST for %s, got %q", d.Path, d.Method)
}
if d.Summary == "" {
t.Errorf("expected non-empty Summary for %s", d.Path)
}
if len(d.Tags) == 0 {
t.Errorf("expected non-empty Tags for %s", d.Path)
}
}
}
func TestBridgeToAPI_Good_FileRead(t *testing.T) {
tmpDir := t.TempDir()
// Create a test file in the workspace.
testContent := "hello from bridge test"
if err := os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte(testContent), 0644); err != nil {
t.Fatal(err)
}
svc, err := New(WithWorkspaceRoot(tmpDir))
if err != nil {
t.Fatal(err)
}
bridge := api.NewToolBridge("/tools")
BridgeToAPI(svc, bridge)
// Register with a Gin engine and make a request.
engine := gin.New()
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
body := `{"path":"test.txt"}`
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/tools/file_read", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
// Parse the response envelope.
var resp api.Response[ReadFileOutput]
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if !resp.Success {
t.Fatalf("expected Success=true, got error: %+v", resp.Error)
}
if resp.Data.Content != testContent {
t.Fatalf("expected content %q, got %q", testContent, resp.Data.Content)
}
if resp.Data.Path != "test.txt" {
t.Fatalf("expected path %q, got %q", "test.txt", resp.Data.Path)
}
}
func TestBridgeToAPI_Bad_InvalidJSON(t *testing.T) {
svc, err := New(WithWorkspaceRoot(t.TempDir()))
if err != nil {
t.Fatal(err)
}
bridge := api.NewToolBridge("/tools")
BridgeToAPI(svc, bridge)
engine := gin.New()
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
// Send malformed JSON.
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/tools/file_read", strings.NewReader("{bad json"))
req.Header.Set("Content-Type", "application/json")
engine.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
// The handler unmarshals via RESTHandler which returns an error,
// but since it's a JSON parse error it ends up as tool_error.
// Check we get a non-200 with an error envelope.
if w.Code == http.StatusOK {
t.Fatalf("expected non-200 for invalid JSON, got 200")
}
}
var resp api.Response[any]
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp.Success {
t.Fatal("expected Success=false for invalid JSON")
}
if resp.Error == nil {
t.Fatal("expected error in response")
}
}
func TestBridgeToAPI_Good_EndToEnd(t *testing.T) {
svc, err := New(WithWorkspaceRoot(t.TempDir()))
if err != nil {
t.Fatal(err)
}
bridge := api.NewToolBridge("/tools")
BridgeToAPI(svc, bridge)
// Create an api.Engine with the bridge registered and Swagger enabled.
e, err := api.New(
api.WithSwagger("MCP Bridge Test", "Testing MCP-to-REST bridge", "0.1.0"),
)
if err != nil {
t.Fatal(err)
}
e.Register(bridge)
// Use a real test server because gin-swagger reads RequestURI
// which is not populated by httptest.NewRecorder.
srv := httptest.NewServer(e.Handler())
defer srv.Close()
// Verify the health endpoint still works.
resp, err := http.Get(srv.URL + "/health")
if err != nil {
t.Fatalf("health request failed: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for /health, got %d", resp.StatusCode)
}
// Verify a tool endpoint is reachable through the engine.
resp2, err := http.Post(srv.URL+"/tools/lang_list", "application/json", nil)
if err != nil {
t.Fatalf("lang_list request failed: %v", err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for /tools/lang_list, got %d", resp2.StatusCode)
}
var langResp api.Response[GetSupportedLanguagesOutput]
if err := json.NewDecoder(resp2.Body).Decode(&langResp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if !langResp.Success {
t.Fatalf("expected Success=true, got error: %+v", langResp.Error)
}
if len(langResp.Data.Languages) == 0 {
t.Fatal("expected non-empty languages list")
}
// Verify Swagger endpoint contains tool paths.
resp3, err := http.Get(srv.URL + "/swagger/doc.json")
if err != nil {
t.Fatalf("swagger request failed: %v", err)
}
defer resp3.Body.Close()
if resp3.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for /swagger/doc.json, got %d", resp3.StatusCode)
}
var specDoc map[string]any
if err := json.NewDecoder(resp3.Body).Decode(&specDoc); err != nil {
t.Fatalf("swagger unmarshal error: %v", err)
}
paths, ok := specDoc["paths"].(map[string]any)
if !ok {
t.Fatal("expected 'paths' in swagger spec")
}
if _, ok := paths["/tools/file_read"]; !ok {
t.Error("expected /tools/file_read in swagger paths")
}
if _, ok := paths["/tools/lang_list"]; !ok {
t.Error("expected /tools/lang_list in swagger paths")
}
}

191
pkg/mcp/ide/bridge.go Normal file
View file

@ -0,0 +1,191 @@
package ide
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"sync"
"time"
"forge.lthn.ai/core/go-ws"
"github.com/gorilla/websocket"
)
// BridgeMessage is the wire format between the IDE and Laravel.
type BridgeMessage struct {
Type string `json:"type"`
Channel string `json:"channel,omitempty"`
SessionID string `json:"sessionId,omitempty"`
Data any `json:"data,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
// Bridge maintains a WebSocket connection to the Laravel core-agentic
// backend and forwards responses to a local ws.Hub.
type Bridge struct {
cfg Config
hub *ws.Hub
conn *websocket.Conn
mu sync.Mutex
connected bool
cancel context.CancelFunc
}
// NewBridge creates a bridge that will connect to the Laravel backend and
// forward incoming messages to the provided ws.Hub channels.
func NewBridge(hub *ws.Hub, cfg Config) *Bridge {
return &Bridge{cfg: cfg, hub: hub}
}
// Start begins the connection loop in a background goroutine.
// Call Shutdown to stop it.
func (b *Bridge) Start(ctx context.Context) {
ctx, b.cancel = context.WithCancel(ctx)
go b.connectLoop(ctx)
}
// Shutdown cleanly closes the bridge.
func (b *Bridge) Shutdown() {
if b.cancel != nil {
b.cancel()
}
b.mu.Lock()
defer b.mu.Unlock()
if b.conn != nil {
b.conn.Close()
b.conn = nil
}
b.connected = false
}
// Connected reports whether the bridge has an active connection.
func (b *Bridge) Connected() bool {
b.mu.Lock()
defer b.mu.Unlock()
return b.connected
}
// Send sends a message to the Laravel backend.
func (b *Bridge) Send(msg BridgeMessage) error {
b.mu.Lock()
defer b.mu.Unlock()
if b.conn == nil {
return errors.New("bridge: not connected")
}
msg.Timestamp = time.Now()
data, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("bridge: marshal failed: %w", err)
}
return b.conn.WriteMessage(websocket.TextMessage, data)
}
// connectLoop reconnects to Laravel with exponential backoff.
func (b *Bridge) connectLoop(ctx context.Context) {
delay := b.cfg.ReconnectInterval
for {
select {
case <-ctx.Done():
return
default:
}
if err := b.dial(ctx); err != nil {
log.Printf("ide bridge: connect failed: %v", err)
select {
case <-ctx.Done():
return
case <-time.After(delay):
}
delay = min(delay*2, b.cfg.MaxReconnectInterval)
continue
}
// Reset backoff on successful connection
delay = b.cfg.ReconnectInterval
b.readLoop(ctx)
}
}
func (b *Bridge) dial(ctx context.Context) error {
dialer := websocket.Dialer{
HandshakeTimeout: 10 * time.Second,
}
var header http.Header
if b.cfg.Token != "" {
header = http.Header{}
header.Set("Authorization", "Bearer "+b.cfg.Token)
}
conn, _, err := dialer.DialContext(ctx, b.cfg.LaravelWSURL, header)
if err != nil {
return err
}
b.mu.Lock()
b.conn = conn
b.connected = true
b.mu.Unlock()
log.Printf("ide bridge: connected to %s", b.cfg.LaravelWSURL)
return nil
}
func (b *Bridge) readLoop(ctx context.Context) {
defer func() {
b.mu.Lock()
if b.conn != nil {
b.conn.Close()
}
b.connected = false
b.mu.Unlock()
}()
for {
select {
case <-ctx.Done():
return
default:
}
_, data, err := b.conn.ReadMessage()
if err != nil {
log.Printf("ide bridge: read error: %v", err)
return
}
var msg BridgeMessage
if err := json.Unmarshal(data, &msg); err != nil {
log.Printf("ide bridge: unmarshal error: %v", err)
continue
}
b.dispatch(msg)
}
}
// dispatch routes an incoming message to the appropriate ws.Hub channel.
func (b *Bridge) dispatch(msg BridgeMessage) {
if b.hub == nil {
return
}
wsMsg := ws.Message{
Type: ws.TypeEvent,
Data: msg.Data,
}
channel := msg.Channel
if channel == "" {
channel = "ide:" + msg.Type
}
if err := b.hub.SendToChannel(channel, wsMsg); err != nil {
log.Printf("ide bridge: dispatch to %s failed: %v", channel, err)
}
}

442
pkg/mcp/ide/bridge_test.go Normal file
View file

@ -0,0 +1,442 @@
package ide
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"forge.lthn.ai/core/go-ws"
"github.com/gorilla/websocket"
)
var testUpgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
// echoServer creates a test WebSocket server that echoes messages back.
func echoServer(t *testing.T) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := testUpgrader.Upgrade(w, r, nil)
if err != nil {
t.Logf("upgrade error: %v", err)
return
}
defer conn.Close()
for {
mt, data, err := conn.ReadMessage()
if err != nil {
break
}
if err := conn.WriteMessage(mt, data); err != nil {
break
}
}
}))
}
func wsURL(ts *httptest.Server) string {
return "ws" + strings.TrimPrefix(ts.URL, "http")
}
// waitConnected polls bridge.Connected() until true or timeout.
func waitConnected(t *testing.T, bridge *Bridge, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for !bridge.Connected() && time.Now().Before(deadline) {
time.Sleep(50 * time.Millisecond)
}
if !bridge.Connected() {
t.Fatal("bridge did not connect within timeout")
}
}
func TestBridge_Good_ConnectAndSend(t *testing.T) {
ts := echoServer(t)
defer ts.Close()
hub := ws.NewHub()
ctx := t.Context()
go hub.Run(ctx)
cfg := DefaultConfig()
cfg.LaravelWSURL = wsURL(ts)
cfg.ReconnectInterval = 100 * time.Millisecond
bridge := NewBridge(hub, cfg)
bridge.Start(ctx)
waitConnected(t, bridge, 2*time.Second)
err := bridge.Send(BridgeMessage{
Type: "test",
Data: "hello",
})
if err != nil {
t.Fatalf("Send() failed: %v", err)
}
}
func TestBridge_Good_Shutdown(t *testing.T) {
ts := echoServer(t)
defer ts.Close()
hub := ws.NewHub()
ctx := t.Context()
go hub.Run(ctx)
cfg := DefaultConfig()
cfg.LaravelWSURL = wsURL(ts)
cfg.ReconnectInterval = 100 * time.Millisecond
bridge := NewBridge(hub, cfg)
bridge.Start(ctx)
waitConnected(t, bridge, 2*time.Second)
bridge.Shutdown()
if bridge.Connected() {
t.Error("bridge should be disconnected after Shutdown")
}
}
func TestBridge_Bad_SendWithoutConnection(t *testing.T) {
hub := ws.NewHub()
cfg := DefaultConfig()
bridge := NewBridge(hub, cfg)
err := bridge.Send(BridgeMessage{Type: "test"})
if err == nil {
t.Error("expected error when sending without connection")
}
}
func TestBridge_Good_MessageDispatch(t *testing.T) {
// Server that sends a message to the bridge on connect.
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := testUpgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
msg := BridgeMessage{
Type: "chat_response",
Channel: "chat:session-1",
Data: "hello from laravel",
}
data, _ := json.Marshal(msg)
conn.WriteMessage(websocket.TextMessage, data)
// Keep connection open
for {
_, _, err := conn.ReadMessage()
if err != nil {
break
}
}
}))
defer ts.Close()
hub := ws.NewHub()
ctx := t.Context()
go hub.Run(ctx)
cfg := DefaultConfig()
cfg.LaravelWSURL = wsURL(ts)
cfg.ReconnectInterval = 100 * time.Millisecond
bridge := NewBridge(hub, cfg)
bridge.Start(ctx)
waitConnected(t, bridge, 2*time.Second)
// Give time for the dispatched message to be processed.
time.Sleep(200 * time.Millisecond)
// Verify hub stats — the message was dispatched (even without subscribers).
// This confirms the dispatch path ran without error.
}
func TestBridge_Good_Reconnect(t *testing.T) {
// Use atomic counter to avoid data race between HTTP handler goroutine
// and the test goroutine.
var callCount atomic.Int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := callCount.Add(1)
conn, err := testUpgrader.Upgrade(w, r, nil)
if err != nil {
return
}
// Close immediately on first connection to force reconnect
if n == 1 {
conn.Close()
return
}
defer conn.Close()
for {
_, _, err := conn.ReadMessage()
if err != nil {
break
}
}
}))
defer ts.Close()
hub := ws.NewHub()
ctx := t.Context()
go hub.Run(ctx)
cfg := DefaultConfig()
cfg.LaravelWSURL = wsURL(ts)
cfg.ReconnectInterval = 100 * time.Millisecond
cfg.MaxReconnectInterval = 200 * time.Millisecond
bridge := NewBridge(hub, cfg)
bridge.Start(ctx)
waitConnected(t, bridge, 3*time.Second)
if callCount.Load() < 2 {
t.Errorf("expected at least 2 connection attempts, got %d", callCount.Load())
}
}
func TestBridge_Good_ExponentialBackoff(t *testing.T) {
// Track timestamps of dial attempts to verify backoff behaviour.
// The server rejects the WebSocket upgrade with HTTP 403, so dial()
// returns an error and the exponential backoff path fires.
var attempts []time.Time
var mu sync.Mutex
var attemptCount atomic.Int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
attempts = append(attempts, time.Now())
mu.Unlock()
attemptCount.Add(1)
// Reject the upgrade — this makes dial() fail, triggering backoff.
http.Error(w, "forbidden", http.StatusForbidden)
}))
defer ts.Close()
hub := ws.NewHub()
ctx := t.Context()
go hub.Run(ctx)
cfg := DefaultConfig()
cfg.LaravelWSURL = wsURL(ts)
cfg.ReconnectInterval = 100 * time.Millisecond
cfg.MaxReconnectInterval = 400 * time.Millisecond
bridge := NewBridge(hub, cfg)
bridge.Start(ctx)
// Wait for at least 4 dial attempts.
deadline := time.Now().Add(5 * time.Second)
for attemptCount.Load() < 4 && time.Now().Before(deadline) {
time.Sleep(50 * time.Millisecond)
}
bridge.Shutdown()
mu.Lock()
defer mu.Unlock()
if len(attempts) < 4 {
t.Fatalf("expected at least 4 connection attempts, got %d", len(attempts))
}
// Verify exponential backoff: gap between attempts should increase.
// Expected delays: ~100ms, ~200ms, ~400ms (capped).
// Allow generous tolerance since timing is non-deterministic.
for i := 1; i < len(attempts) && i <= 3; i++ {
gap := attempts[i].Sub(attempts[i-1])
// Minimum expected delay doubles each time: 100, 200, 400.
// We check a lower bound (50% of expected) to be resilient.
expectedMin := time.Duration(50*(1<<(i-1))) * time.Millisecond
if gap < expectedMin {
t.Errorf("attempt %d->%d gap %v < expected minimum %v", i-1, i, gap, expectedMin)
}
}
// Verify the backoff caps at MaxReconnectInterval.
if len(attempts) >= 5 {
gap := attempts[4].Sub(attempts[3])
// After cap is hit, delay should not exceed MaxReconnectInterval + tolerance.
maxExpected := cfg.MaxReconnectInterval + 200*time.Millisecond
if gap > maxExpected {
t.Errorf("attempt 3->4 gap %v exceeded max backoff %v", gap, maxExpected)
}
}
}
func TestBridge_Good_ReconnectDetectsServerShutdown(t *testing.T) {
// Start a server that closes the WS connection on demand, then close
// the server entirely so the bridge cannot reconnect.
closeConn := make(chan struct{}, 1)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := testUpgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
// Wait for signal to close
<-closeConn
conn.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseGoingAway, "shutdown"))
}))
hub := ws.NewHub()
ctx := t.Context()
go hub.Run(ctx)
cfg := DefaultConfig()
cfg.LaravelWSURL = wsURL(ts)
// Use long reconnect so bridge stays disconnected after server dies.
cfg.ReconnectInterval = 5 * time.Second
cfg.MaxReconnectInterval = 5 * time.Second
bridge := NewBridge(hub, cfg)
bridge.Start(ctx)
waitConnected(t, bridge, 2*time.Second)
// Signal server handler to close the WS connection, then shut down
// the server so the reconnect dial() also fails.
closeConn <- struct{}{}
ts.Close()
// Wait for disconnection.
deadline := time.Now().Add(3 * time.Second)
for bridge.Connected() && time.Now().Before(deadline) {
time.Sleep(50 * time.Millisecond)
}
if bridge.Connected() {
t.Error("expected bridge to detect server-side connection close")
}
}
func TestBridge_Good_AuthHeader(t *testing.T) {
// Server that checks for the Authorization header on upgrade.
var receivedAuth atomic.Value
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedAuth.Store(r.Header.Get("Authorization"))
conn, err := testUpgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
for {
_, _, err := conn.ReadMessage()
if err != nil {
break
}
}
}))
defer ts.Close()
hub := ws.NewHub()
ctx := t.Context()
go hub.Run(ctx)
cfg := DefaultConfig()
cfg.LaravelWSURL = wsURL(ts)
cfg.ReconnectInterval = 100 * time.Millisecond
cfg.Token = "test-secret-token-42"
bridge := NewBridge(hub, cfg)
bridge.Start(ctx)
waitConnected(t, bridge, 2*time.Second)
auth, ok := receivedAuth.Load().(string)
if !ok || auth == "" {
t.Fatal("server did not receive Authorization header")
}
expected := "Bearer test-secret-token-42"
if auth != expected {
t.Errorf("expected auth header %q, got %q", expected, auth)
}
}
func TestBridge_Good_NoAuthHeaderWhenTokenEmpty(t *testing.T) {
// Verify that no Authorization header is sent when Token is empty.
var receivedAuth atomic.Value
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedAuth.Store(r.Header.Get("Authorization"))
conn, err := testUpgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
for {
_, _, err := conn.ReadMessage()
if err != nil {
break
}
}
}))
defer ts.Close()
hub := ws.NewHub()
ctx := t.Context()
go hub.Run(ctx)
cfg := DefaultConfig()
cfg.LaravelWSURL = wsURL(ts)
cfg.ReconnectInterval = 100 * time.Millisecond
// Token intentionally left empty
bridge := NewBridge(hub, cfg)
bridge.Start(ctx)
waitConnected(t, bridge, 2*time.Second)
auth, _ := receivedAuth.Load().(string)
if auth != "" {
t.Errorf("expected no Authorization header when token is empty, got %q", auth)
}
}
func TestBridge_Good_WithTokenOption(t *testing.T) {
// Verify the WithToken option function works.
cfg := DefaultConfig()
opt := WithToken("my-token")
opt(&cfg)
if cfg.Token != "my-token" {
t.Errorf("expected token 'my-token', got %q", cfg.Token)
}
}
func TestSubsystem_Good_Name(t *testing.T) {
sub := New(nil)
if sub.Name() != "ide" {
t.Errorf("expected name 'ide', got %q", sub.Name())
}
}
func TestSubsystem_Good_NilHub(t *testing.T) {
sub := New(nil)
if sub.Bridge() != nil {
t.Error("expected nil bridge when hub is nil")
}
// Shutdown should not panic
if err := sub.Shutdown(context.Background()); err != nil {
t.Errorf("Shutdown with nil bridge failed: %v", err)
}
}

57
pkg/mcp/ide/config.go Normal file
View file

@ -0,0 +1,57 @@
// Package ide provides an MCP subsystem that bridges the desktop IDE to
// a Laravel core-agentic backend over WebSocket.
package ide
import "time"
// Config holds connection and workspace settings for the IDE subsystem.
type Config struct {
// LaravelWSURL is the WebSocket endpoint for the Laravel core-agentic backend.
LaravelWSURL string
// WorkspaceRoot is the local path used as the default workspace context.
WorkspaceRoot string
// Token is the Bearer token sent in the Authorization header during
// WebSocket upgrade. When empty, no auth header is sent.
Token string
// ReconnectInterval controls how long to wait between reconnect attempts.
ReconnectInterval time.Duration
// MaxReconnectInterval caps exponential backoff for reconnection.
MaxReconnectInterval time.Duration
}
// DefaultConfig returns sensible defaults for local development.
func DefaultConfig() Config {
return Config{
LaravelWSURL: "ws://localhost:9876/ws",
WorkspaceRoot: ".",
ReconnectInterval: 2 * time.Second,
MaxReconnectInterval: 30 * time.Second,
}
}
// Option configures the IDE subsystem.
type Option func(*Config)
// WithLaravelURL sets the Laravel WebSocket endpoint.
func WithLaravelURL(url string) Option {
return func(c *Config) { c.LaravelWSURL = url }
}
// WithWorkspaceRoot sets the workspace root directory.
func WithWorkspaceRoot(root string) Option {
return func(c *Config) { c.WorkspaceRoot = root }
}
// WithReconnectInterval sets the base reconnect interval.
func WithReconnectInterval(d time.Duration) Option {
return func(c *Config) { c.ReconnectInterval = d }
}
// WithToken sets the Bearer token for WebSocket authentication.
func WithToken(token string) Option {
return func(c *Config) { c.Token = token }
}

62
pkg/mcp/ide/ide.go Normal file
View file

@ -0,0 +1,62 @@
package ide
import (
"context"
"errors"
"forge.lthn.ai/core/go-ws"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// errBridgeNotAvailable is returned when a tool requires the Laravel bridge
// but it has not been initialised (headless mode).
var errBridgeNotAvailable = errors.New("bridge not available")
// Subsystem implements mcp.Subsystem and mcp.SubsystemWithShutdown for the IDE.
type Subsystem struct {
cfg Config
bridge *Bridge
hub *ws.Hub
}
// New creates an IDE subsystem. The ws.Hub is used for real-time forwarding;
// pass nil if headless (tools still work but real-time streaming is disabled).
func New(hub *ws.Hub, opts ...Option) *Subsystem {
cfg := DefaultConfig()
for _, opt := range opts {
opt(&cfg)
}
var bridge *Bridge
if hub != nil {
bridge = NewBridge(hub, cfg)
}
return &Subsystem{cfg: cfg, bridge: bridge, hub: hub}
}
// Name implements mcp.Subsystem.
func (s *Subsystem) Name() string { return "ide" }
// RegisterTools implements mcp.Subsystem.
func (s *Subsystem) RegisterTools(server *mcp.Server) {
s.registerChatTools(server)
s.registerBuildTools(server)
s.registerDashboardTools(server)
}
// Shutdown implements mcp.SubsystemWithShutdown.
func (s *Subsystem) Shutdown(_ context.Context) error {
if s.bridge != nil {
s.bridge.Shutdown()
}
return nil
}
// Bridge returns the Laravel WebSocket bridge (may be nil in headless mode).
func (s *Subsystem) Bridge() *Bridge { return s.bridge }
// StartBridge begins the background connection to the Laravel backend.
func (s *Subsystem) StartBridge(ctx context.Context) {
if s.bridge != nil {
s.bridge.Start(ctx)
}
}

114
pkg/mcp/ide/tools_build.go Normal file
View file

@ -0,0 +1,114 @@
package ide
import (
"context"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// Build tool input/output types.
// BuildStatusInput is the input for ide_build_status.
type BuildStatusInput struct {
BuildID string `json:"buildId"`
}
// BuildInfo represents a single build.
type BuildInfo struct {
ID string `json:"id"`
Repo string `json:"repo"`
Branch string `json:"branch"`
Status string `json:"status"`
Duration string `json:"duration,omitempty"`
StartedAt time.Time `json:"startedAt"`
}
// BuildStatusOutput is the output for ide_build_status.
type BuildStatusOutput struct {
Build BuildInfo `json:"build"`
}
// BuildListInput is the input for ide_build_list.
type BuildListInput struct {
Repo string `json:"repo,omitempty"`
Limit int `json:"limit,omitempty"`
}
// BuildListOutput is the output for ide_build_list.
type BuildListOutput struct {
Builds []BuildInfo `json:"builds"`
}
// BuildLogsInput is the input for ide_build_logs.
type BuildLogsInput struct {
BuildID string `json:"buildId"`
Tail int `json:"tail,omitempty"`
}
// BuildLogsOutput is the output for ide_build_logs.
type BuildLogsOutput struct {
BuildID string `json:"buildId"`
Lines []string `json:"lines"`
}
func (s *Subsystem) registerBuildTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "ide_build_status",
Description: "Get the status of a specific build",
}, s.buildStatus)
mcp.AddTool(server, &mcp.Tool{
Name: "ide_build_list",
Description: "List recent builds, optionally filtered by repository",
}, s.buildList)
mcp.AddTool(server, &mcp.Tool{
Name: "ide_build_logs",
Description: "Retrieve log output for a build",
}, s.buildLogs)
}
// buildStatus requests build status from the Laravel backend.
// Stub implementation: sends request via bridge, returns "unknown" status. Awaiting Laravel backend.
func (s *Subsystem) buildStatus(_ context.Context, _ *mcp.CallToolRequest, input BuildStatusInput) (*mcp.CallToolResult, BuildStatusOutput, error) {
if s.bridge == nil {
return nil, BuildStatusOutput{}, errBridgeNotAvailable
}
_ = s.bridge.Send(BridgeMessage{
Type: "build_status",
Data: map[string]any{"buildId": input.BuildID},
})
return nil, BuildStatusOutput{
Build: BuildInfo{ID: input.BuildID, Status: "unknown"},
}, nil
}
// buildList requests a list of builds from the Laravel backend.
// Stub implementation: sends request via bridge, returns empty list. Awaiting Laravel backend.
func (s *Subsystem) buildList(_ context.Context, _ *mcp.CallToolRequest, input BuildListInput) (*mcp.CallToolResult, BuildListOutput, error) {
if s.bridge == nil {
return nil, BuildListOutput{}, errBridgeNotAvailable
}
_ = s.bridge.Send(BridgeMessage{
Type: "build_list",
Data: map[string]any{"repo": input.Repo, "limit": input.Limit},
})
return nil, BuildListOutput{Builds: []BuildInfo{}}, nil
}
// buildLogs requests build log output from the Laravel backend.
// Stub implementation: sends request via bridge, returns empty lines. Awaiting Laravel backend.
func (s *Subsystem) buildLogs(_ context.Context, _ *mcp.CallToolRequest, input BuildLogsInput) (*mcp.CallToolResult, BuildLogsOutput, error) {
if s.bridge == nil {
return nil, BuildLogsOutput{}, errBridgeNotAvailable
}
_ = s.bridge.Send(BridgeMessage{
Type: "build_logs",
Data: map[string]any{"buildId": input.BuildID, "tail": input.Tail},
})
return nil, BuildLogsOutput{
BuildID: input.BuildID,
Lines: []string{},
}, nil
}

201
pkg/mcp/ide/tools_chat.go Normal file
View file

@ -0,0 +1,201 @@
package ide
import (
"context"
"fmt"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// Chat tool input/output types.
// ChatSendInput is the input for ide_chat_send.
type ChatSendInput struct {
SessionID string `json:"sessionId"`
Message string `json:"message"`
}
// ChatSendOutput is the output for ide_chat_send.
type ChatSendOutput struct {
Sent bool `json:"sent"`
SessionID string `json:"sessionId"`
Timestamp time.Time `json:"timestamp"`
}
// ChatHistoryInput is the input for ide_chat_history.
type ChatHistoryInput struct {
SessionID string `json:"sessionId"`
Limit int `json:"limit,omitempty"`
}
// ChatMessage represents a single message in history.
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
Timestamp time.Time `json:"timestamp"`
}
// ChatHistoryOutput is the output for ide_chat_history.
type ChatHistoryOutput struct {
SessionID string `json:"sessionId"`
Messages []ChatMessage `json:"messages"`
}
// SessionListInput is the input for ide_session_list.
type SessionListInput struct{}
// Session represents an agent session.
type Session struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
}
// SessionListOutput is the output for ide_session_list.
type SessionListOutput struct {
Sessions []Session `json:"sessions"`
}
// SessionCreateInput is the input for ide_session_create.
type SessionCreateInput struct {
Name string `json:"name"`
}
// SessionCreateOutput is the output for ide_session_create.
type SessionCreateOutput struct {
Session Session `json:"session"`
}
// PlanStatusInput is the input for ide_plan_status.
type PlanStatusInput struct {
SessionID string `json:"sessionId"`
}
// PlanStep is a single step in an agent plan.
type PlanStep struct {
Name string `json:"name"`
Status string `json:"status"`
}
// PlanStatusOutput is the output for ide_plan_status.
type PlanStatusOutput struct {
SessionID string `json:"sessionId"`
Status string `json:"status"`
Steps []PlanStep `json:"steps"`
}
func (s *Subsystem) registerChatTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "ide_chat_send",
Description: "Send a message to an agent chat session",
}, s.chatSend)
mcp.AddTool(server, &mcp.Tool{
Name: "ide_chat_history",
Description: "Retrieve message history for a chat session",
}, s.chatHistory)
mcp.AddTool(server, &mcp.Tool{
Name: "ide_session_list",
Description: "List active agent sessions",
}, s.sessionList)
mcp.AddTool(server, &mcp.Tool{
Name: "ide_session_create",
Description: "Create a new agent session",
}, s.sessionCreate)
mcp.AddTool(server, &mcp.Tool{
Name: "ide_plan_status",
Description: "Get the current plan status for a session",
}, s.planStatus)
}
// chatSend forwards a chat message to the Laravel backend via bridge.
// Stub implementation: delegates to bridge, real response arrives via WebSocket subscription.
func (s *Subsystem) chatSend(_ context.Context, _ *mcp.CallToolRequest, input ChatSendInput) (*mcp.CallToolResult, ChatSendOutput, error) {
if s.bridge == nil {
return nil, ChatSendOutput{}, errBridgeNotAvailable
}
err := s.bridge.Send(BridgeMessage{
Type: "chat_send",
Channel: "chat:" + input.SessionID,
SessionID: input.SessionID,
Data: input.Message,
})
if err != nil {
return nil, ChatSendOutput{}, fmt.Errorf("failed to send message: %w", err)
}
return nil, ChatSendOutput{
Sent: true,
SessionID: input.SessionID,
Timestamp: time.Now(),
}, nil
}
// chatHistory requests message history from the Laravel backend.
// Stub implementation: sends request via bridge, returns empty messages. Real data arrives via WebSocket.
func (s *Subsystem) chatHistory(_ context.Context, _ *mcp.CallToolRequest, input ChatHistoryInput) (*mcp.CallToolResult, ChatHistoryOutput, error) {
if s.bridge == nil {
return nil, ChatHistoryOutput{}, errBridgeNotAvailable
}
// Request history via bridge; for now return placeholder indicating the
// request was forwarded. Real data arrives via WebSocket subscription.
_ = s.bridge.Send(BridgeMessage{
Type: "chat_history",
SessionID: input.SessionID,
Data: map[string]any{"limit": input.Limit},
})
return nil, ChatHistoryOutput{
SessionID: input.SessionID,
Messages: []ChatMessage{},
}, nil
}
// sessionList requests the session list from the Laravel backend.
// Stub implementation: sends request via bridge, returns empty sessions. Awaiting Laravel backend.
func (s *Subsystem) sessionList(_ context.Context, _ *mcp.CallToolRequest, _ SessionListInput) (*mcp.CallToolResult, SessionListOutput, error) {
if s.bridge == nil {
return nil, SessionListOutput{}, errBridgeNotAvailable
}
_ = s.bridge.Send(BridgeMessage{Type: "session_list"})
return nil, SessionListOutput{Sessions: []Session{}}, nil
}
// sessionCreate requests a new session from the Laravel backend.
// Stub implementation: sends request via bridge, returns placeholder session. Awaiting Laravel backend.
func (s *Subsystem) sessionCreate(_ context.Context, _ *mcp.CallToolRequest, input SessionCreateInput) (*mcp.CallToolResult, SessionCreateOutput, error) {
if s.bridge == nil {
return nil, SessionCreateOutput{}, errBridgeNotAvailable
}
_ = s.bridge.Send(BridgeMessage{
Type: "session_create",
Data: map[string]any{"name": input.Name},
})
return nil, SessionCreateOutput{
Session: Session{
Name: input.Name,
Status: "creating",
CreatedAt: time.Now(),
},
}, nil
}
// planStatus requests plan status from the Laravel backend.
// Stub implementation: sends request via bridge, returns "unknown" status. Awaiting Laravel backend.
func (s *Subsystem) planStatus(_ context.Context, _ *mcp.CallToolRequest, input PlanStatusInput) (*mcp.CallToolResult, PlanStatusOutput, error) {
if s.bridge == nil {
return nil, PlanStatusOutput{}, errBridgeNotAvailable
}
_ = s.bridge.Send(BridgeMessage{
Type: "plan_status",
SessionID: input.SessionID,
})
return nil, PlanStatusOutput{
SessionID: input.SessionID,
Status: "unknown",
Steps: []PlanStep{},
}, nil
}

View file

@ -0,0 +1,132 @@
package ide
import (
"context"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// Dashboard tool input/output types.
// DashboardOverviewInput is the input for ide_dashboard_overview.
type DashboardOverviewInput struct{}
// DashboardOverview contains high-level platform stats.
type DashboardOverview struct {
Repos int `json:"repos"`
Services int `json:"services"`
ActiveSessions int `json:"activeSessions"`
RecentBuilds int `json:"recentBuilds"`
BridgeOnline bool `json:"bridgeOnline"`
}
// DashboardOverviewOutput is the output for ide_dashboard_overview.
type DashboardOverviewOutput struct {
Overview DashboardOverview `json:"overview"`
}
// DashboardActivityInput is the input for ide_dashboard_activity.
type DashboardActivityInput struct {
Limit int `json:"limit,omitempty"`
}
// ActivityEvent represents a single activity feed item.
type ActivityEvent struct {
Type string `json:"type"`
Message string `json:"message"`
Timestamp time.Time `json:"timestamp"`
}
// DashboardActivityOutput is the output for ide_dashboard_activity.
type DashboardActivityOutput struct {
Events []ActivityEvent `json:"events"`
}
// DashboardMetricsInput is the input for ide_dashboard_metrics.
type DashboardMetricsInput struct {
Period string `json:"period,omitempty"` // "1h", "24h", "7d"
}
// DashboardMetrics contains aggregate metrics.
type DashboardMetrics struct {
BuildsTotal int `json:"buildsTotal"`
BuildsSuccess int `json:"buildsSuccess"`
BuildsFailed int `json:"buildsFailed"`
AvgBuildTime string `json:"avgBuildTime"`
AgentSessions int `json:"agentSessions"`
MessagesTotal int `json:"messagesTotal"`
SuccessRate float64 `json:"successRate"`
}
// DashboardMetricsOutput is the output for ide_dashboard_metrics.
type DashboardMetricsOutput struct {
Period string `json:"period"`
Metrics DashboardMetrics `json:"metrics"`
}
func (s *Subsystem) registerDashboardTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "ide_dashboard_overview",
Description: "Get a high-level overview of the platform (repos, services, sessions, builds)",
}, s.dashboardOverview)
mcp.AddTool(server, &mcp.Tool{
Name: "ide_dashboard_activity",
Description: "Get the recent activity feed",
}, s.dashboardActivity)
mcp.AddTool(server, &mcp.Tool{
Name: "ide_dashboard_metrics",
Description: "Get aggregate build and agent metrics for a time period",
}, s.dashboardMetrics)
}
// dashboardOverview returns a platform overview with bridge status.
// Stub implementation: only BridgeOnline is live; other fields return zero values. Awaiting Laravel backend.
func (s *Subsystem) dashboardOverview(_ context.Context, _ *mcp.CallToolRequest, _ DashboardOverviewInput) (*mcp.CallToolResult, DashboardOverviewOutput, error) {
connected := s.bridge != nil && s.bridge.Connected()
if s.bridge != nil {
_ = s.bridge.Send(BridgeMessage{Type: "dashboard_overview"})
}
return nil, DashboardOverviewOutput{
Overview: DashboardOverview{
BridgeOnline: connected,
},
}, nil
}
// dashboardActivity requests the activity feed from the Laravel backend.
// Stub implementation: sends request via bridge, returns empty events. Awaiting Laravel backend.
func (s *Subsystem) dashboardActivity(_ context.Context, _ *mcp.CallToolRequest, input DashboardActivityInput) (*mcp.CallToolResult, DashboardActivityOutput, error) {
if s.bridge == nil {
return nil, DashboardActivityOutput{}, errBridgeNotAvailable
}
_ = s.bridge.Send(BridgeMessage{
Type: "dashboard_activity",
Data: map[string]any{"limit": input.Limit},
})
return nil, DashboardActivityOutput{Events: []ActivityEvent{}}, nil
}
// dashboardMetrics requests aggregate metrics from the Laravel backend.
// Stub implementation: sends request via bridge, returns zero metrics. Awaiting Laravel backend.
func (s *Subsystem) dashboardMetrics(_ context.Context, _ *mcp.CallToolRequest, input DashboardMetricsInput) (*mcp.CallToolResult, DashboardMetricsOutput, error) {
if s.bridge == nil {
return nil, DashboardMetricsOutput{}, errBridgeNotAvailable
}
period := input.Period
if period == "" {
period = "24h"
}
_ = s.bridge.Send(BridgeMessage{
Type: "dashboard_metrics",
Data: map[string]any{"period": period},
})
return nil, DashboardMetricsOutput{
Period: period,
Metrics: DashboardMetrics{},
}, nil
}

781
pkg/mcp/ide/tools_test.go Normal file
View file

@ -0,0 +1,781 @@
package ide
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"forge.lthn.ai/core/go-ws"
)
// --- Helpers ---
// newNilBridgeSubsystem returns a Subsystem with no hub/bridge (headless mode).
func newNilBridgeSubsystem() *Subsystem {
return New(nil)
}
// newConnectedSubsystem returns a Subsystem with a connected bridge and a
// running echo WS server. Caller must cancel ctx and close server when done.
func newConnectedSubsystem(t *testing.T) (*Subsystem, context.CancelFunc, *httptest.Server) {
t.Helper()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := testUpgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
for {
mt, data, err := conn.ReadMessage()
if err != nil {
break
}
_ = conn.WriteMessage(mt, data)
}
}))
hub := ws.NewHub()
ctx, cancel := context.WithCancel(context.Background())
go hub.Run(ctx)
sub := New(hub,
WithLaravelURL(wsURL(ts)),
WithReconnectInterval(50*time.Millisecond),
)
sub.StartBridge(ctx)
waitConnected(t, sub.Bridge(), 2*time.Second)
return sub, cancel, ts
}
// --- 4.3: Chat tool tests ---
// TestChatSend_Bad_NilBridge verifies chatSend returns error without a bridge.
func TestChatSend_Bad_NilBridge(t *testing.T) {
sub := newNilBridgeSubsystem()
_, _, err := sub.chatSend(context.Background(), nil, ChatSendInput{
SessionID: "s1",
Message: "hello",
})
if err == nil {
t.Error("expected error when bridge is nil")
}
}
// TestChatSend_Good_Connected verifies chatSend succeeds with a connected bridge.
func TestChatSend_Good_Connected(t *testing.T) {
sub, cancel, ts := newConnectedSubsystem(t)
defer cancel()
defer ts.Close()
_, out, err := sub.chatSend(context.Background(), nil, ChatSendInput{
SessionID: "sess-42",
Message: "hello",
})
if err != nil {
t.Fatalf("chatSend failed: %v", err)
}
if !out.Sent {
t.Error("expected Sent=true")
}
if out.SessionID != "sess-42" {
t.Errorf("expected sessionId 'sess-42', got %q", out.SessionID)
}
if out.Timestamp.IsZero() {
t.Error("expected non-zero timestamp")
}
}
// TestChatHistory_Bad_NilBridge verifies chatHistory returns error without a bridge.
func TestChatHistory_Bad_NilBridge(t *testing.T) {
sub := newNilBridgeSubsystem()
_, _, err := sub.chatHistory(context.Background(), nil, ChatHistoryInput{
SessionID: "s1",
})
if err == nil {
t.Error("expected error when bridge is nil")
}
}
// TestChatHistory_Good_Connected verifies chatHistory succeeds and returns empty messages.
func TestChatHistory_Good_Connected(t *testing.T) {
sub, cancel, ts := newConnectedSubsystem(t)
defer cancel()
defer ts.Close()
_, out, err := sub.chatHistory(context.Background(), nil, ChatHistoryInput{
SessionID: "sess-1",
Limit: 50,
})
if err != nil {
t.Fatalf("chatHistory failed: %v", err)
}
if out.SessionID != "sess-1" {
t.Errorf("expected sessionId 'sess-1', got %q", out.SessionID)
}
if out.Messages == nil {
t.Error("expected non-nil messages slice")
}
if len(out.Messages) != 0 {
t.Errorf("expected 0 messages (stub), got %d", len(out.Messages))
}
}
// TestSessionList_Bad_NilBridge verifies sessionList returns error without a bridge.
func TestSessionList_Bad_NilBridge(t *testing.T) {
sub := newNilBridgeSubsystem()
_, _, err := sub.sessionList(context.Background(), nil, SessionListInput{})
if err == nil {
t.Error("expected error when bridge is nil")
}
}
// TestSessionList_Good_Connected verifies sessionList returns empty sessions.
func TestSessionList_Good_Connected(t *testing.T) {
sub, cancel, ts := newConnectedSubsystem(t)
defer cancel()
defer ts.Close()
_, out, err := sub.sessionList(context.Background(), nil, SessionListInput{})
if err != nil {
t.Fatalf("sessionList failed: %v", err)
}
if out.Sessions == nil {
t.Error("expected non-nil sessions slice")
}
if len(out.Sessions) != 0 {
t.Errorf("expected 0 sessions (stub), got %d", len(out.Sessions))
}
}
// TestSessionCreate_Bad_NilBridge verifies sessionCreate returns error without a bridge.
func TestSessionCreate_Bad_NilBridge(t *testing.T) {
sub := newNilBridgeSubsystem()
_, _, err := sub.sessionCreate(context.Background(), nil, SessionCreateInput{
Name: "test",
})
if err == nil {
t.Error("expected error when bridge is nil")
}
}
// TestSessionCreate_Good_Connected verifies sessionCreate returns a session stub.
func TestSessionCreate_Good_Connected(t *testing.T) {
sub, cancel, ts := newConnectedSubsystem(t)
defer cancel()
defer ts.Close()
_, out, err := sub.sessionCreate(context.Background(), nil, SessionCreateInput{
Name: "my-session",
})
if err != nil {
t.Fatalf("sessionCreate failed: %v", err)
}
if out.Session.Name != "my-session" {
t.Errorf("expected name 'my-session', got %q", out.Session.Name)
}
if out.Session.Status != "creating" {
t.Errorf("expected status 'creating', got %q", out.Session.Status)
}
if out.Session.CreatedAt.IsZero() {
t.Error("expected non-zero CreatedAt")
}
}
// TestPlanStatus_Bad_NilBridge verifies planStatus returns error without a bridge.
func TestPlanStatus_Bad_NilBridge(t *testing.T) {
sub := newNilBridgeSubsystem()
_, _, err := sub.planStatus(context.Background(), nil, PlanStatusInput{
SessionID: "s1",
})
if err == nil {
t.Error("expected error when bridge is nil")
}
}
// TestPlanStatus_Good_Connected verifies planStatus returns a stub status.
func TestPlanStatus_Good_Connected(t *testing.T) {
sub, cancel, ts := newConnectedSubsystem(t)
defer cancel()
defer ts.Close()
_, out, err := sub.planStatus(context.Background(), nil, PlanStatusInput{
SessionID: "sess-7",
})
if err != nil {
t.Fatalf("planStatus failed: %v", err)
}
if out.SessionID != "sess-7" {
t.Errorf("expected sessionId 'sess-7', got %q", out.SessionID)
}
if out.Status != "unknown" {
t.Errorf("expected status 'unknown', got %q", out.Status)
}
if out.Steps == nil {
t.Error("expected non-nil steps slice")
}
}
// --- 4.3: Build tool tests ---
// TestBuildStatus_Bad_NilBridge verifies buildStatus returns error without a bridge.
func TestBuildStatus_Bad_NilBridge(t *testing.T) {
sub := newNilBridgeSubsystem()
_, _, err := sub.buildStatus(context.Background(), nil, BuildStatusInput{
BuildID: "b1",
})
if err == nil {
t.Error("expected error when bridge is nil")
}
}
// TestBuildStatus_Good_Connected verifies buildStatus returns a stub.
func TestBuildStatus_Good_Connected(t *testing.T) {
sub, cancel, ts := newConnectedSubsystem(t)
defer cancel()
defer ts.Close()
_, out, err := sub.buildStatus(context.Background(), nil, BuildStatusInput{
BuildID: "build-99",
})
if err != nil {
t.Fatalf("buildStatus failed: %v", err)
}
if out.Build.ID != "build-99" {
t.Errorf("expected build ID 'build-99', got %q", out.Build.ID)
}
if out.Build.Status != "unknown" {
t.Errorf("expected status 'unknown', got %q", out.Build.Status)
}
}
// TestBuildList_Bad_NilBridge verifies buildList returns error without a bridge.
func TestBuildList_Bad_NilBridge(t *testing.T) {
sub := newNilBridgeSubsystem()
_, _, err := sub.buildList(context.Background(), nil, BuildListInput{
Repo: "core-php",
Limit: 10,
})
if err == nil {
t.Error("expected error when bridge is nil")
}
}
// TestBuildList_Good_Connected verifies buildList returns an empty list.
func TestBuildList_Good_Connected(t *testing.T) {
sub, cancel, ts := newConnectedSubsystem(t)
defer cancel()
defer ts.Close()
_, out, err := sub.buildList(context.Background(), nil, BuildListInput{
Repo: "core-php",
Limit: 10,
})
if err != nil {
t.Fatalf("buildList failed: %v", err)
}
if out.Builds == nil {
t.Error("expected non-nil builds slice")
}
if len(out.Builds) != 0 {
t.Errorf("expected 0 builds (stub), got %d", len(out.Builds))
}
}
// TestBuildLogs_Bad_NilBridge verifies buildLogs returns error without a bridge.
func TestBuildLogs_Bad_NilBridge(t *testing.T) {
sub := newNilBridgeSubsystem()
_, _, err := sub.buildLogs(context.Background(), nil, BuildLogsInput{
BuildID: "b1",
Tail: 100,
})
if err == nil {
t.Error("expected error when bridge is nil")
}
}
// TestBuildLogs_Good_Connected verifies buildLogs returns empty lines.
func TestBuildLogs_Good_Connected(t *testing.T) {
sub, cancel, ts := newConnectedSubsystem(t)
defer cancel()
defer ts.Close()
_, out, err := sub.buildLogs(context.Background(), nil, BuildLogsInput{
BuildID: "build-55",
Tail: 50,
})
if err != nil {
t.Fatalf("buildLogs failed: %v", err)
}
if out.BuildID != "build-55" {
t.Errorf("expected buildId 'build-55', got %q", out.BuildID)
}
if out.Lines == nil {
t.Error("expected non-nil lines slice")
}
if len(out.Lines) != 0 {
t.Errorf("expected 0 lines (stub), got %d", len(out.Lines))
}
}
// --- 4.3: Dashboard tool tests ---
// TestDashboardOverview_Good_NilBridge verifies dashboardOverview works without bridge
// (it does not return error — it reports BridgeOnline=false).
func TestDashboardOverview_Good_NilBridge(t *testing.T) {
sub := newNilBridgeSubsystem()
_, out, err := sub.dashboardOverview(context.Background(), nil, DashboardOverviewInput{})
if err != nil {
t.Fatalf("dashboardOverview failed: %v", err)
}
if out.Overview.BridgeOnline {
t.Error("expected BridgeOnline=false when bridge is nil")
}
}
// TestDashboardOverview_Good_Connected verifies dashboardOverview reports bridge online.
func TestDashboardOverview_Good_Connected(t *testing.T) {
sub, cancel, ts := newConnectedSubsystem(t)
defer cancel()
defer ts.Close()
_, out, err := sub.dashboardOverview(context.Background(), nil, DashboardOverviewInput{})
if err != nil {
t.Fatalf("dashboardOverview failed: %v", err)
}
if !out.Overview.BridgeOnline {
t.Error("expected BridgeOnline=true when bridge is connected")
}
}
// TestDashboardActivity_Bad_NilBridge verifies dashboardActivity returns error without bridge.
func TestDashboardActivity_Bad_NilBridge(t *testing.T) {
sub := newNilBridgeSubsystem()
_, _, err := sub.dashboardActivity(context.Background(), nil, DashboardActivityInput{
Limit: 10,
})
if err == nil {
t.Error("expected error when bridge is nil")
}
}
// TestDashboardActivity_Good_Connected verifies dashboardActivity returns empty events.
func TestDashboardActivity_Good_Connected(t *testing.T) {
sub, cancel, ts := newConnectedSubsystem(t)
defer cancel()
defer ts.Close()
_, out, err := sub.dashboardActivity(context.Background(), nil, DashboardActivityInput{
Limit: 20,
})
if err != nil {
t.Fatalf("dashboardActivity failed: %v", err)
}
if out.Events == nil {
t.Error("expected non-nil events slice")
}
if len(out.Events) != 0 {
t.Errorf("expected 0 events (stub), got %d", len(out.Events))
}
}
// TestDashboardMetrics_Bad_NilBridge verifies dashboardMetrics returns error without bridge.
func TestDashboardMetrics_Bad_NilBridge(t *testing.T) {
sub := newNilBridgeSubsystem()
_, _, err := sub.dashboardMetrics(context.Background(), nil, DashboardMetricsInput{
Period: "1h",
})
if err == nil {
t.Error("expected error when bridge is nil")
}
}
// TestDashboardMetrics_Good_Connected verifies dashboardMetrics returns empty metrics.
func TestDashboardMetrics_Good_Connected(t *testing.T) {
sub, cancel, ts := newConnectedSubsystem(t)
defer cancel()
defer ts.Close()
_, out, err := sub.dashboardMetrics(context.Background(), nil, DashboardMetricsInput{
Period: "7d",
})
if err != nil {
t.Fatalf("dashboardMetrics failed: %v", err)
}
if out.Period != "7d" {
t.Errorf("expected period '7d', got %q", out.Period)
}
}
// TestDashboardMetrics_Good_DefaultPeriod verifies the default period is "24h".
func TestDashboardMetrics_Good_DefaultPeriod(t *testing.T) {
sub, cancel, ts := newConnectedSubsystem(t)
defer cancel()
defer ts.Close()
_, out, err := sub.dashboardMetrics(context.Background(), nil, DashboardMetricsInput{})
if err != nil {
t.Fatalf("dashboardMetrics failed: %v", err)
}
if out.Period != "24h" {
t.Errorf("expected default period '24h', got %q", out.Period)
}
}
// --- Struct serialisation round-trip tests ---
// TestChatSendInput_Good_RoundTrip verifies JSON serialisation of ChatSendInput.
func TestChatSendInput_Good_RoundTrip(t *testing.T) {
in := ChatSendInput{SessionID: "s1", Message: "hello"}
data, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var out ChatSendInput
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if out != in {
t.Errorf("round-trip mismatch: %+v != %+v", out, in)
}
}
// TestChatSendOutput_Good_RoundTrip verifies JSON serialisation of ChatSendOutput.
func TestChatSendOutput_Good_RoundTrip(t *testing.T) {
in := ChatSendOutput{Sent: true, SessionID: "s1", Timestamp: time.Now().Truncate(time.Second)}
data, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var out ChatSendOutput
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if out.Sent != in.Sent || out.SessionID != in.SessionID {
t.Errorf("round-trip mismatch: %+v != %+v", out, in)
}
}
// TestChatHistoryOutput_Good_RoundTrip verifies ChatHistoryOutput JSON round-trip.
func TestChatHistoryOutput_Good_RoundTrip(t *testing.T) {
in := ChatHistoryOutput{
SessionID: "s1",
Messages: []ChatMessage{
{Role: "user", Content: "hello", Timestamp: time.Now().Truncate(time.Second)},
{Role: "assistant", Content: "hi", Timestamp: time.Now().Truncate(time.Second)},
},
}
data, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var out ChatHistoryOutput
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if out.SessionID != in.SessionID {
t.Errorf("sessionId mismatch: %q != %q", out.SessionID, in.SessionID)
}
if len(out.Messages) != 2 {
t.Errorf("expected 2 messages, got %d", len(out.Messages))
}
}
// TestSessionListOutput_Good_RoundTrip verifies SessionListOutput JSON round-trip.
func TestSessionListOutput_Good_RoundTrip(t *testing.T) {
in := SessionListOutput{
Sessions: []Session{
{ID: "s1", Name: "test", Status: "active", CreatedAt: time.Now().Truncate(time.Second)},
},
}
data, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var out SessionListOutput
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if len(out.Sessions) != 1 || out.Sessions[0].ID != "s1" {
t.Errorf("round-trip mismatch: %+v", out)
}
}
// TestPlanStatusOutput_Good_RoundTrip verifies PlanStatusOutput JSON round-trip.
func TestPlanStatusOutput_Good_RoundTrip(t *testing.T) {
in := PlanStatusOutput{
SessionID: "s1",
Status: "running",
Steps: []PlanStep{{Name: "step1", Status: "done"}, {Name: "step2", Status: "pending"}},
}
data, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var out PlanStatusOutput
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if out.SessionID != "s1" || len(out.Steps) != 2 {
t.Errorf("round-trip mismatch: %+v", out)
}
}
// TestBuildStatusOutput_Good_RoundTrip verifies BuildStatusOutput JSON round-trip.
func TestBuildStatusOutput_Good_RoundTrip(t *testing.T) {
in := BuildStatusOutput{
Build: BuildInfo{
ID: "b1",
Repo: "core-php",
Branch: "main",
Status: "success",
Duration: "2m30s",
StartedAt: time.Now().Truncate(time.Second),
},
}
data, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var out BuildStatusOutput
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if out.Build.ID != "b1" || out.Build.Status != "success" {
t.Errorf("round-trip mismatch: %+v", out)
}
}
// TestBuildListOutput_Good_RoundTrip verifies BuildListOutput JSON round-trip.
func TestBuildListOutput_Good_RoundTrip(t *testing.T) {
in := BuildListOutput{
Builds: []BuildInfo{
{ID: "b1", Repo: "core-php", Branch: "main", Status: "success"},
{ID: "b2", Repo: "core-admin", Branch: "dev", Status: "failed"},
},
}
data, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var out BuildListOutput
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if len(out.Builds) != 2 {
t.Errorf("expected 2 builds, got %d", len(out.Builds))
}
}
// TestBuildLogsOutput_Good_RoundTrip verifies BuildLogsOutput JSON round-trip.
func TestBuildLogsOutput_Good_RoundTrip(t *testing.T) {
in := BuildLogsOutput{
BuildID: "b1",
Lines: []string{"line1", "line2", "line3"},
}
data, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var out BuildLogsOutput
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if out.BuildID != "b1" || len(out.Lines) != 3 {
t.Errorf("round-trip mismatch: %+v", out)
}
}
// TestDashboardOverviewOutput_Good_RoundTrip verifies DashboardOverviewOutput JSON round-trip.
func TestDashboardOverviewOutput_Good_RoundTrip(t *testing.T) {
in := DashboardOverviewOutput{
Overview: DashboardOverview{
Repos: 18,
Services: 5,
ActiveSessions: 3,
RecentBuilds: 12,
BridgeOnline: true,
},
}
data, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var out DashboardOverviewOutput
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if out.Overview.Repos != 18 || !out.Overview.BridgeOnline {
t.Errorf("round-trip mismatch: %+v", out)
}
}
// TestDashboardActivityOutput_Good_RoundTrip verifies DashboardActivityOutput JSON round-trip.
func TestDashboardActivityOutput_Good_RoundTrip(t *testing.T) {
in := DashboardActivityOutput{
Events: []ActivityEvent{
{Type: "deploy", Message: "deployed v1.2", Timestamp: time.Now().Truncate(time.Second)},
},
}
data, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var out DashboardActivityOutput
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if len(out.Events) != 1 || out.Events[0].Type != "deploy" {
t.Errorf("round-trip mismatch: %+v", out)
}
}
// TestDashboardMetricsOutput_Good_RoundTrip verifies DashboardMetricsOutput JSON round-trip.
func TestDashboardMetricsOutput_Good_RoundTrip(t *testing.T) {
in := DashboardMetricsOutput{
Period: "24h",
Metrics: DashboardMetrics{
BuildsTotal: 100,
BuildsSuccess: 90,
BuildsFailed: 10,
AvgBuildTime: "3m",
AgentSessions: 5,
MessagesTotal: 500,
SuccessRate: 0.9,
},
}
data, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var out DashboardMetricsOutput
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if out.Period != "24h" || out.Metrics.BuildsTotal != 100 || out.Metrics.SuccessRate != 0.9 {
t.Errorf("round-trip mismatch: %+v", out)
}
}
// TestBridgeMessage_Good_RoundTrip verifies BridgeMessage JSON round-trip.
func TestBridgeMessage_Good_RoundTrip(t *testing.T) {
in := BridgeMessage{
Type: "test",
Channel: "ch1",
SessionID: "s1",
Data: "payload",
Timestamp: time.Now().Truncate(time.Second),
}
data, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var out BridgeMessage
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if out.Type != "test" || out.Channel != "ch1" || out.SessionID != "s1" {
t.Errorf("round-trip mismatch: %+v", out)
}
}
// --- Subsystem integration tests ---
// TestSubsystem_Good_RegisterTools verifies RegisterTools does not panic.
func TestSubsystem_Good_RegisterTools(t *testing.T) {
// RegisterTools requires a real mcp.Server which is complex to construct
// in isolation. This test verifies the Subsystem can be created and
// the Bridge/Shutdown path works end-to-end.
sub := New(nil)
if sub.Bridge() != nil {
t.Error("expected nil bridge with nil hub")
}
if err := sub.Shutdown(context.Background()); err != nil {
t.Errorf("Shutdown failed: %v", err)
}
}
// TestSubsystem_Good_StartBridgeNilHub verifies StartBridge is a no-op with nil hub.
func TestSubsystem_Good_StartBridgeNilHub(t *testing.T) {
sub := New(nil)
// Should not panic
sub.StartBridge(context.Background())
}
// TestSubsystem_Good_WithOptions verifies all config options apply correctly.
func TestSubsystem_Good_WithOptions(t *testing.T) {
hub := ws.NewHub()
sub := New(hub,
WithLaravelURL("ws://custom:1234/ws"),
WithWorkspaceRoot("/tmp/test"),
WithReconnectInterval(5*time.Second),
WithToken("secret-123"),
)
if sub.cfg.LaravelWSURL != "ws://custom:1234/ws" {
t.Errorf("expected custom URL, got %q", sub.cfg.LaravelWSURL)
}
if sub.cfg.WorkspaceRoot != "/tmp/test" {
t.Errorf("expected workspace '/tmp/test', got %q", sub.cfg.WorkspaceRoot)
}
if sub.cfg.ReconnectInterval != 5*time.Second {
t.Errorf("expected 5s reconnect interval, got %v", sub.cfg.ReconnectInterval)
}
if sub.cfg.Token != "secret-123" {
t.Errorf("expected token 'secret-123', got %q", sub.cfg.Token)
}
}
// --- Tool sends correct bridge message type ---
// TestChatSend_Good_BridgeMessageType verifies the bridge receives the correct message type.
func TestChatSend_Good_BridgeMessageType(t *testing.T) {
msgCh := make(chan BridgeMessage, 1)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := testUpgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
_, data, err := conn.ReadMessage()
if err != nil {
return
}
var msg BridgeMessage
json.Unmarshal(data, &msg)
msgCh <- msg
// Keep alive
for {
if _, _, err := conn.ReadMessage(); err != nil {
break
}
}
}))
defer ts.Close()
hub := ws.NewHub()
ctx := t.Context()
go hub.Run(ctx)
sub := New(hub, WithLaravelURL(wsURL(ts)), WithReconnectInterval(50*time.Millisecond))
sub.StartBridge(ctx)
waitConnected(t, sub.Bridge(), 2*time.Second)
sub.chatSend(ctx, nil, ChatSendInput{SessionID: "s1", Message: "test"})
select {
case received := <-msgCh:
if received.Type != "chat_send" {
t.Errorf("expected bridge message type 'chat_send', got %q", received.Type)
}
if received.Channel != "chat:s1" {
t.Errorf("expected channel 'chat:s1', got %q", received.Channel)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for bridge message")
}
}

121
pkg/mcp/integration_test.go Normal file
View file

@ -0,0 +1,121 @@
package mcp
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
func TestIntegration_FileTools(t *testing.T) {
tmpDir := t.TempDir()
s, err := New(WithWorkspaceRoot(tmpDir))
assert.NoError(t, err)
ctx := context.Background()
// 1. Test file_write
writeInput := WriteFileInput{
Path: "test.txt",
Content: "hello world",
}
_, writeOutput, err := s.writeFile(ctx, nil, writeInput)
assert.NoError(t, err)
assert.True(t, writeOutput.Success)
assert.Equal(t, "test.txt", writeOutput.Path)
// Verify on disk
content, _ := os.ReadFile(filepath.Join(tmpDir, "test.txt"))
assert.Equal(t, "hello world", string(content))
// 2. Test file_read
readInput := ReadFileInput{
Path: "test.txt",
}
_, readOutput, err := s.readFile(ctx, nil, readInput)
assert.NoError(t, err)
assert.Equal(t, "hello world", readOutput.Content)
assert.Equal(t, "plaintext", readOutput.Language)
// 3. Test file_edit (replace_all=false)
editInput := EditDiffInput{
Path: "test.txt",
OldString: "world",
NewString: "mcp",
}
_, editOutput, err := s.editDiff(ctx, nil, editInput)
assert.NoError(t, err)
assert.True(t, editOutput.Success)
assert.Equal(t, 1, editOutput.Replacements)
// Verify change
_, readOutput, _ = s.readFile(ctx, nil, readInput)
assert.Equal(t, "hello mcp", readOutput.Content)
// 4. Test file_edit (replace_all=true)
_ = s.medium.Write("multi.txt", "abc abc abc")
editInputMulti := EditDiffInput{
Path: "multi.txt",
OldString: "abc",
NewString: "xyz",
ReplaceAll: true,
}
_, editOutput, err = s.editDiff(ctx, nil, editInputMulti)
assert.NoError(t, err)
assert.Equal(t, 3, editOutput.Replacements)
content, _ = os.ReadFile(filepath.Join(tmpDir, "multi.txt"))
assert.Equal(t, "xyz xyz xyz", string(content))
// 5. Test dir_list
_ = s.medium.EnsureDir("subdir")
_ = s.medium.Write("subdir/file1.txt", "content1")
listInput := ListDirectoryInput{
Path: "subdir",
}
_, listOutput, err := s.listDirectory(ctx, nil, listInput)
assert.NoError(t, err)
assert.Len(t, listOutput.Entries, 1)
assert.Equal(t, "file1.txt", listOutput.Entries[0].Name)
assert.False(t, listOutput.Entries[0].IsDir)
}
func TestIntegration_ErrorPaths(t *testing.T) {
tmpDir := t.TempDir()
s, err := New(WithWorkspaceRoot(tmpDir))
assert.NoError(t, err)
ctx := context.Background()
// Read nonexistent file
_, _, err = s.readFile(ctx, nil, ReadFileInput{Path: "nonexistent.txt"})
assert.Error(t, err)
// Edit nonexistent file
_, _, err = s.editDiff(ctx, nil, EditDiffInput{
Path: "nonexistent.txt",
OldString: "foo",
NewString: "bar",
})
assert.Error(t, err)
// Edit with empty old_string
_, _, err = s.editDiff(ctx, nil, EditDiffInput{
Path: "test.txt",
OldString: "",
NewString: "bar",
})
assert.Error(t, err)
// Edit with old_string not found
_ = s.medium.Write("test.txt", "hello")
_, _, err = s.editDiff(ctx, nil, EditDiffInput{
Path: "test.txt",
OldString: "missing",
NewString: "bar",
})
assert.Error(t, err)
}

40
pkg/mcp/iter_test.go Normal file
View file

@ -0,0 +1,40 @@
// SPDX-License-Identifier: EUPL-1.2
package mcp
import (
"slices"
"testing"
)
func TestService_Iterators(t *testing.T) {
svc, err := New(WithWorkspaceRoot(t.TempDir()))
if err != nil {
t.Fatal(err)
}
// Test ToolsSeq
tools := slices.Collect(svc.ToolsSeq())
if len(tools) == 0 {
t.Error("expected non-empty ToolsSeq")
}
if len(tools) != len(svc.Tools()) {
t.Errorf("ToolsSeq length %d != Tools() length %d", len(tools), len(svc.Tools()))
}
// Test SubsystemsSeq
subsystems := slices.Collect(svc.SubsystemsSeq())
if len(subsystems) != len(svc.Subsystems()) {
t.Errorf("SubsystemsSeq length %d != Subsystems() length %d", len(subsystems), len(svc.Subsystems()))
}
}
func TestRegistry_SplitTagSeq(t *testing.T) {
tag := "name,omitempty,json"
parts := slices.Collect(splitTagSeq(tag))
expected := []string{"name", "omitempty", "json"}
if !slices.Equal(parts, expected) {
t.Errorf("expected %v, got %v", expected, parts)
}
}

580
pkg/mcp/mcp.go Normal file
View file

@ -0,0 +1,580 @@
// SPDX-License-Identifier: EUPL-1.2
// Package mcp provides a lightweight MCP (Model Context Protocol) server for CLI use.
// For full GUI integration (display, webview, process management), see core-gui/pkg/mcp.
package mcp
import (
"context"
"errors"
"fmt"
"iter"
"net/http"
"os"
"path/filepath"
"slices"
"strings"
"forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go-process"
"forge.lthn.ai/core/go-ws"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// Service provides a lightweight MCP server with file operations only.
// For full GUI features, use the core-gui package.
type Service struct {
server *mcp.Server
workspaceRoot string // Root directory for file operations (empty = unrestricted)
medium io.Medium // Filesystem medium for sandboxed operations
subsystems []Subsystem // Additional subsystems registered via WithSubsystem
logger *log.Logger // Logger for tool execution auditing
processService *process.Service // Process management service (optional)
wsHub *ws.Hub // WebSocket hub for real-time streaming (optional)
wsServer *http.Server // WebSocket HTTP server (optional)
wsAddr string // WebSocket server address
tools []ToolRecord // Parallel tool registry for REST bridge
}
// Option configures a Service.
type Option func(*Service) error
// WithWorkspaceRoot restricts file operations to the given directory.
// All paths are validated to be within this directory.
// An empty string disables the restriction (not recommended).
func WithWorkspaceRoot(root string) Option {
return func(s *Service) error {
if root == "" {
// Explicitly disable restriction - use unsandboxed global
s.workspaceRoot = ""
s.medium = io.Local
return nil
}
// Create sandboxed medium for this workspace
abs, err := filepath.Abs(root)
if err != nil {
return fmt.Errorf("invalid workspace root: %w", err)
}
m, err := io.NewSandboxed(abs)
if err != nil {
return fmt.Errorf("failed to create workspace medium: %w", err)
}
s.workspaceRoot = abs
s.medium = m
return nil
}
}
// New creates a new MCP service with file operations.
// By default, restricts file access to the current working directory.
// Use WithWorkspaceRoot("") to disable restrictions (not recommended).
// Returns an error if initialization fails.
func New(opts ...Option) (*Service, error) {
impl := &mcp.Implementation{
Name: "core-cli",
Version: "0.1.0",
}
server := mcp.NewServer(impl, nil)
s := &Service{
server: server,
logger: log.Default(),
}
// Default to current working directory with sandboxed medium
cwd, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("failed to get working directory: %w", err)
}
s.workspaceRoot = cwd
m, err := io.NewSandboxed(cwd)
if err != nil {
return nil, fmt.Errorf("failed to create sandboxed medium: %w", err)
}
s.medium = m
// Apply options
for _, opt := range opts {
if err := opt(s); err != nil {
return nil, fmt.Errorf("failed to apply option: %w", err)
}
}
s.registerTools(s.server)
// Register subsystem tools.
for _, sub := range s.subsystems {
sub.RegisterTools(s.server)
}
return s, nil
}
// Subsystems returns the registered subsystems.
func (s *Service) Subsystems() []Subsystem {
return s.subsystems
}
// SubsystemsSeq returns an iterator over the registered subsystems.
func (s *Service) SubsystemsSeq() iter.Seq[Subsystem] {
return slices.Values(s.subsystems)
}
// Tools returns all recorded tool metadata.
func (s *Service) Tools() []ToolRecord {
return s.tools
}
// ToolsSeq returns an iterator over all recorded tool metadata.
func (s *Service) ToolsSeq() iter.Seq[ToolRecord] {
return slices.Values(s.tools)
}
// Shutdown gracefully shuts down all subsystems that support it.
func (s *Service) Shutdown(ctx context.Context) error {
for _, sub := range s.subsystems {
if sh, ok := sub.(SubsystemWithShutdown); ok {
if err := sh.Shutdown(ctx); err != nil {
return fmt.Errorf("shutdown %s: %w", sub.Name(), err)
}
}
}
return nil
}
// WithProcessService configures the process management service.
func WithProcessService(ps *process.Service) Option {
return func(s *Service) error {
s.processService = ps
return nil
}
}
// WithWSHub configures the WebSocket hub for real-time streaming.
func WithWSHub(hub *ws.Hub) Option {
return func(s *Service) error {
s.wsHub = hub
return nil
}
}
// WSHub returns the WebSocket hub.
func (s *Service) WSHub() *ws.Hub {
return s.wsHub
}
// ProcessService returns the process service.
func (s *Service) ProcessService() *process.Service {
return s.processService
}
// registerTools adds file operation tools to the MCP server.
func (s *Service) registerTools(server *mcp.Server) {
// File operations
addToolRecorded(s, server, "files", &mcp.Tool{
Name: "file_read",
Description: "Read the contents of a file",
}, s.readFile)
addToolRecorded(s, server, "files", &mcp.Tool{
Name: "file_write",
Description: "Write content to a file",
}, s.writeFile)
addToolRecorded(s, server, "files", &mcp.Tool{
Name: "file_delete",
Description: "Delete a file or empty directory",
}, s.deleteFile)
addToolRecorded(s, server, "files", &mcp.Tool{
Name: "file_rename",
Description: "Rename or move a file",
}, s.renameFile)
addToolRecorded(s, server, "files", &mcp.Tool{
Name: "file_exists",
Description: "Check if a file or directory exists",
}, s.fileExists)
addToolRecorded(s, server, "files", &mcp.Tool{
Name: "file_edit",
Description: "Edit a file by replacing old_string with new_string. Use replace_all=true to replace all occurrences.",
}, s.editDiff)
// Directory operations
addToolRecorded(s, server, "files", &mcp.Tool{
Name: "dir_list",
Description: "List contents of a directory",
}, s.listDirectory)
addToolRecorded(s, server, "files", &mcp.Tool{
Name: "dir_create",
Description: "Create a new directory",
}, s.createDirectory)
// Language detection
addToolRecorded(s, server, "language", &mcp.Tool{
Name: "lang_detect",
Description: "Detect the programming language of a file",
}, s.detectLanguage)
addToolRecorded(s, server, "language", &mcp.Tool{
Name: "lang_list",
Description: "Get list of supported programming languages",
}, s.getSupportedLanguages)
}
// Tool input/output types for MCP file operations.
// ReadFileInput contains parameters for reading a file.
type ReadFileInput struct {
Path string `json:"path"`
}
// ReadFileOutput contains the result of reading a file.
type ReadFileOutput struct {
Content string `json:"content"`
Language string `json:"language"`
Path string `json:"path"`
}
// WriteFileInput contains parameters for writing a file.
type WriteFileInput struct {
Path string `json:"path"`
Content string `json:"content"`
}
// WriteFileOutput contains the result of writing a file.
type WriteFileOutput struct {
Success bool `json:"success"`
Path string `json:"path"`
}
// ListDirectoryInput contains parameters for listing a directory.
type ListDirectoryInput struct {
Path string `json:"path"`
}
// ListDirectoryOutput contains the result of listing a directory.
type ListDirectoryOutput struct {
Entries []DirectoryEntry `json:"entries"`
Path string `json:"path"`
}
// DirectoryEntry represents a single entry in a directory listing.
type DirectoryEntry struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"isDir"`
Size int64 `json:"size"`
}
// CreateDirectoryInput contains parameters for creating a directory.
type CreateDirectoryInput struct {
Path string `json:"path"`
}
// CreateDirectoryOutput contains the result of creating a directory.
type CreateDirectoryOutput struct {
Success bool `json:"success"`
Path string `json:"path"`
}
// DeleteFileInput contains parameters for deleting a file.
type DeleteFileInput struct {
Path string `json:"path"`
}
// DeleteFileOutput contains the result of deleting a file.
type DeleteFileOutput struct {
Success bool `json:"success"`
Path string `json:"path"`
}
// RenameFileInput contains parameters for renaming a file.
type RenameFileInput struct {
OldPath string `json:"oldPath"`
NewPath string `json:"newPath"`
}
// RenameFileOutput contains the result of renaming a file.
type RenameFileOutput struct {
Success bool `json:"success"`
OldPath string `json:"oldPath"`
NewPath string `json:"newPath"`
}
// FileExistsInput contains parameters for checking file existence.
type FileExistsInput struct {
Path string `json:"path"`
}
// FileExistsOutput contains the result of checking file existence.
type FileExistsOutput struct {
Exists bool `json:"exists"`
IsDir bool `json:"isDir"`
Path string `json:"path"`
}
// DetectLanguageInput contains parameters for detecting file language.
type DetectLanguageInput struct {
Path string `json:"path"`
}
// DetectLanguageOutput contains the detected programming language.
type DetectLanguageOutput struct {
Language string `json:"language"`
Path string `json:"path"`
}
// GetSupportedLanguagesInput is an empty struct for the languages query.
type GetSupportedLanguagesInput struct{}
// GetSupportedLanguagesOutput contains the list of supported languages.
type GetSupportedLanguagesOutput struct {
Languages []LanguageInfo `json:"languages"`
}
// LanguageInfo describes a supported programming language.
type LanguageInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Extensions []string `json:"extensions"`
}
// EditDiffInput contains parameters for editing a file via diff.
type EditDiffInput struct {
Path string `json:"path"`
OldString string `json:"old_string"`
NewString string `json:"new_string"`
ReplaceAll bool `json:"replace_all,omitempty"`
}
// EditDiffOutput contains the result of a diff-based edit operation.
type EditDiffOutput struct {
Path string `json:"path"`
Success bool `json:"success"`
Replacements int `json:"replacements"`
}
// Tool handlers
func (s *Service) readFile(ctx context.Context, req *mcp.CallToolRequest, input ReadFileInput) (*mcp.CallToolResult, ReadFileOutput, error) {
content, err := s.medium.Read(input.Path)
if err != nil {
return nil, ReadFileOutput{}, fmt.Errorf("failed to read file: %w", err)
}
return nil, ReadFileOutput{
Content: content,
Language: detectLanguageFromPath(input.Path),
Path: input.Path,
}, nil
}
func (s *Service) writeFile(ctx context.Context, req *mcp.CallToolRequest, input WriteFileInput) (*mcp.CallToolResult, WriteFileOutput, error) {
// Medium.Write creates parent directories automatically
if err := s.medium.Write(input.Path, input.Content); err != nil {
return nil, WriteFileOutput{}, fmt.Errorf("failed to write file: %w", err)
}
return nil, WriteFileOutput{Success: true, Path: input.Path}, nil
}
func (s *Service) listDirectory(ctx context.Context, req *mcp.CallToolRequest, input ListDirectoryInput) (*mcp.CallToolResult, ListDirectoryOutput, error) {
entries, err := s.medium.List(input.Path)
if err != nil {
return nil, ListDirectoryOutput{}, fmt.Errorf("failed to list directory: %w", err)
}
result := make([]DirectoryEntry, 0, len(entries))
for _, e := range entries {
info, _ := e.Info()
var size int64
if info != nil {
size = info.Size()
}
result = append(result, DirectoryEntry{
Name: e.Name(),
Path: filepath.Join(input.Path, e.Name()), // Note: This might be relative path, client might expect absolute?
// Issue 103 says "Replace ... with local.Medium sandboxing".
// Previous code returned `filepath.Join(input.Path, e.Name())`.
// If input.Path is relative, this preserves it.
IsDir: e.IsDir(),
Size: size,
})
}
return nil, ListDirectoryOutput{Entries: result, Path: input.Path}, nil
}
func (s *Service) createDirectory(ctx context.Context, req *mcp.CallToolRequest, input CreateDirectoryInput) (*mcp.CallToolResult, CreateDirectoryOutput, error) {
if err := s.medium.EnsureDir(input.Path); err != nil {
return nil, CreateDirectoryOutput{}, fmt.Errorf("failed to create directory: %w", err)
}
return nil, CreateDirectoryOutput{Success: true, Path: input.Path}, nil
}
func (s *Service) deleteFile(ctx context.Context, req *mcp.CallToolRequest, input DeleteFileInput) (*mcp.CallToolResult, DeleteFileOutput, error) {
if err := s.medium.Delete(input.Path); err != nil {
return nil, DeleteFileOutput{}, fmt.Errorf("failed to delete file: %w", err)
}
return nil, DeleteFileOutput{Success: true, Path: input.Path}, nil
}
func (s *Service) renameFile(ctx context.Context, req *mcp.CallToolRequest, input RenameFileInput) (*mcp.CallToolResult, RenameFileOutput, error) {
if err := s.medium.Rename(input.OldPath, input.NewPath); err != nil {
return nil, RenameFileOutput{}, fmt.Errorf("failed to rename file: %w", err)
}
return nil, RenameFileOutput{Success: true, OldPath: input.OldPath, NewPath: input.NewPath}, nil
}
func (s *Service) fileExists(ctx context.Context, req *mcp.CallToolRequest, input FileExistsInput) (*mcp.CallToolResult, FileExistsOutput, error) {
exists := s.medium.IsFile(input.Path)
if exists {
return nil, FileExistsOutput{Exists: true, IsDir: false, Path: input.Path}, nil
}
// Check if it's a directory by attempting to list it
// List might fail if it's a file too (but we checked IsFile) or if doesn't exist.
_, err := s.medium.List(input.Path)
isDir := err == nil
// If List failed, it might mean it doesn't exist OR it's a special file or permissions.
// Assuming if List works, it's a directory.
// Refinement: If it doesn't exist, List returns error.
return nil, FileExistsOutput{Exists: isDir, IsDir: isDir, Path: input.Path}, nil
}
func (s *Service) detectLanguage(ctx context.Context, req *mcp.CallToolRequest, input DetectLanguageInput) (*mcp.CallToolResult, DetectLanguageOutput, error) {
lang := detectLanguageFromPath(input.Path)
return nil, DetectLanguageOutput{Language: lang, Path: input.Path}, nil
}
func (s *Service) getSupportedLanguages(ctx context.Context, req *mcp.CallToolRequest, input GetSupportedLanguagesInput) (*mcp.CallToolResult, GetSupportedLanguagesOutput, error) {
languages := []LanguageInfo{
{ID: "typescript", Name: "TypeScript", Extensions: []string{".ts", ".tsx"}},
{ID: "javascript", Name: "JavaScript", Extensions: []string{".js", ".jsx"}},
{ID: "go", Name: "Go", Extensions: []string{".go"}},
{ID: "python", Name: "Python", Extensions: []string{".py"}},
{ID: "rust", Name: "Rust", Extensions: []string{".rs"}},
{ID: "java", Name: "Java", Extensions: []string{".java"}},
{ID: "php", Name: "PHP", Extensions: []string{".php"}},
{ID: "ruby", Name: "Ruby", Extensions: []string{".rb"}},
{ID: "html", Name: "HTML", Extensions: []string{".html", ".htm"}},
{ID: "css", Name: "CSS", Extensions: []string{".css"}},
{ID: "json", Name: "JSON", Extensions: []string{".json"}},
{ID: "yaml", Name: "YAML", Extensions: []string{".yaml", ".yml"}},
{ID: "markdown", Name: "Markdown", Extensions: []string{".md", ".markdown"}},
{ID: "sql", Name: "SQL", Extensions: []string{".sql"}},
{ID: "shell", Name: "Shell", Extensions: []string{".sh", ".bash"}},
}
return nil, GetSupportedLanguagesOutput{Languages: languages}, nil
}
func (s *Service) editDiff(ctx context.Context, req *mcp.CallToolRequest, input EditDiffInput) (*mcp.CallToolResult, EditDiffOutput, error) {
if input.OldString == "" {
return nil, EditDiffOutput{}, errors.New("old_string cannot be empty")
}
content, err := s.medium.Read(input.Path)
if err != nil {
return nil, EditDiffOutput{}, fmt.Errorf("failed to read file: %w", err)
}
count := 0
if input.ReplaceAll {
count = strings.Count(content, input.OldString)
if count == 0 {
return nil, EditDiffOutput{}, errors.New("old_string not found in file")
}
content = strings.ReplaceAll(content, input.OldString, input.NewString)
} else {
if !strings.Contains(content, input.OldString) {
return nil, EditDiffOutput{}, errors.New("old_string not found in file")
}
content = strings.Replace(content, input.OldString, input.NewString, 1)
count = 1
}
if err := s.medium.Write(input.Path, content); err != nil {
return nil, EditDiffOutput{}, fmt.Errorf("failed to write file: %w", err)
}
return nil, EditDiffOutput{
Path: input.Path,
Success: true,
Replacements: count,
}, nil
}
// detectLanguageFromPath maps file extensions to language IDs.
func detectLanguageFromPath(path string) string {
ext := filepath.Ext(path)
switch ext {
case ".ts", ".tsx":
return "typescript"
case ".js", ".jsx":
return "javascript"
case ".go":
return "go"
case ".py":
return "python"
case ".rs":
return "rust"
case ".rb":
return "ruby"
case ".java":
return "java"
case ".php":
return "php"
case ".c", ".h":
return "c"
case ".cpp", ".hpp", ".cc", ".cxx":
return "cpp"
case ".cs":
return "csharp"
case ".html", ".htm":
return "html"
case ".css":
return "css"
case ".scss":
return "scss"
case ".json":
return "json"
case ".yaml", ".yml":
return "yaml"
case ".xml":
return "xml"
case ".md", ".markdown":
return "markdown"
case ".sql":
return "sql"
case ".sh", ".bash":
return "shell"
case ".swift":
return "swift"
case ".kt", ".kts":
return "kotlin"
default:
if filepath.Base(path) == "Dockerfile" {
return "dockerfile"
}
return "plaintext"
}
}
// Run starts the MCP server.
// If MCP_ADDR is set, it starts a TCP server.
// Otherwise, it starts a Stdio server.
func (s *Service) Run(ctx context.Context) error {
addr := os.Getenv("MCP_ADDR")
if addr != "" {
return s.ServeTCP(ctx, addr)
}
return s.server.Run(ctx, &mcp.StdioTransport{})
}
// Server returns the underlying MCP server for advanced configuration.
func (s *Service) Server() *mcp.Server {
return s.server
}

180
pkg/mcp/mcp_test.go Normal file
View file

@ -0,0 +1,180 @@
package mcp
import (
"os"
"path/filepath"
"testing"
)
func TestNew_Good_DefaultWorkspace(t *testing.T) {
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
s, err := New()
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(WithWorkspaceRoot(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(WithWorkspaceRoot(""))
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 TestMedium_Good_ReadWrite(t *testing.T) {
tmpDir := t.TempDir()
s, err := New(WithWorkspaceRoot(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 TestMedium_Good_EnsureDir(t *testing.T) {
tmpDir := t.TempDir()
s, err := New(WithWorkspaceRoot(tmpDir))
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
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")
}
}
func TestMedium_Good_IsFile(t *testing.T) {
tmpDir := t.TempDir()
s, err := New(WithWorkspaceRoot(tmpDir))
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
// 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")
}
}
func TestSandboxing_Traversal_Sanitized(t *testing.T) {
tmpDir := t.TempDir()
s, err := New(WithWorkspaceRoot(tmpDir))
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(WithWorkspaceRoot(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")
}
}

149
pkg/mcp/registry.go Normal file
View file

@ -0,0 +1,149 @@
// SPDX-License-Identifier: EUPL-1.2
package mcp
import (
"context"
"encoding/json"
"iter"
"reflect"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// RESTHandler handles a tool call from a REST endpoint.
// It receives raw JSON input and returns the typed output or an error.
type RESTHandler func(ctx context.Context, body []byte) (any, error)
// ToolRecord captures metadata about a registered MCP tool.
type ToolRecord struct {
Name string // Tool name, e.g. "file_read"
Description string // Human-readable description
Group string // Subsystem group name, e.g. "files", "rag"
InputSchema map[string]any // JSON Schema from Go struct reflection
OutputSchema map[string]any // JSON Schema from Go struct reflection
RESTHandler RESTHandler // REST-callable handler created at registration time
}
// addToolRecorded registers a tool with the MCP server AND records its metadata.
// This is a generic function that captures the In/Out types for schema extraction.
// It also creates a RESTHandler closure that can unmarshal JSON to the correct
// input type and call the handler directly, enabling the MCP-to-REST bridge.
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)
restHandler := func(ctx context.Context, body []byte) (any, error) {
var input In
if len(body) > 0 {
if err := json.Unmarshal(body, &input); err != nil {
return nil, err
}
}
// nil: REST callers have no MCP request context.
// Tool handlers called via REST must not dereference CallToolRequest.
_, output, err := h(ctx, nil, input)
return output, err
}
s.tools = append(s.tools, ToolRecord{
Name: t.Name,
Description: t.Description,
Group: group,
InputSchema: structSchema(new(In)),
OutputSchema: structSchema(new(Out)),
RESTHandler: restHandler,
})
}
// structSchema builds a simple JSON Schema from a struct's json tags via reflection.
// Returns nil for non-struct types or empty structs.
func structSchema(v any) map[string]any {
t := reflect.TypeOf(v)
if t == nil {
return nil
}
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return nil
}
if t.NumField() == 0 {
return map[string]any{"type": "object", "properties": map[string]any{}}
}
properties := make(map[string]any)
required := make([]string, 0)
for f := range t.Fields() {
f := f
if !f.IsExported() {
continue
}
jsonTag := f.Tag.Get("json")
if jsonTag == "-" {
continue
}
name := f.Name
isOptional := false
if jsonTag != "" {
parts := splitTag(jsonTag)
name = parts[0]
for _, p := range parts[1:] {
if p == "omitempty" {
isOptional = true
}
}
}
prop := map[string]any{
"type": goTypeToJSONType(f.Type),
}
properties[name] = prop
if !isOptional {
required = append(required, name)
}
}
schema := map[string]any{
"type": "object",
"properties": properties,
}
if len(required) > 0 {
schema["required"] = required
}
return schema
}
// splitTag splits a struct tag value by commas.
func splitTag(tag string) []string {
return strings.Split(tag, ",")
}
// splitTagSeq returns an iterator over the tag parts.
func splitTagSeq(tag string) iter.Seq[string] {
return strings.SplitSeq(tag, ",")
}
// goTypeToJSONType maps Go types to JSON Schema types.
func goTypeToJSONType(t reflect.Type) string {
switch t.Kind() {
case reflect.String:
return "string"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return "integer"
case reflect.Float32, reflect.Float64:
return "number"
case reflect.Bool:
return "boolean"
case reflect.Slice, reflect.Array:
return "array"
case reflect.Map, reflect.Struct:
return "object"
default:
return "string"
}
}

150
pkg/mcp/registry_test.go Normal file
View file

@ -0,0 +1,150 @@
// SPDX-License-Identifier: EUPL-1.2
package mcp
import (
"testing"
)
func TestToolRegistry_Good_RecordsTools(t *testing.T) {
svc, err := New(WithWorkspaceRoot(t.TempDir()))
if err != nil {
t.Fatal(err)
}
tools := svc.Tools()
if len(tools) == 0 {
t.Fatal("expected non-empty tool registry")
}
found := false
for _, tr := range tools {
if tr.Name == "file_read" {
found = true
break
}
}
if !found {
t.Error("expected file_read in tool registry")
}
}
func TestToolRegistry_Good_SchemaExtraction(t *testing.T) {
svc, err := New(WithWorkspaceRoot(t.TempDir()))
if err != nil {
t.Fatal(err)
}
var record ToolRecord
for _, tr := range svc.Tools() {
if tr.Name == "file_read" {
record = tr
break
}
}
if record.Name == "" {
t.Fatal("file_read not found in registry")
}
if record.InputSchema == nil {
t.Fatal("expected non-nil InputSchema for file_read")
}
props, ok := record.InputSchema["properties"].(map[string]any)
if !ok {
t.Fatal("expected properties map in InputSchema")
}
if _, ok := props["path"]; !ok {
t.Error("expected 'path' property in file_read InputSchema")
}
}
func TestToolRegistry_Good_ToolCount(t *testing.T) {
svc, err := New(WithWorkspaceRoot(t.TempDir()))
if err != nil {
t.Fatal(err)
}
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
const expectedCount = 10
if len(tools) != expectedCount {
t.Errorf("expected %d tools, got %d", expectedCount, len(tools))
for _, tr := range tools {
t.Logf(" - %s (%s)", tr.Name, tr.Group)
}
}
}
func TestToolRegistry_Good_GroupAssignment(t *testing.T) {
svc, err := New(WithWorkspaceRoot(t.TempDir()))
if err != nil {
t.Fatal(err)
}
fileTools := []string{"file_read", "file_write", "file_delete", "file_rename", "file_exists", "file_edit", "dir_list", "dir_create"}
langTools := []string{"lang_detect", "lang_list"}
byName := make(map[string]ToolRecord)
for _, tr := range svc.Tools() {
byName[tr.Name] = tr
}
for _, name := range fileTools {
tr, ok := byName[name]
if !ok {
t.Errorf("tool %s not found in registry", name)
continue
}
if tr.Group != "files" {
t.Errorf("tool %s: expected group 'files', got %q", name, tr.Group)
}
}
for _, name := range langTools {
tr, ok := byName[name]
if !ok {
t.Errorf("tool %s not found in registry", name)
continue
}
if tr.Group != "language" {
t.Errorf("tool %s: expected group 'language', got %q", name, tr.Group)
}
}
}
func TestToolRegistry_Good_ToolRecordFields(t *testing.T) {
svc, err := New(WithWorkspaceRoot(t.TempDir()))
if err != nil {
t.Fatal(err)
}
var record ToolRecord
for _, tr := range svc.Tools() {
if tr.Name == "file_write" {
record = tr
break
}
}
if record.Name == "" {
t.Fatal("file_write not found in registry")
}
if record.Name != "file_write" {
t.Errorf("expected Name 'file_write', got %q", record.Name)
}
if record.Description == "" {
t.Error("expected non-empty Description")
}
if record.Group == "" {
t.Error("expected non-empty Group")
}
if record.InputSchema == nil {
t.Error("expected non-nil InputSchema")
}
if record.OutputSchema == nil {
t.Error("expected non-nil OutputSchema")
}
}

32
pkg/mcp/subsystem.go Normal file
View file

@ -0,0 +1,32 @@
package mcp
import (
"context"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// Subsystem registers additional MCP tools at startup.
// Implementations should be safe to call concurrently.
type Subsystem interface {
// Name returns a human-readable identifier for logging.
Name() string
// RegisterTools adds tools to the MCP server during initialisation.
RegisterTools(server *mcp.Server)
}
// SubsystemWithShutdown extends Subsystem with graceful cleanup.
type SubsystemWithShutdown interface {
Subsystem
Shutdown(ctx context.Context) error
}
// WithSubsystem registers a subsystem whose tools will be added
// after the built-in tools during New().
func WithSubsystem(sub Subsystem) Option {
return func(s *Service) error {
s.subsystems = append(s.subsystems, sub)
return nil
}
}

114
pkg/mcp/subsystem_test.go Normal file
View file

@ -0,0 +1,114 @@
package mcp
import (
"context"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// stubSubsystem is a minimal Subsystem for testing.
type stubSubsystem struct {
name string
toolsRegistered bool
}
func (s *stubSubsystem) Name() string { return s.name }
func (s *stubSubsystem) RegisterTools(server *mcp.Server) {
s.toolsRegistered = true
}
// shutdownSubsystem tracks Shutdown calls.
type shutdownSubsystem struct {
stubSubsystem
shutdownCalled bool
shutdownErr error
}
func (s *shutdownSubsystem) Shutdown(_ context.Context) error {
s.shutdownCalled = true
return s.shutdownErr
}
func TestWithSubsystem_Good_Registration(t *testing.T) {
sub := &stubSubsystem{name: "test-sub"}
svc, err := New(WithSubsystem(sub))
if err != nil {
t.Fatalf("New() failed: %v", err)
}
if len(svc.Subsystems()) != 1 {
t.Fatalf("expected 1 subsystem, got %d", len(svc.Subsystems()))
}
if svc.Subsystems()[0].Name() != "test-sub" {
t.Errorf("expected name 'test-sub', got %q", svc.Subsystems()[0].Name())
}
}
func TestWithSubsystem_Good_ToolsRegistered(t *testing.T) {
sub := &stubSubsystem{name: "tools-sub"}
_, err := New(WithSubsystem(sub))
if err != nil {
t.Fatalf("New() failed: %v", err)
}
if !sub.toolsRegistered {
t.Error("expected RegisterTools to have been called")
}
}
func TestWithSubsystem_Good_MultipleSubsystems(t *testing.T) {
sub1 := &stubSubsystem{name: "sub-1"}
sub2 := &stubSubsystem{name: "sub-2"}
svc, err := New(WithSubsystem(sub1), WithSubsystem(sub2))
if err != nil {
t.Fatalf("New() failed: %v", err)
}
if len(svc.Subsystems()) != 2 {
t.Fatalf("expected 2 subsystems, got %d", len(svc.Subsystems()))
}
if !sub1.toolsRegistered || !sub2.toolsRegistered {
t.Error("expected all subsystems to have RegisterTools called")
}
}
func TestSubsystemShutdown_Good(t *testing.T) {
sub := &shutdownSubsystem{stubSubsystem: stubSubsystem{name: "shutdown-sub"}}
svc, err := New(WithSubsystem(sub))
if err != nil {
t.Fatalf("New() failed: %v", err)
}
if err := svc.Shutdown(context.Background()); err != nil {
t.Fatalf("Shutdown() failed: %v", err)
}
if !sub.shutdownCalled {
t.Error("expected Shutdown to have been called")
}
}
func TestSubsystemShutdown_Bad_Error(t *testing.T) {
sub := &shutdownSubsystem{
stubSubsystem: stubSubsystem{name: "fail-sub"},
shutdownErr: context.DeadlineExceeded,
}
svc, err := New(WithSubsystem(sub))
if err != nil {
t.Fatalf("New() failed: %v", err)
}
err = svc.Shutdown(context.Background())
if err == nil {
t.Fatal("expected error from Shutdown")
}
}
func TestSubsystemShutdown_Good_NoShutdownInterface(t *testing.T) {
// A plain Subsystem (without Shutdown) should not cause errors.
sub := &stubSubsystem{name: "plain-sub"}
svc, err := New(WithSubsystem(sub))
if err != nil {
t.Fatalf("New() failed: %v", err)
}
if err := svc.Shutdown(context.Background()); err != nil {
t.Fatalf("Shutdown() should succeed for non-shutdown subsystem: %v", err)
}
}

213
pkg/mcp/tools_metrics.go Normal file
View file

@ -0,0 +1,213 @@
package mcp
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"forge.lthn.ai/core/go-ai/ai"
"forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// Default values for metrics operations.
const (
DefaultMetricsSince = "7d"
DefaultMetricsLimit = 10
)
// MetricsRecordInput contains parameters for recording a metrics event.
type MetricsRecordInput struct {
Type string `json:"type"` // Event type (required)
AgentID string `json:"agent_id,omitempty"` // Agent identifier
Repo string `json:"repo,omitempty"` // Repository name
Data map[string]any `json:"data,omitempty"` // Additional event data
}
// MetricsRecordOutput contains the result of recording a metrics event.
type MetricsRecordOutput struct {
Success bool `json:"success"`
Timestamp time.Time `json:"timestamp"`
}
// MetricsQueryInput contains parameters for querying metrics.
type MetricsQueryInput struct {
Since string `json:"since,omitempty"` // Time range like "7d", "24h", "30m" (default: "7d")
}
// MetricsQueryOutput contains the results of a metrics query.
type MetricsQueryOutput struct {
Total int `json:"total"`
ByType []MetricCount `json:"by_type"`
ByRepo []MetricCount `json:"by_repo"`
ByAgent []MetricCount `json:"by_agent"`
Events []MetricEventBrief `json:"events"` // Most recent 10 events
}
// MetricCount represents a count for a specific key.
type MetricCount struct {
Key string `json:"key"`
Count int `json:"count"`
}
// MetricEventBrief represents a brief summary of an event.
type MetricEventBrief struct {
Type string `json:"type"`
Timestamp time.Time `json:"timestamp"`
AgentID string `json:"agent_id,omitempty"`
Repo string `json:"repo,omitempty"`
}
// registerMetricsTools adds metrics tools to the MCP server.
func (s *Service) registerMetricsTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "metrics_record",
Description: "Record a metrics event for AI/security tracking. Events are stored in daily JSONL files.",
}, s.metricsRecord)
mcp.AddTool(server, &mcp.Tool{
Name: "metrics_query",
Description: "Query metrics events and get aggregated statistics by type, repo, and agent.",
}, s.metricsQuery)
}
// metricsRecord handles the metrics_record tool call.
func (s *Service) metricsRecord(ctx context.Context, req *mcp.CallToolRequest, input MetricsRecordInput) (*mcp.CallToolResult, MetricsRecordOutput, error) {
s.logger.Info("MCP tool execution", "tool", "metrics_record", "type", input.Type, "agent_id", input.AgentID, "repo", input.Repo, "user", log.Username())
// Validate input
if input.Type == "" {
return nil, MetricsRecordOutput{}, errors.New("type cannot be empty")
}
// Create the event
event := ai.Event{
Type: input.Type,
Timestamp: time.Now(),
AgentID: input.AgentID,
Repo: input.Repo,
Data: input.Data,
}
// Record the event
if err := ai.Record(event); err != nil {
log.Error("mcp: metrics record failed", "type", input.Type, "err", err)
return nil, MetricsRecordOutput{}, fmt.Errorf("failed to record metrics: %w", err)
}
return nil, MetricsRecordOutput{
Success: true,
Timestamp: event.Timestamp,
}, nil
}
// metricsQuery handles the metrics_query tool call.
func (s *Service) metricsQuery(ctx context.Context, req *mcp.CallToolRequest, input MetricsQueryInput) (*mcp.CallToolResult, MetricsQueryOutput, error) {
// Apply defaults
since := input.Since
if since == "" {
since = DefaultMetricsSince
}
s.logger.Info("MCP tool execution", "tool", "metrics_query", "since", since, "user", log.Username())
// Parse the duration
duration, err := parseDuration(since)
if err != nil {
return nil, MetricsQueryOutput{}, fmt.Errorf("invalid since value: %w", err)
}
sinceTime := time.Now().Add(-duration)
// Read events
events, err := ai.ReadEvents(sinceTime)
if err != nil {
log.Error("mcp: metrics query failed", "since", since, "err", err)
return nil, MetricsQueryOutput{}, fmt.Errorf("failed to read metrics: %w", err)
}
// Get summary
summary := ai.Summary(events)
// Build output
output := MetricsQueryOutput{
Total: summary["total"].(int),
ByType: convertMetricCounts(summary["by_type"]),
ByRepo: convertMetricCounts(summary["by_repo"]),
ByAgent: convertMetricCounts(summary["by_agent"]),
Events: make([]MetricEventBrief, 0, DefaultMetricsLimit),
}
// Get recent events (last 10, most recent first)
startIdx := max(len(events)-DefaultMetricsLimit, 0)
for i := len(events) - 1; i >= startIdx; i-- {
ev := events[i]
output.Events = append(output.Events, MetricEventBrief{
Type: ev.Type,
Timestamp: ev.Timestamp,
AgentID: ev.AgentID,
Repo: ev.Repo,
})
}
return nil, output, nil
}
// convertMetricCounts converts the summary map format to MetricCount slice.
func convertMetricCounts(data any) []MetricCount {
if data == nil {
return []MetricCount{}
}
items, ok := data.([]map[string]any)
if !ok {
return []MetricCount{}
}
result := make([]MetricCount, len(items))
for i, item := range items {
key, _ := item["key"].(string)
count, _ := item["count"].(int)
result[i] = MetricCount{Key: key, Count: count}
}
return result
}
// parseDuration parses a duration string like "7d", "24h", "30m".
func parseDuration(s string) (time.Duration, error) {
if s == "" {
return 0, errors.New("duration cannot be empty")
}
s = strings.TrimSpace(s)
if len(s) < 2 {
return 0, fmt.Errorf("invalid duration format: %q", s)
}
// Get the numeric part and unit
unit := s[len(s)-1]
numStr := s[:len(s)-1]
num, err := strconv.Atoi(numStr)
if err != nil {
return 0, fmt.Errorf("invalid duration number: %q", numStr)
}
if num <= 0 {
return 0, fmt.Errorf("duration must be positive: %d", num)
}
switch unit {
case 'd':
return time.Duration(num) * 24 * time.Hour, nil
case 'h':
return time.Duration(num) * time.Hour, nil
case 'm':
return time.Duration(num) * time.Minute, nil
default:
return 0, fmt.Errorf("invalid duration unit: %q (expected d, h, or m)", string(unit))
}
}

View file

@ -0,0 +1,207 @@
package mcp
import (
"testing"
"time"
)
// TestMetricsToolsRegistered_Good verifies that metrics tools are registered with the MCP server.
func TestMetricsToolsRegistered_Good(t *testing.T) {
// Create a new MCP service - this should register all tools including metrics
s, err := New()
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
// The server should have registered the metrics tools
// We verify by checking that the server and logger exist
if s.server == nil {
t.Fatal("Server should not be nil")
}
if s.logger == nil {
t.Error("Logger should not be nil")
}
}
// TestMetricsRecordInput_Good verifies the MetricsRecordInput struct has expected fields.
func TestMetricsRecordInput_Good(t *testing.T) {
input := MetricsRecordInput{
Type: "tool_call",
AgentID: "agent-123",
Repo: "host-uk/core",
Data: map[string]any{"tool": "file_read", "duration_ms": 150},
}
if input.Type != "tool_call" {
t.Errorf("Expected type 'tool_call', got %q", input.Type)
}
if input.AgentID != "agent-123" {
t.Errorf("Expected agent_id 'agent-123', got %q", input.AgentID)
}
if input.Repo != "host-uk/core" {
t.Errorf("Expected repo 'host-uk/core', got %q", input.Repo)
}
if input.Data["tool"] != "file_read" {
t.Errorf("Expected data[tool] 'file_read', got %v", input.Data["tool"])
}
}
// TestMetricsRecordOutput_Good verifies the MetricsRecordOutput struct has expected fields.
func TestMetricsRecordOutput_Good(t *testing.T) {
ts := time.Now()
output := MetricsRecordOutput{
Success: true,
Timestamp: ts,
}
if !output.Success {
t.Error("Expected success to be true")
}
if output.Timestamp != ts {
t.Errorf("Expected timestamp %v, got %v", ts, output.Timestamp)
}
}
// TestMetricsQueryInput_Good verifies the MetricsQueryInput struct has expected fields.
func TestMetricsQueryInput_Good(t *testing.T) {
input := MetricsQueryInput{
Since: "7d",
}
if input.Since != "7d" {
t.Errorf("Expected since '7d', got %q", input.Since)
}
}
// TestMetricsQueryInput_Defaults verifies default values are handled correctly.
func TestMetricsQueryInput_Defaults(t *testing.T) {
input := MetricsQueryInput{}
// Empty since should use default when processed
if input.Since != "" {
t.Errorf("Expected empty since before defaults, got %q", input.Since)
}
}
// TestMetricsQueryOutput_Good verifies the MetricsQueryOutput struct has expected fields.
func TestMetricsQueryOutput_Good(t *testing.T) {
output := MetricsQueryOutput{
Total: 100,
ByType: []MetricCount{
{Key: "tool_call", Count: 50},
{Key: "query", Count: 30},
},
ByRepo: []MetricCount{
{Key: "host-uk/core", Count: 40},
},
ByAgent: []MetricCount{
{Key: "agent-123", Count: 25},
},
Events: []MetricEventBrief{
{Type: "tool_call", Timestamp: time.Now(), AgentID: "agent-1", Repo: "host-uk/core"},
},
}
if output.Total != 100 {
t.Errorf("Expected total 100, got %d", output.Total)
}
if len(output.ByType) != 2 {
t.Errorf("Expected 2 ByType entries, got %d", len(output.ByType))
}
if output.ByType[0].Key != "tool_call" {
t.Errorf("Expected ByType[0].Key 'tool_call', got %q", output.ByType[0].Key)
}
if output.ByType[0].Count != 50 {
t.Errorf("Expected ByType[0].Count 50, got %d", output.ByType[0].Count)
}
if len(output.Events) != 1 {
t.Errorf("Expected 1 event, got %d", len(output.Events))
}
}
// TestMetricCount_Good verifies the MetricCount struct has expected fields.
func TestMetricCount_Good(t *testing.T) {
mc := MetricCount{
Key: "tool_call",
Count: 42,
}
if mc.Key != "tool_call" {
t.Errorf("Expected key 'tool_call', got %q", mc.Key)
}
if mc.Count != 42 {
t.Errorf("Expected count 42, got %d", mc.Count)
}
}
// TestMetricEventBrief_Good verifies the MetricEventBrief struct has expected fields.
func TestMetricEventBrief_Good(t *testing.T) {
ts := time.Now()
ev := MetricEventBrief{
Type: "tool_call",
Timestamp: ts,
AgentID: "agent-123",
Repo: "host-uk/core",
}
if ev.Type != "tool_call" {
t.Errorf("Expected type 'tool_call', got %q", ev.Type)
}
if ev.Timestamp != ts {
t.Errorf("Expected timestamp %v, got %v", ts, ev.Timestamp)
}
if ev.AgentID != "agent-123" {
t.Errorf("Expected agent_id 'agent-123', got %q", ev.AgentID)
}
if ev.Repo != "host-uk/core" {
t.Errorf("Expected repo 'host-uk/core', got %q", ev.Repo)
}
}
// TestParseDuration_Good verifies the parseDuration helper handles various formats.
func TestParseDuration_Good(t *testing.T) {
tests := []struct {
input string
expected time.Duration
}{
{"7d", 7 * 24 * time.Hour},
{"24h", 24 * time.Hour},
{"30m", 30 * time.Minute},
{"1d", 24 * time.Hour},
{"14d", 14 * 24 * time.Hour},
{"1h", time.Hour},
{"10m", 10 * time.Minute},
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
d, err := parseDuration(tc.input)
if err != nil {
t.Fatalf("parseDuration(%q) returned error: %v", tc.input, err)
}
if d != tc.expected {
t.Errorf("parseDuration(%q) = %v, want %v", tc.input, d, tc.expected)
}
})
}
}
// TestParseDuration_Bad verifies parseDuration returns errors for invalid input.
func TestParseDuration_Bad(t *testing.T) {
tests := []string{
"",
"abc",
"7x",
"-7d",
}
for _, input := range tests {
t.Run(input, func(t *testing.T) {
_, err := parseDuration(input)
if err == nil {
t.Errorf("parseDuration(%q) should return error", input)
}
})
}
}

290
pkg/mcp/tools_ml.go Normal file
View file

@ -0,0 +1,290 @@
package mcp
import (
"context"
"errors"
"fmt"
"strings"
"forge.lthn.ai/core/go-inference"
"forge.lthn.ai/core/go-ml"
"forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// MLSubsystem exposes ML inference and scoring tools via MCP.
type MLSubsystem struct {
service *ml.Service
logger *log.Logger
}
// NewMLSubsystem creates an MCP subsystem for ML tools.
func NewMLSubsystem(svc *ml.Service) *MLSubsystem {
return &MLSubsystem{
service: svc,
logger: log.Default(),
}
}
func (m *MLSubsystem) Name() string { return "ml" }
// RegisterTools adds ML tools to the MCP server.
func (m *MLSubsystem) RegisterTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "ml_generate",
Description: "Generate text via a configured ML inference backend.",
}, m.mlGenerate)
mcp.AddTool(server, &mcp.Tool{
Name: "ml_score",
Description: "Score a prompt/response pair using heuristic and LLM judge suites.",
}, m.mlScore)
mcp.AddTool(server, &mcp.Tool{
Name: "ml_probe",
Description: "Run capability probes against an inference backend.",
}, m.mlProbe)
mcp.AddTool(server, &mcp.Tool{
Name: "ml_status",
Description: "Show training and generation progress from InfluxDB.",
}, m.mlStatus)
mcp.AddTool(server, &mcp.Tool{
Name: "ml_backends",
Description: "List available inference backends and their status.",
}, m.mlBackends)
}
// --- Input/Output types ---
// MLGenerateInput contains parameters for text generation.
type MLGenerateInput struct {
Prompt string `json:"prompt"` // The prompt to generate from
Backend string `json:"backend,omitempty"` // Backend name (default: service default)
Model string `json:"model,omitempty"` // Model override
Temperature float64 `json:"temperature,omitempty"` // Sampling temperature
MaxTokens int `json:"max_tokens,omitempty"` // Maximum tokens to generate
}
// MLGenerateOutput contains the generation result.
type MLGenerateOutput struct {
Response string `json:"response"`
Backend string `json:"backend"`
Model string `json:"model,omitempty"`
}
// MLScoreInput contains parameters for scoring a response.
type MLScoreInput struct {
Prompt string `json:"prompt"` // The original prompt
Response string `json:"response"` // The model response to score
Suites string `json:"suites,omitempty"` // Comma-separated suites (default: heuristic)
}
// MLScoreOutput contains the scoring result.
type MLScoreOutput struct {
Heuristic *ml.HeuristicScores `json:"heuristic,omitempty"`
Semantic *ml.SemanticScores `json:"semantic,omitempty"`
Content *ml.ContentScores `json:"content,omitempty"`
}
// MLProbeInput contains parameters for running probes.
type MLProbeInput struct {
Backend string `json:"backend,omitempty"` // Backend name
Categories string `json:"categories,omitempty"` // Comma-separated categories to run
}
// MLProbeOutput contains probe results.
type MLProbeOutput struct {
Total int `json:"total"`
Results []MLProbeResultItem `json:"results"`
}
// MLProbeResultItem is a single probe result.
type MLProbeResultItem struct {
ID string `json:"id"`
Category string `json:"category"`
Response string `json:"response"`
}
// MLStatusInput contains parameters for the status query.
type MLStatusInput struct {
InfluxURL string `json:"influx_url,omitempty"` // InfluxDB URL override
InfluxDB string `json:"influx_db,omitempty"` // InfluxDB database override
}
// MLStatusOutput contains pipeline status.
type MLStatusOutput struct {
Status string `json:"status"`
}
// MLBackendsInput is empty — lists all backends.
type MLBackendsInput struct{}
// MLBackendsOutput lists available backends.
type MLBackendsOutput struct {
Backends []MLBackendInfo `json:"backends"`
Default string `json:"default"`
}
// MLBackendInfo describes a single backend.
type MLBackendInfo struct {
Name string `json:"name"`
Available bool `json:"available"`
}
// --- Tool handlers ---
// mlGenerate delegates to go-ml.Service.Generate, which internally uses
// InferenceAdapter to route generation through an inference.TextModel.
// Flow: go-ai → go-ml.Service.Generate → InferenceAdapter → inference.TextModel.
func (m *MLSubsystem) mlGenerate(ctx context.Context, req *mcp.CallToolRequest, input MLGenerateInput) (*mcp.CallToolResult, MLGenerateOutput, error) {
m.logger.Info("MCP tool execution", "tool", "ml_generate", "backend", input.Backend, "user", log.Username())
if input.Prompt == "" {
return nil, MLGenerateOutput{}, errors.New("prompt cannot be empty")
}
opts := ml.GenOpts{
Temperature: input.Temperature,
MaxTokens: input.MaxTokens,
Model: input.Model,
}
result, err := m.service.Generate(ctx, input.Backend, input.Prompt, opts)
if err != nil {
return nil, MLGenerateOutput{}, fmt.Errorf("generate: %w", err)
}
return nil, MLGenerateOutput{
Response: result.Text,
Backend: input.Backend,
Model: input.Model,
}, nil
}
func (m *MLSubsystem) mlScore(ctx context.Context, req *mcp.CallToolRequest, input MLScoreInput) (*mcp.CallToolResult, MLScoreOutput, error) {
m.logger.Info("MCP tool execution", "tool", "ml_score", "suites", input.Suites, "user", log.Username())
if input.Prompt == "" || input.Response == "" {
return nil, MLScoreOutput{}, errors.New("prompt and response cannot be empty")
}
suites := input.Suites
if suites == "" {
suites = "heuristic"
}
output := MLScoreOutput{}
for suite := range strings.SplitSeq(suites, ",") {
suite = strings.TrimSpace(suite)
switch suite {
case "heuristic":
output.Heuristic = ml.ScoreHeuristic(input.Response)
case "semantic":
judge := m.service.Judge()
if judge == nil {
return nil, MLScoreOutput{}, errors.New("semantic scoring requires a judge backend")
}
s, err := judge.ScoreSemantic(ctx, input.Prompt, input.Response)
if err != nil {
return nil, MLScoreOutput{}, fmt.Errorf("semantic score: %w", err)
}
output.Semantic = s
case "content":
return nil, MLScoreOutput{}, errors.New("content scoring requires a ContentProbe — use ml_probe instead")
}
}
return nil, output, nil
}
// mlProbe runs capability probes by generating responses via go-ml.Service.
// Flow: go-ai → go-ml.Service.Generate → InferenceAdapter → inference.TextModel.
func (m *MLSubsystem) mlProbe(ctx context.Context, req *mcp.CallToolRequest, input MLProbeInput) (*mcp.CallToolResult, MLProbeOutput, error) {
m.logger.Info("MCP tool execution", "tool", "ml_probe", "backend", input.Backend, "user", log.Username())
// Filter probes by category if specified.
probes := ml.CapabilityProbes
if input.Categories != "" {
cats := make(map[string]bool)
for c := range strings.SplitSeq(input.Categories, ",") {
cats[strings.TrimSpace(c)] = true
}
var filtered []ml.Probe
for _, p := range probes {
if cats[p.Category] {
filtered = append(filtered, p)
}
}
probes = filtered
}
var results []MLProbeResultItem
for _, probe := range probes {
result, err := m.service.Generate(ctx, input.Backend, probe.Prompt, ml.GenOpts{Temperature: 0.7, MaxTokens: 2048})
respText := result.Text
if err != nil {
respText = fmt.Sprintf("error: %v", err)
}
results = append(results, MLProbeResultItem{
ID: probe.ID,
Category: probe.Category,
Response: respText,
})
}
return nil, MLProbeOutput{
Total: len(results),
Results: results,
}, nil
}
func (m *MLSubsystem) mlStatus(ctx context.Context, req *mcp.CallToolRequest, input MLStatusInput) (*mcp.CallToolResult, MLStatusOutput, error) {
m.logger.Info("MCP tool execution", "tool", "ml_status", "user", log.Username())
url := input.InfluxURL
db := input.InfluxDB
if url == "" {
url = "http://localhost:8086"
}
if db == "" {
db = "lem"
}
influx := ml.NewInfluxClient(url, db)
var buf strings.Builder
if err := ml.PrintStatus(influx, &buf); err != nil {
return nil, MLStatusOutput{}, fmt.Errorf("status: %w", err)
}
return nil, MLStatusOutput{Status: buf.String()}, nil
}
// mlBackends enumerates registered backends via the go-inference registry,
// bypassing go-ml.Service entirely. This is the canonical source of truth
// for backend availability since all backends register with inference.Register().
func (m *MLSubsystem) mlBackends(ctx context.Context, req *mcp.CallToolRequest, input MLBackendsInput) (*mcp.CallToolResult, MLBackendsOutput, error) {
m.logger.Info("MCP tool execution", "tool", "ml_backends", "user", log.Username())
names := inference.List()
backends := make([]MLBackendInfo, 0, len(names))
for _, name := range names {
b, ok := inference.Get(name)
backends = append(backends, MLBackendInfo{
Name: name,
Available: ok && b.Available(),
})
}
defaultName := ""
if db, err := inference.Default(); err == nil {
defaultName = db.Name()
}
return nil, MLBackendsOutput{
Backends: backends,
Default: defaultName,
}, nil
}

479
pkg/mcp/tools_ml_test.go Normal file
View file

@ -0,0 +1,479 @@
package mcp
import (
"context"
"fmt"
"strings"
"testing"
"forge.lthn.ai/core/go-inference"
"forge.lthn.ai/core/go-ml"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/go-log"
)
// --- Mock backend for inference registry ---
// mockInferenceBackend implements inference.Backend for CI testing of ml_backends.
type mockInferenceBackend struct {
name string
available bool
}
func (m *mockInferenceBackend) Name() string { return m.name }
func (m *mockInferenceBackend) Available() bool { return m.available }
func (m *mockInferenceBackend) LoadModel(_ string, _ ...inference.LoadOption) (inference.TextModel, error) {
return nil, fmt.Errorf("mock backend: LoadModel not implemented")
}
// --- Mock ml.Backend for Generate ---
// mockMLBackend implements ml.Backend for CI testing.
type mockMLBackend struct {
name string
available bool
generateResp string
generateErr error
}
func (m *mockMLBackend) Name() string { return m.name }
func (m *mockMLBackend) Available() bool { return m.available }
func (m *mockMLBackend) Generate(_ context.Context, _ string, _ ml.GenOpts) (ml.Result, error) {
return ml.Result{Text: m.generateResp}, m.generateErr
}
func (m *mockMLBackend) Chat(_ context.Context, _ []ml.Message, _ ml.GenOpts) (ml.Result, error) {
return ml.Result{Text: m.generateResp}, m.generateErr
}
// newTestMLSubsystem creates an MLSubsystem with a real ml.Service for testing.
func newTestMLSubsystem(t *testing.T, backends ...ml.Backend) *MLSubsystem {
t.Helper()
c, err := core.New(
core.WithName("ml", ml.NewService(ml.Options{})),
)
if err != nil {
t.Fatalf("Failed to create framework core: %v", err)
}
svc, err := core.ServiceFor[*ml.Service](c, "ml")
if err != nil {
t.Fatalf("Failed to get ML service: %v", err)
}
// Register mock backends
for _, b := range backends {
svc.RegisterBackend(b.Name(), b)
}
return &MLSubsystem{
service: svc,
logger: log.Default(),
}
}
// --- Input/Output struct tests ---
// TestMLGenerateInput_Good verifies all fields can be set.
func TestMLGenerateInput_Good(t *testing.T) {
input := MLGenerateInput{
Prompt: "Hello world",
Backend: "test",
Model: "test-model",
Temperature: 0.7,
MaxTokens: 100,
}
if input.Prompt != "Hello world" {
t.Errorf("Expected prompt 'Hello world', got %q", input.Prompt)
}
if input.Temperature != 0.7 {
t.Errorf("Expected temperature 0.7, got %f", input.Temperature)
}
if input.MaxTokens != 100 {
t.Errorf("Expected max_tokens 100, got %d", input.MaxTokens)
}
}
// TestMLScoreInput_Good verifies all fields can be set.
func TestMLScoreInput_Good(t *testing.T) {
input := MLScoreInput{
Prompt: "test prompt",
Response: "test response",
Suites: "heuristic,semantic",
}
if input.Prompt != "test prompt" {
t.Errorf("Expected prompt 'test prompt', got %q", input.Prompt)
}
if input.Response != "test response" {
t.Errorf("Expected response 'test response', got %q", input.Response)
}
}
// TestMLProbeInput_Good verifies all fields can be set.
func TestMLProbeInput_Good(t *testing.T) {
input := MLProbeInput{
Backend: "test",
Categories: "reasoning,code",
}
if input.Backend != "test" {
t.Errorf("Expected backend 'test', got %q", input.Backend)
}
}
// TestMLStatusInput_Good verifies all fields can be set.
func TestMLStatusInput_Good(t *testing.T) {
input := MLStatusInput{
InfluxURL: "http://localhost:8086",
InfluxDB: "lem",
}
if input.InfluxURL != "http://localhost:8086" {
t.Errorf("Expected InfluxURL, got %q", input.InfluxURL)
}
}
// TestMLBackendsInput_Good verifies empty struct.
func TestMLBackendsInput_Good(t *testing.T) {
_ = MLBackendsInput{}
}
// TestMLBackendsOutput_Good verifies struct fields.
func TestMLBackendsOutput_Good(t *testing.T) {
output := MLBackendsOutput{
Backends: []MLBackendInfo{
{Name: "ollama", Available: true},
{Name: "llama", Available: false},
},
Default: "ollama",
}
if len(output.Backends) != 2 {
t.Fatalf("Expected 2 backends, got %d", len(output.Backends))
}
if output.Default != "ollama" {
t.Errorf("Expected default 'ollama', got %q", output.Default)
}
if !output.Backends[0].Available {
t.Error("Expected first backend to be available")
}
}
// TestMLProbeOutput_Good verifies struct fields.
func TestMLProbeOutput_Good(t *testing.T) {
output := MLProbeOutput{
Total: 2,
Results: []MLProbeResultItem{
{ID: "probe-1", Category: "reasoning", Response: "test"},
{ID: "probe-2", Category: "code", Response: "test2"},
},
}
if output.Total != 2 {
t.Errorf("Expected total 2, got %d", output.Total)
}
if output.Results[0].ID != "probe-1" {
t.Errorf("Expected ID 'probe-1', got %q", output.Results[0].ID)
}
}
// TestMLStatusOutput_Good verifies struct fields.
func TestMLStatusOutput_Good(t *testing.T) {
output := MLStatusOutput{Status: "OK: 5 training runs"}
if output.Status != "OK: 5 training runs" {
t.Errorf("Unexpected status: %q", output.Status)
}
}
// TestMLGenerateOutput_Good verifies struct fields.
func TestMLGenerateOutput_Good(t *testing.T) {
output := MLGenerateOutput{
Response: "Generated text here",
Backend: "ollama",
Model: "qwen3:8b",
}
if output.Response != "Generated text here" {
t.Errorf("Unexpected response: %q", output.Response)
}
}
// TestMLScoreOutput_Good verifies struct fields.
func TestMLScoreOutput_Good(t *testing.T) {
output := MLScoreOutput{
Heuristic: &ml.HeuristicScores{},
}
if output.Heuristic == nil {
t.Error("Expected Heuristic to be set")
}
if output.Semantic != nil {
t.Error("Expected Semantic to be nil")
}
}
// --- Handler validation tests ---
// TestMLGenerate_Bad_EmptyPrompt verifies empty prompt returns error.
func TestMLGenerate_Bad_EmptyPrompt(t *testing.T) {
m := newTestMLSubsystem(t)
ctx := context.Background()
_, _, err := m.mlGenerate(ctx, nil, MLGenerateInput{})
if err == nil {
t.Fatal("Expected error for empty prompt")
}
if !strings.Contains(err.Error(), "prompt cannot be empty") {
t.Errorf("Unexpected error: %v", err)
}
}
// TestMLGenerate_Good_WithMockBackend verifies generate works with a mock backend.
func TestMLGenerate_Good_WithMockBackend(t *testing.T) {
mock := &mockMLBackend{
name: "test-mock",
available: true,
generateResp: "mock response",
}
m := newTestMLSubsystem(t, mock)
ctx := context.Background()
_, out, err := m.mlGenerate(ctx, nil, MLGenerateInput{
Prompt: "test",
Backend: "test-mock",
})
if err != nil {
t.Fatalf("mlGenerate failed: %v", err)
}
if out.Response != "mock response" {
t.Errorf("Expected 'mock response', got %q", out.Response)
}
}
// TestMLGenerate_Bad_NoBackend verifies generate fails gracefully without a backend.
func TestMLGenerate_Bad_NoBackend(t *testing.T) {
m := newTestMLSubsystem(t)
ctx := context.Background()
_, _, err := m.mlGenerate(ctx, nil, MLGenerateInput{
Prompt: "test",
Backend: "nonexistent",
})
if err == nil {
t.Fatal("Expected error for missing backend")
}
if !strings.Contains(err.Error(), "no backend available") {
t.Errorf("Unexpected error: %v", err)
}
}
// TestMLScore_Bad_EmptyPrompt verifies empty prompt returns error.
func TestMLScore_Bad_EmptyPrompt(t *testing.T) {
m := newTestMLSubsystem(t)
ctx := context.Background()
_, _, err := m.mlScore(ctx, nil, MLScoreInput{Response: "some"})
if err == nil {
t.Fatal("Expected error for empty prompt")
}
}
// TestMLScore_Bad_EmptyResponse verifies empty response returns error.
func TestMLScore_Bad_EmptyResponse(t *testing.T) {
m := newTestMLSubsystem(t)
ctx := context.Background()
_, _, err := m.mlScore(ctx, nil, MLScoreInput{Prompt: "some"})
if err == nil {
t.Fatal("Expected error for empty response")
}
}
// TestMLScore_Good_Heuristic verifies heuristic scoring without live services.
func TestMLScore_Good_Heuristic(t *testing.T) {
m := newTestMLSubsystem(t)
ctx := context.Background()
_, out, err := m.mlScore(ctx, nil, MLScoreInput{
Prompt: "What is Go?",
Response: "Go is a statically typed, compiled programming language designed at Google.",
Suites: "heuristic",
})
if err != nil {
t.Fatalf("mlScore failed: %v", err)
}
if out.Heuristic == nil {
t.Fatal("Expected heuristic scores to be set")
}
}
// TestMLScore_Good_DefaultSuite verifies default suite is heuristic.
func TestMLScore_Good_DefaultSuite(t *testing.T) {
m := newTestMLSubsystem(t)
ctx := context.Background()
_, out, err := m.mlScore(ctx, nil, MLScoreInput{
Prompt: "What is Go?",
Response: "Go is a statically typed, compiled programming language designed at Google.",
})
if err != nil {
t.Fatalf("mlScore failed: %v", err)
}
if out.Heuristic == nil {
t.Fatal("Expected heuristic scores (default suite)")
}
}
// TestMLScore_Bad_SemanticNoJudge verifies semantic scoring fails without a judge.
func TestMLScore_Bad_SemanticNoJudge(t *testing.T) {
m := newTestMLSubsystem(t)
ctx := context.Background()
_, _, err := m.mlScore(ctx, nil, MLScoreInput{
Prompt: "test",
Response: "test",
Suites: "semantic",
})
if err == nil {
t.Fatal("Expected error for semantic scoring without judge")
}
if !strings.Contains(err.Error(), "requires a judge") {
t.Errorf("Unexpected error: %v", err)
}
}
// TestMLScore_Bad_ContentSuite verifies content suite redirects to ml_probe.
func TestMLScore_Bad_ContentSuite(t *testing.T) {
m := newTestMLSubsystem(t)
ctx := context.Background()
_, _, err := m.mlScore(ctx, nil, MLScoreInput{
Prompt: "test",
Response: "test",
Suites: "content",
})
if err == nil {
t.Fatal("Expected error for content suite")
}
if !strings.Contains(err.Error(), "ContentProbe") {
t.Errorf("Unexpected error: %v", err)
}
}
// TestMLProbe_Good_WithMockBackend verifies probes run with mock backend.
func TestMLProbe_Good_WithMockBackend(t *testing.T) {
mock := &mockMLBackend{
name: "probe-mock",
available: true,
generateResp: "probe response",
}
m := newTestMLSubsystem(t, mock)
ctx := context.Background()
_, out, err := m.mlProbe(ctx, nil, MLProbeInput{
Backend: "probe-mock",
Categories: "reasoning",
})
if err != nil {
t.Fatalf("mlProbe failed: %v", err)
}
// Should have run probes in the "reasoning" category
for _, r := range out.Results {
if r.Category != "reasoning" {
t.Errorf("Expected category 'reasoning', got %q", r.Category)
}
if r.Response != "probe response" {
t.Errorf("Expected 'probe response', got %q", r.Response)
}
}
if out.Total != len(out.Results) {
t.Errorf("Expected total %d, got %d", len(out.Results), out.Total)
}
}
// TestMLProbe_Good_NoCategory verifies all probes run without category filter.
func TestMLProbe_Good_NoCategory(t *testing.T) {
mock := &mockMLBackend{
name: "all-probe-mock",
available: true,
generateResp: "ok",
}
m := newTestMLSubsystem(t, mock)
ctx := context.Background()
_, out, err := m.mlProbe(ctx, nil, MLProbeInput{Backend: "all-probe-mock"})
if err != nil {
t.Fatalf("mlProbe failed: %v", err)
}
// Should run all 23 probes
if out.Total != len(ml.CapabilityProbes) {
t.Errorf("Expected %d probes, got %d", len(ml.CapabilityProbes), out.Total)
}
}
// TestMLBackends_Good_EmptyRegistry verifies empty result when no backends registered.
func TestMLBackends_Good_EmptyRegistry(t *testing.T) {
m := newTestMLSubsystem(t)
ctx := context.Background()
// Note: inference.List() returns global registry state.
// This test verifies the handler runs without panic.
_, out, err := m.mlBackends(ctx, nil, MLBackendsInput{})
if err != nil {
t.Fatalf("mlBackends failed: %v", err)
}
// We can't guarantee what's in the global registry, but it should not panic
_ = out
}
// TestMLBackends_Good_WithMockInferenceBackend verifies registered backend appears.
func TestMLBackends_Good_WithMockInferenceBackend(t *testing.T) {
// Register a mock backend in the global inference registry
mock := &mockInferenceBackend{name: "test-ci-mock", available: true}
inference.Register(mock)
m := newTestMLSubsystem(t)
ctx := context.Background()
_, out, err := m.mlBackends(ctx, nil, MLBackendsInput{})
if err != nil {
t.Fatalf("mlBackends failed: %v", err)
}
found := false
for _, b := range out.Backends {
if b.Name == "test-ci-mock" {
found = true
if !b.Available {
t.Error("Expected mock backend to be available")
}
}
}
if !found {
t.Error("Expected to find 'test-ci-mock' in backends list")
}
}
// TestMLSubsystem_Good_Name verifies subsystem name.
func TestMLSubsystem_Good_Name(t *testing.T) {
m := newTestMLSubsystem(t)
if m.Name() != "ml" {
t.Errorf("Expected name 'ml', got %q", m.Name())
}
}
// TestNewMLSubsystem_Good verifies constructor.
func TestNewMLSubsystem_Good(t *testing.T) {
c, err := core.New(
core.WithName("ml", ml.NewService(ml.Options{})),
)
if err != nil {
t.Fatalf("Failed to create core: %v", err)
}
svc, err := core.ServiceFor[*ml.Service](c, "ml")
if err != nil {
t.Fatalf("Failed to get service: %v", err)
}
sub := NewMLSubsystem(svc)
if sub == nil {
t.Fatal("Expected non-nil subsystem")
}
if sub.service != svc {
t.Error("Expected service to be set")
}
if sub.logger == nil {
t.Error("Expected logger to be set")
}
}

305
pkg/mcp/tools_process.go Normal file
View file

@ -0,0 +1,305 @@
package mcp
import (
"context"
"errors"
"fmt"
"time"
"forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go-process"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// errIDEmpty is returned when a process tool call omits the required ID.
var errIDEmpty = errors.New("id cannot be empty")
// ProcessStartInput contains parameters for starting a new process.
type ProcessStartInput struct {
Command string `json:"command"` // The command to run
Args []string `json:"args,omitempty"` // Command arguments
Dir string `json:"dir,omitempty"` // Working directory
Env []string `json:"env,omitempty"` // Environment variables (KEY=VALUE format)
}
// ProcessStartOutput contains the result of starting a process.
type ProcessStartOutput struct {
ID string `json:"id"`
PID int `json:"pid"`
Command string `json:"command"`
Args []string `json:"args"`
StartedAt time.Time `json:"startedAt"`
}
// ProcessStopInput contains parameters for gracefully stopping a process.
type ProcessStopInput struct {
ID string `json:"id"` // Process ID to stop
}
// ProcessStopOutput contains the result of stopping a process.
type ProcessStopOutput struct {
ID string `json:"id"`
Success bool `json:"success"`
Message string `json:"message,omitempty"`
}
// ProcessKillInput contains parameters for force killing a process.
type ProcessKillInput struct {
ID string `json:"id"` // Process ID to kill
}
// ProcessKillOutput contains the result of killing a process.
type ProcessKillOutput struct {
ID string `json:"id"`
Success bool `json:"success"`
Message string `json:"message,omitempty"`
}
// ProcessListInput contains parameters for listing processes.
type ProcessListInput struct {
RunningOnly bool `json:"running_only,omitempty"` // If true, only return running processes
}
// ProcessListOutput contains the list of processes.
type ProcessListOutput struct {
Processes []ProcessInfo `json:"processes"`
Total int `json:"total"`
}
// ProcessInfo represents information about a process.
type ProcessInfo struct {
ID string `json:"id"`
Command string `json:"command"`
Args []string `json:"args"`
Dir string `json:"dir"`
Status string `json:"status"`
PID int `json:"pid"`
ExitCode int `json:"exitCode"`
StartedAt time.Time `json:"startedAt"`
Duration time.Duration `json:"duration"`
}
// ProcessOutputInput contains parameters for getting process output.
type ProcessOutputInput struct {
ID string `json:"id"` // Process ID
}
// ProcessOutputOutput contains the captured output of a process.
type ProcessOutputOutput struct {
ID string `json:"id"`
Output string `json:"output"`
}
// ProcessInputInput contains parameters for sending input to a process.
type ProcessInputInput struct {
ID string `json:"id"` // Process ID
Input string `json:"input"` // Input to send to stdin
}
// ProcessInputOutput contains the result of sending input to a process.
type ProcessInputOutput struct {
ID string `json:"id"`
Success bool `json:"success"`
Message string `json:"message,omitempty"`
}
// registerProcessTools adds process management tools to the MCP server.
// Returns false if process service is not available.
func (s *Service) registerProcessTools(server *mcp.Server) bool {
if s.processService == nil {
return false
}
mcp.AddTool(server, &mcp.Tool{
Name: "process_start",
Description: "Start a new external process. Returns process ID for tracking.",
}, s.processStart)
mcp.AddTool(server, &mcp.Tool{
Name: "process_stop",
Description: "Gracefully stop a running process by ID.",
}, s.processStop)
mcp.AddTool(server, &mcp.Tool{
Name: "process_kill",
Description: "Force kill a process by ID. Use when process_stop doesn't work.",
}, s.processKill)
mcp.AddTool(server, &mcp.Tool{
Name: "process_list",
Description: "List all managed processes. Use running_only=true for only active processes.",
}, s.processList)
mcp.AddTool(server, &mcp.Tool{
Name: "process_output",
Description: "Get the captured output of a process by ID.",
}, s.processOutput)
mcp.AddTool(server, &mcp.Tool{
Name: "process_input",
Description: "Send input to a running process stdin.",
}, s.processInput)
return true
}
// processStart handles the process_start tool call.
func (s *Service) processStart(ctx context.Context, req *mcp.CallToolRequest, input ProcessStartInput) (*mcp.CallToolResult, ProcessStartOutput, error) {
s.logger.Security("MCP tool execution", "tool", "process_start", "command", input.Command, "args", input.Args, "dir", input.Dir, "user", log.Username())
if input.Command == "" {
return nil, ProcessStartOutput{}, errors.New("command cannot be empty")
}
opts := process.RunOptions{
Command: input.Command,
Args: input.Args,
Dir: input.Dir,
Env: input.Env,
}
proc, err := s.processService.StartWithOptions(ctx, opts)
if err != nil {
log.Error("mcp: process start failed", "command", input.Command, "err", err)
return nil, ProcessStartOutput{}, fmt.Errorf("failed to start process: %w", err)
}
info := proc.Info()
return nil, ProcessStartOutput{
ID: proc.ID,
PID: info.PID,
Command: proc.Command,
Args: proc.Args,
StartedAt: proc.StartedAt,
}, nil
}
// processStop handles the process_stop tool call.
func (s *Service) processStop(ctx context.Context, req *mcp.CallToolRequest, input ProcessStopInput) (*mcp.CallToolResult, ProcessStopOutput, error) {
s.logger.Security("MCP tool execution", "tool", "process_stop", "id", input.ID, "user", log.Username())
if input.ID == "" {
return nil, ProcessStopOutput{}, errIDEmpty
}
proc, err := s.processService.Get(input.ID)
if err != nil {
log.Error("mcp: process stop failed", "id", input.ID, "err", err)
return nil, ProcessStopOutput{}, fmt.Errorf("process not found: %w", err)
}
// For graceful stop, we use Kill() which sends SIGKILL
// A more sophisticated implementation could use SIGTERM first
if err := proc.Kill(); err != nil {
log.Error("mcp: process stop kill failed", "id", input.ID, "err", err)
return nil, ProcessStopOutput{}, fmt.Errorf("failed to stop process: %w", err)
}
return nil, ProcessStopOutput{
ID: input.ID,
Success: true,
Message: "Process stop signal sent",
}, nil
}
// processKill handles the process_kill tool call.
func (s *Service) processKill(ctx context.Context, req *mcp.CallToolRequest, input ProcessKillInput) (*mcp.CallToolResult, ProcessKillOutput, error) {
s.logger.Security("MCP tool execution", "tool", "process_kill", "id", input.ID, "user", log.Username())
if input.ID == "" {
return nil, ProcessKillOutput{}, errIDEmpty
}
if err := s.processService.Kill(input.ID); err != nil {
log.Error("mcp: process kill failed", "id", input.ID, "err", err)
return nil, ProcessKillOutput{}, fmt.Errorf("failed to kill process: %w", err)
}
return nil, ProcessKillOutput{
ID: input.ID,
Success: true,
Message: "Process killed",
}, nil
}
// processList handles the process_list tool call.
func (s *Service) processList(ctx context.Context, req *mcp.CallToolRequest, input ProcessListInput) (*mcp.CallToolResult, ProcessListOutput, error) {
s.logger.Info("MCP tool execution", "tool", "process_list", "running_only", input.RunningOnly, "user", log.Username())
var procs []*process.Process
if input.RunningOnly {
procs = s.processService.Running()
} else {
procs = s.processService.List()
}
result := make([]ProcessInfo, len(procs))
for i, p := range procs {
info := p.Info()
result[i] = ProcessInfo{
ID: info.ID,
Command: info.Command,
Args: info.Args,
Dir: info.Dir,
Status: string(info.Status),
PID: info.PID,
ExitCode: info.ExitCode,
StartedAt: info.StartedAt,
Duration: info.Duration,
}
}
return nil, ProcessListOutput{
Processes: result,
Total: len(result),
}, nil
}
// processOutput handles the process_output tool call.
func (s *Service) processOutput(ctx context.Context, req *mcp.CallToolRequest, input ProcessOutputInput) (*mcp.CallToolResult, ProcessOutputOutput, error) {
s.logger.Info("MCP tool execution", "tool", "process_output", "id", input.ID, "user", log.Username())
if input.ID == "" {
return nil, ProcessOutputOutput{}, errIDEmpty
}
output, err := s.processService.Output(input.ID)
if err != nil {
log.Error("mcp: process output failed", "id", input.ID, "err", err)
return nil, ProcessOutputOutput{}, fmt.Errorf("failed to get process output: %w", err)
}
return nil, ProcessOutputOutput{
ID: input.ID,
Output: output,
}, nil
}
// processInput handles the process_input tool call.
func (s *Service) processInput(ctx context.Context, req *mcp.CallToolRequest, input ProcessInputInput) (*mcp.CallToolResult, ProcessInputOutput, error) {
s.logger.Security("MCP tool execution", "tool", "process_input", "id", input.ID, "user", log.Username())
if input.ID == "" {
return nil, ProcessInputOutput{}, errIDEmpty
}
if input.Input == "" {
return nil, ProcessInputOutput{}, errors.New("input cannot be empty")
}
proc, err := s.processService.Get(input.ID)
if err != nil {
log.Error("mcp: process input get failed", "id", input.ID, "err", err)
return nil, ProcessInputOutput{}, fmt.Errorf("process not found: %w", err)
}
if err := proc.SendInput(input.Input); err != nil {
log.Error("mcp: process input send failed", "id", input.ID, "err", err)
return nil, ProcessInputOutput{}, fmt.Errorf("failed to send input: %w", err)
}
return nil, ProcessInputOutput{
ID: input.ID,
Success: true,
Message: "Input sent successfully",
}, nil
}

View file

@ -0,0 +1,515 @@
package mcp
import (
"context"
"strings"
"testing"
"time"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/go-process"
)
// newTestProcessService creates a real process.Service backed by a core.Core for CI tests.
func newTestProcessService(t *testing.T) *process.Service {
t.Helper()
c, err := core.New(
core.WithName("process", process.NewService(process.Options{})),
)
if err != nil {
t.Fatalf("Failed to create framework core: %v", err)
}
svc, err := core.ServiceFor[*process.Service](c, "process")
if err != nil {
t.Fatalf("Failed to get process service: %v", err)
}
// Start services (calls OnStartup)
if err := c.ServiceStartup(context.Background(), nil); err != nil {
t.Fatalf("Failed to start core: %v", err)
}
t.Cleanup(func() {
_ = c.ServiceShutdown(context.Background())
})
return svc
}
// newTestMCPWithProcess creates an MCP Service wired to a real process.Service.
func newTestMCPWithProcess(t *testing.T) (*Service, *process.Service) {
t.Helper()
ps := newTestProcessService(t)
s, err := New(WithProcessService(ps))
if err != nil {
t.Fatalf("Failed to create MCP service: %v", err)
}
return s, ps
}
// --- CI-safe handler tests ---
// TestProcessStart_Good_Echo starts "echo hello" and verifies the output.
func TestProcessStart_Good_Echo(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, out, err := s.processStart(ctx, nil, ProcessStartInput{
Command: "echo",
Args: []string{"hello"},
})
if err != nil {
t.Fatalf("processStart failed: %v", err)
}
if out.ID == "" {
t.Error("Expected non-empty process ID")
}
if out.Command != "echo" {
t.Errorf("Expected command 'echo', got %q", out.Command)
}
if out.PID <= 0 {
t.Errorf("Expected positive PID, got %d", out.PID)
}
if out.StartedAt.IsZero() {
t.Error("Expected non-zero StartedAt")
}
}
// TestProcessStart_Bad_EmptyCommand verifies empty command returns an error.
func TestProcessStart_Bad_EmptyCommand(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, _, err := s.processStart(ctx, nil, ProcessStartInput{})
if err == nil {
t.Fatal("Expected error for empty command")
}
if !strings.Contains(err.Error(), "command cannot be empty") {
t.Errorf("Unexpected error: %v", err)
}
}
// TestProcessStart_Bad_NonexistentCommand verifies an invalid command returns an error.
func TestProcessStart_Bad_NonexistentCommand(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, _, err := s.processStart(ctx, nil, ProcessStartInput{
Command: "/nonexistent/binary/that/does/not/exist",
})
if err == nil {
t.Fatal("Expected error for nonexistent command")
}
}
// TestProcessList_Good_Empty verifies list is empty initially.
func TestProcessList_Good_Empty(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, out, err := s.processList(ctx, nil, ProcessListInput{})
if err != nil {
t.Fatalf("processList failed: %v", err)
}
if out.Total != 0 {
t.Errorf("Expected 0 processes, got %d", out.Total)
}
}
// TestProcessList_Good_AfterStart verifies a started process appears in list.
func TestProcessList_Good_AfterStart(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
// Start a short-lived process
_, startOut, err := s.processStart(ctx, nil, ProcessStartInput{
Command: "echo",
Args: []string{"listing"},
})
if err != nil {
t.Fatalf("processStart failed: %v", err)
}
// Give it a moment to register
time.Sleep(50 * time.Millisecond)
// List all processes (including exited)
_, listOut, err := s.processList(ctx, nil, ProcessListInput{})
if err != nil {
t.Fatalf("processList failed: %v", err)
}
if listOut.Total < 1 {
t.Fatalf("Expected at least 1 process, got %d", listOut.Total)
}
found := false
for _, p := range listOut.Processes {
if p.ID == startOut.ID {
found = true
if p.Command != "echo" {
t.Errorf("Expected command 'echo', got %q", p.Command)
}
}
}
if !found {
t.Errorf("Process %s not found in list", startOut.ID)
}
}
// TestProcessList_Good_RunningOnly verifies filtering for running-only processes.
func TestProcessList_Good_RunningOnly(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
// Start a process that exits quickly
_, _, err := s.processStart(ctx, nil, ProcessStartInput{
Command: "echo",
Args: []string{"done"},
})
if err != nil {
t.Fatalf("processStart failed: %v", err)
}
// Wait for it to exit
time.Sleep(100 * time.Millisecond)
// Running-only should be empty now
_, listOut, err := s.processList(ctx, nil, ProcessListInput{RunningOnly: true})
if err != nil {
t.Fatalf("processList failed: %v", err)
}
if listOut.Total != 0 {
t.Errorf("Expected 0 running processes after echo exits, got %d", listOut.Total)
}
}
// TestProcessOutput_Good_Echo verifies output capture from echo.
func TestProcessOutput_Good_Echo(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, startOut, err := s.processStart(ctx, nil, ProcessStartInput{
Command: "echo",
Args: []string{"output_test"},
})
if err != nil {
t.Fatalf("processStart failed: %v", err)
}
// Wait for process to complete and output to be captured
time.Sleep(200 * time.Millisecond)
_, outputOut, err := s.processOutput(ctx, nil, ProcessOutputInput{ID: startOut.ID})
if err != nil {
t.Fatalf("processOutput failed: %v", err)
}
if !strings.Contains(outputOut.Output, "output_test") {
t.Errorf("Expected output to contain 'output_test', got %q", outputOut.Output)
}
}
// TestProcessOutput_Bad_EmptyID verifies empty ID returns error.
func TestProcessOutput_Bad_EmptyID(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, _, err := s.processOutput(ctx, nil, ProcessOutputInput{})
if err == nil {
t.Fatal("Expected error for empty ID")
}
if !strings.Contains(err.Error(), "id cannot be empty") {
t.Errorf("Unexpected error: %v", err)
}
}
// TestProcessOutput_Bad_NotFound verifies nonexistent ID returns error.
func TestProcessOutput_Bad_NotFound(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, _, err := s.processOutput(ctx, nil, ProcessOutputInput{ID: "nonexistent-id"})
if err == nil {
t.Fatal("Expected error for nonexistent ID")
}
}
// TestProcessStop_Good_LongRunning starts a sleep, stops it, and verifies.
func TestProcessStop_Good_LongRunning(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
// Start a process that sleeps for a while
_, startOut, err := s.processStart(ctx, nil, ProcessStartInput{
Command: "sleep",
Args: []string{"10"},
})
if err != nil {
t.Fatalf("processStart failed: %v", err)
}
// Verify it's running
time.Sleep(50 * time.Millisecond)
_, listOut, _ := s.processList(ctx, nil, ProcessListInput{RunningOnly: true})
if listOut.Total < 1 {
t.Fatal("Expected at least 1 running process")
}
// Stop it
_, stopOut, err := s.processStop(ctx, nil, ProcessStopInput{ID: startOut.ID})
if err != nil {
t.Fatalf("processStop failed: %v", err)
}
if !stopOut.Success {
t.Error("Expected stop to succeed")
}
if stopOut.ID != startOut.ID {
t.Errorf("Expected ID %q, got %q", startOut.ID, stopOut.ID)
}
}
// TestProcessStop_Bad_EmptyID verifies empty ID returns error.
func TestProcessStop_Bad_EmptyID(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, _, err := s.processStop(ctx, nil, ProcessStopInput{})
if err == nil {
t.Fatal("Expected error for empty ID")
}
}
// TestProcessStop_Bad_NotFound verifies nonexistent ID returns error.
func TestProcessStop_Bad_NotFound(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, _, err := s.processStop(ctx, nil, ProcessStopInput{ID: "nonexistent-id"})
if err == nil {
t.Fatal("Expected error for nonexistent ID")
}
}
// TestProcessKill_Good_LongRunning starts a sleep, kills it, and verifies.
func TestProcessKill_Good_LongRunning(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, startOut, err := s.processStart(ctx, nil, ProcessStartInput{
Command: "sleep",
Args: []string{"10"},
})
if err != nil {
t.Fatalf("processStart failed: %v", err)
}
time.Sleep(50 * time.Millisecond)
_, killOut, err := s.processKill(ctx, nil, ProcessKillInput{ID: startOut.ID})
if err != nil {
t.Fatalf("processKill failed: %v", err)
}
if !killOut.Success {
t.Error("Expected kill to succeed")
}
if killOut.Message != "Process killed" {
t.Errorf("Expected message 'Process killed', got %q", killOut.Message)
}
}
// TestProcessKill_Bad_EmptyID verifies empty ID returns error.
func TestProcessKill_Bad_EmptyID(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, _, err := s.processKill(ctx, nil, ProcessKillInput{})
if err == nil {
t.Fatal("Expected error for empty ID")
}
}
// TestProcessKill_Bad_NotFound verifies nonexistent ID returns error.
func TestProcessKill_Bad_NotFound(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, _, err := s.processKill(ctx, nil, ProcessKillInput{ID: "nonexistent-id"})
if err == nil {
t.Fatal("Expected error for nonexistent ID")
}
}
// TestProcessInput_Bad_EmptyID verifies empty ID returns error.
func TestProcessInput_Bad_EmptyID(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, _, err := s.processInput(ctx, nil, ProcessInputInput{})
if err == nil {
t.Fatal("Expected error for empty ID")
}
}
// TestProcessInput_Bad_EmptyInput verifies empty input string returns error.
func TestProcessInput_Bad_EmptyInput(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, _, err := s.processInput(ctx, nil, ProcessInputInput{ID: "some-id"})
if err == nil {
t.Fatal("Expected error for empty input")
}
}
// TestProcessInput_Bad_NotFound verifies nonexistent process ID returns error.
func TestProcessInput_Bad_NotFound(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, _, err := s.processInput(ctx, nil, ProcessInputInput{
ID: "nonexistent-id",
Input: "hello\n",
})
if err == nil {
t.Fatal("Expected error for nonexistent ID")
}
}
// TestProcessInput_Good_Cat sends input to cat and reads it back.
func TestProcessInput_Good_Cat(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
// Start cat which reads stdin and echoes to stdout
_, startOut, err := s.processStart(ctx, nil, ProcessStartInput{
Command: "cat",
})
if err != nil {
t.Fatalf("processStart failed: %v", err)
}
time.Sleep(50 * time.Millisecond)
// Send input
_, inputOut, err := s.processInput(ctx, nil, ProcessInputInput{
ID: startOut.ID,
Input: "stdin_test\n",
})
if err != nil {
t.Fatalf("processInput failed: %v", err)
}
if !inputOut.Success {
t.Error("Expected input to succeed")
}
// Wait for output capture
time.Sleep(100 * time.Millisecond)
// Read output
_, outputOut, err := s.processOutput(ctx, nil, ProcessOutputInput{ID: startOut.ID})
if err != nil {
t.Fatalf("processOutput failed: %v", err)
}
if !strings.Contains(outputOut.Output, "stdin_test") {
t.Errorf("Expected output to contain 'stdin_test', got %q", outputOut.Output)
}
// Kill the cat process (it's still running)
_, _, _ = s.processKill(ctx, nil, ProcessKillInput{ID: startOut.ID})
}
// TestProcessStart_Good_WithDir verifies working directory is respected.
func TestProcessStart_Good_WithDir(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
dir := t.TempDir()
_, startOut, err := s.processStart(ctx, nil, ProcessStartInput{
Command: "pwd",
Dir: dir,
})
if err != nil {
t.Fatalf("processStart failed: %v", err)
}
time.Sleep(200 * time.Millisecond)
_, outputOut, err := s.processOutput(ctx, nil, ProcessOutputInput{ID: startOut.ID})
if err != nil {
t.Fatalf("processOutput failed: %v", err)
}
if !strings.Contains(outputOut.Output, dir) {
t.Errorf("Expected output to contain dir %q, got %q", dir, outputOut.Output)
}
}
// TestProcessStart_Good_WithEnv verifies environment variables are passed.
func TestProcessStart_Good_WithEnv(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, startOut, err := s.processStart(ctx, nil, ProcessStartInput{
Command: "env",
Env: []string{"TEST_MCP_VAR=hello_from_test"},
})
if err != nil {
t.Fatalf("processStart failed: %v", err)
}
time.Sleep(200 * time.Millisecond)
_, outputOut, err := s.processOutput(ctx, nil, ProcessOutputInput{ID: startOut.ID})
if err != nil {
t.Fatalf("processOutput failed: %v", err)
}
if !strings.Contains(outputOut.Output, "TEST_MCP_VAR=hello_from_test") {
t.Errorf("Expected output to contain env var, got %q", outputOut.Output)
}
}
// TestProcessToolsRegistered_Good_WithService verifies tools are registered when service is provided.
func TestProcessToolsRegistered_Good_WithService(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
if s.processService == nil {
t.Error("Expected process service to be set")
}
}
// TestProcessFullLifecycle_Good tests the start → list → output → kill → list cycle.
func TestProcessFullLifecycle_Good(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
// 1. Start
_, startOut, err := s.processStart(ctx, nil, ProcessStartInput{
Command: "sleep",
Args: []string{"10"},
})
if err != nil {
t.Fatalf("processStart failed: %v", err)
}
time.Sleep(50 * time.Millisecond)
// 2. List (should be running)
_, listOut, _ := s.processList(ctx, nil, ProcessListInput{RunningOnly: true})
if listOut.Total < 1 {
t.Fatal("Expected at least 1 running process")
}
// 3. Kill
_, killOut, err := s.processKill(ctx, nil, ProcessKillInput{ID: startOut.ID})
if err != nil {
t.Fatalf("processKill failed: %v", err)
}
if !killOut.Success {
t.Error("Expected kill to succeed")
}
// 4. Wait for exit
time.Sleep(100 * time.Millisecond)
// 5. Should not be running anymore
_, listOut, _ = s.processList(ctx, nil, ProcessListInput{RunningOnly: true})
for _, p := range listOut.Processes {
if p.ID == startOut.ID {
t.Errorf("Process %s should not be running after kill", startOut.ID)
}
}
}

View file

@ -0,0 +1,290 @@
package mcp
import (
"testing"
"time"
)
// TestProcessToolsRegistered_Good verifies that process tools are registered when process service is available.
func TestProcessToolsRegistered_Good(t *testing.T) {
// Create a new MCP service without process service - tools should not be registered
s, err := New()
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
if s.processService != nil {
t.Error("Process service should be nil by default")
}
if s.server == nil {
t.Fatal("Server should not be nil")
}
}
// TestProcessStartInput_Good verifies the ProcessStartInput struct has expected fields.
func TestProcessStartInput_Good(t *testing.T) {
input := ProcessStartInput{
Command: "echo",
Args: []string{"hello", "world"},
Dir: "/tmp",
Env: []string{"FOO=bar"},
}
if input.Command != "echo" {
t.Errorf("Expected command 'echo', got %q", input.Command)
}
if len(input.Args) != 2 {
t.Errorf("Expected 2 args, got %d", len(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 var, got %d", len(input.Env))
}
}
// TestProcessStartOutput_Good verifies the ProcessStartOutput struct has expected fields.
func TestProcessStartOutput_Good(t *testing.T) {
now := time.Now()
output := ProcessStartOutput{
ID: "proc-1",
PID: 12345,
Command: "echo",
Args: []string{"hello"},
StartedAt: now,
}
if output.ID != "proc-1" {
t.Errorf("Expected ID 'proc-1', got %q", output.ID)
}
if output.PID != 12345 {
t.Errorf("Expected PID 12345, got %d", output.PID)
}
if output.Command != "echo" {
t.Errorf("Expected command 'echo', got %q", output.Command)
}
if !output.StartedAt.Equal(now) {
t.Errorf("Expected StartedAt %v, got %v", now, output.StartedAt)
}
}
// TestProcessStopInput_Good verifies the ProcessStopInput struct has expected fields.
func TestProcessStopInput_Good(t *testing.T) {
input := ProcessStopInput{
ID: "proc-1",
}
if input.ID != "proc-1" {
t.Errorf("Expected ID 'proc-1', got %q", input.ID)
}
}
// TestProcessStopOutput_Good verifies the ProcessStopOutput struct has expected fields.
func TestProcessStopOutput_Good(t *testing.T) {
output := ProcessStopOutput{
ID: "proc-1",
Success: true,
Message: "Process stopped",
}
if output.ID != "proc-1" {
t.Errorf("Expected ID 'proc-1', got %q", output.ID)
}
if !output.Success {
t.Error("Expected Success to be true")
}
if output.Message != "Process stopped" {
t.Errorf("Expected message 'Process stopped', got %q", output.Message)
}
}
// TestProcessKillInput_Good verifies the ProcessKillInput struct has expected fields.
func TestProcessKillInput_Good(t *testing.T) {
input := ProcessKillInput{
ID: "proc-1",
}
if input.ID != "proc-1" {
t.Errorf("Expected ID 'proc-1', got %q", input.ID)
}
}
// TestProcessKillOutput_Good verifies the ProcessKillOutput struct has expected fields.
func TestProcessKillOutput_Good(t *testing.T) {
output := ProcessKillOutput{
ID: "proc-1",
Success: true,
Message: "Process killed",
}
if output.ID != "proc-1" {
t.Errorf("Expected ID 'proc-1', got %q", output.ID)
}
if !output.Success {
t.Error("Expected Success to be true")
}
}
// TestProcessListInput_Good verifies the ProcessListInput struct has expected fields.
func TestProcessListInput_Good(t *testing.T) {
input := ProcessListInput{
RunningOnly: true,
}
if !input.RunningOnly {
t.Error("Expected RunningOnly to be true")
}
}
// TestProcessListInput_Defaults verifies default values.
func TestProcessListInput_Defaults(t *testing.T) {
input := ProcessListInput{}
if input.RunningOnly {
t.Error("Expected RunningOnly to default to false")
}
}
// TestProcessListOutput_Good verifies the ProcessListOutput struct has expected fields.
func TestProcessListOutput_Good(t *testing.T) {
now := time.Now()
output := ProcessListOutput{
Processes: []ProcessInfo{
{
ID: "proc-1",
Command: "echo",
Args: []string{"hello"},
Dir: "/tmp",
Status: "running",
PID: 12345,
ExitCode: 0,
StartedAt: now,
Duration: 5 * time.Second,
},
},
Total: 1,
}
if len(output.Processes) != 1 {
t.Fatalf("Expected 1 process, got %d", len(output.Processes))
}
if output.Total != 1 {
t.Errorf("Expected total 1, got %d", output.Total)
}
proc := output.Processes[0]
if proc.ID != "proc-1" {
t.Errorf("Expected ID 'proc-1', got %q", proc.ID)
}
if proc.Status != "running" {
t.Errorf("Expected status 'running', got %q", proc.Status)
}
if proc.PID != 12345 {
t.Errorf("Expected PID 12345, got %d", proc.PID)
}
}
// TestProcessOutputInput_Good verifies the ProcessOutputInput struct has expected fields.
func TestProcessOutputInput_Good(t *testing.T) {
input := ProcessOutputInput{
ID: "proc-1",
}
if input.ID != "proc-1" {
t.Errorf("Expected ID 'proc-1', got %q", input.ID)
}
}
// TestProcessOutputOutput_Good verifies the ProcessOutputOutput struct has expected fields.
func TestProcessOutputOutput_Good(t *testing.T) {
output := ProcessOutputOutput{
ID: "proc-1",
Output: "hello world\n",
}
if output.ID != "proc-1" {
t.Errorf("Expected ID 'proc-1', got %q", output.ID)
}
if output.Output != "hello world\n" {
t.Errorf("Expected output 'hello world\\n', got %q", output.Output)
}
}
// TestProcessInputInput_Good verifies the ProcessInputInput struct has expected fields.
func TestProcessInputInput_Good(t *testing.T) {
input := ProcessInputInput{
ID: "proc-1",
Input: "test input\n",
}
if input.ID != "proc-1" {
t.Errorf("Expected ID 'proc-1', got %q", input.ID)
}
if input.Input != "test input\n" {
t.Errorf("Expected input 'test input\\n', got %q", input.Input)
}
}
// TestProcessInputOutput_Good verifies the ProcessInputOutput struct has expected fields.
func TestProcessInputOutput_Good(t *testing.T) {
output := ProcessInputOutput{
ID: "proc-1",
Success: true,
Message: "Input sent",
}
if output.ID != "proc-1" {
t.Errorf("Expected ID 'proc-1', got %q", output.ID)
}
if !output.Success {
t.Error("Expected Success to be true")
}
}
// TestProcessInfo_Good verifies the ProcessInfo struct has expected fields.
func TestProcessInfo_Good(t *testing.T) {
now := time.Now()
info := ProcessInfo{
ID: "proc-1",
Command: "echo",
Args: []string{"hello"},
Dir: "/tmp",
Status: "exited",
PID: 12345,
ExitCode: 0,
StartedAt: now,
Duration: 2 * time.Second,
}
if info.ID != "proc-1" {
t.Errorf("Expected ID 'proc-1', got %q", info.ID)
}
if info.Command != "echo" {
t.Errorf("Expected command 'echo', got %q", info.Command)
}
if info.Status != "exited" {
t.Errorf("Expected status 'exited', got %q", info.Status)
}
if info.ExitCode != 0 {
t.Errorf("Expected exit code 0, got %d", info.ExitCode)
}
if info.Duration != 2*time.Second {
t.Errorf("Expected duration 2s, got %v", info.Duration)
}
}
// TestWithProcessService_Good verifies the WithProcessService option.
func TestWithProcessService_Good(t *testing.T) {
// Note: We can't easily create a real process.Service here without Core,
// so we just verify the option doesn't panic with nil.
s, err := New(WithProcessService(nil))
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
if s.processService != nil {
t.Error("Expected processService to be nil when passed nil")
}
}

233
pkg/mcp/tools_rag.go Normal file
View file

@ -0,0 +1,233 @@
package mcp
import (
"context"
"errors"
"fmt"
"forge.lthn.ai/core/go-rag"
"forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// Default values for RAG operations.
const (
DefaultRAGCollection = "hostuk-docs"
DefaultRAGTopK = 5
)
// RAGQueryInput contains parameters for querying the RAG vector database.
type RAGQueryInput struct {
Question string `json:"question"` // The question or search query
Collection string `json:"collection,omitempty"` // Collection name (default: hostuk-docs)
TopK int `json:"topK,omitempty"` // Number of results to return (default: 5)
}
// RAGQueryResult represents a single query result.
type RAGQueryResult struct {
Content string `json:"content"`
Source string `json:"source"`
Section string `json:"section,omitempty"`
Category string `json:"category,omitempty"`
ChunkIndex int `json:"chunkIndex,omitempty"`
Score float32 `json:"score"`
}
// RAGQueryOutput contains the results of a RAG query.
type RAGQueryOutput struct {
Results []RAGQueryResult `json:"results"`
Query string `json:"query"`
Collection string `json:"collection"`
Context string `json:"context"`
}
// RAGIngestInput contains parameters for ingesting documents into the RAG database.
type RAGIngestInput struct {
Path string `json:"path"` // File or directory path to ingest
Collection string `json:"collection,omitempty"` // Collection name (default: hostuk-docs)
Recreate bool `json:"recreate,omitempty"` // Whether to recreate the collection
}
// RAGIngestOutput contains the result of a RAG ingest operation.
type RAGIngestOutput struct {
Success bool `json:"success"`
Path string `json:"path"`
Collection string `json:"collection"`
Chunks int `json:"chunks"`
Message string `json:"message,omitempty"`
}
// RAGCollectionsInput contains parameters for listing collections.
type RAGCollectionsInput struct {
ShowStats bool `json:"show_stats,omitempty"` // Include collection stats (point count, status)
}
// CollectionInfo contains information about a collection.
type CollectionInfo struct {
Name string `json:"name"`
PointsCount uint64 `json:"points_count"`
Status string `json:"status"`
}
// RAGCollectionsOutput contains the list of available collections.
type RAGCollectionsOutput struct {
Collections []CollectionInfo `json:"collections"`
}
// registerRAGTools adds RAG tools to the MCP server.
func (s *Service) registerRAGTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "rag_query",
Description: "Query the RAG vector database for relevant documentation. Returns semantically similar content based on the query.",
}, s.ragQuery)
mcp.AddTool(server, &mcp.Tool{
Name: "rag_ingest",
Description: "Ingest documents into the RAG vector database. Supports both single files and directories.",
}, s.ragIngest)
mcp.AddTool(server, &mcp.Tool{
Name: "rag_collections",
Description: "List all available collections in the RAG vector database.",
}, s.ragCollections)
}
// ragQuery handles the rag_query tool call.
func (s *Service) ragQuery(ctx context.Context, req *mcp.CallToolRequest, input RAGQueryInput) (*mcp.CallToolResult, RAGQueryOutput, error) {
// Apply defaults
collection := input.Collection
if collection == "" {
collection = DefaultRAGCollection
}
topK := input.TopK
if topK <= 0 {
topK = DefaultRAGTopK
}
s.logger.Info("MCP tool execution", "tool", "rag_query", "question", input.Question, "collection", collection, "topK", topK, "user", log.Username())
// Validate input
if input.Question == "" {
return nil, RAGQueryOutput{}, errors.New("question cannot be empty")
}
// Call the RAG query function
results, err := rag.QueryDocs(ctx, input.Question, collection, topK)
if err != nil {
log.Error("mcp: rag query failed", "question", input.Question, "collection", collection, "err", err)
return nil, RAGQueryOutput{}, fmt.Errorf("failed to query RAG: %w", err)
}
// Convert results
output := RAGQueryOutput{
Results: make([]RAGQueryResult, len(results)),
Query: input.Question,
Collection: collection,
Context: rag.FormatResultsContext(results),
}
for i, r := range results {
output.Results[i] = RAGQueryResult{
Content: r.Text,
Source: r.Source,
Section: r.Section,
Category: r.Category,
ChunkIndex: r.ChunkIndex,
Score: r.Score,
}
}
return nil, output, nil
}
// ragIngest handles the rag_ingest tool call.
func (s *Service) ragIngest(ctx context.Context, req *mcp.CallToolRequest, input RAGIngestInput) (*mcp.CallToolResult, RAGIngestOutput, error) {
// Apply defaults
collection := input.Collection
if collection == "" {
collection = DefaultRAGCollection
}
s.logger.Security("MCP tool execution", "tool", "rag_ingest", "path", input.Path, "collection", collection, "recreate", input.Recreate, "user", log.Username())
// Validate input
if input.Path == "" {
return nil, RAGIngestOutput{}, errors.New("path cannot be empty")
}
// Check if path is a file or directory using the medium
info, err := s.medium.Stat(input.Path)
if err != nil {
log.Error("mcp: rag ingest stat failed", "path", input.Path, "err", err)
return nil, RAGIngestOutput{}, fmt.Errorf("failed to access path: %w", err)
}
var message string
var chunks int
if info.IsDir() {
// Ingest directory
err = rag.IngestDirectory(ctx, input.Path, collection, input.Recreate)
if err != nil {
log.Error("mcp: rag ingest directory failed", "path", input.Path, "collection", collection, "err", err)
return nil, RAGIngestOutput{}, fmt.Errorf("failed to ingest directory: %w", err)
}
message = fmt.Sprintf("Successfully ingested directory %s into collection %s", input.Path, collection)
} else {
// Ingest single file
chunks, err = rag.IngestSingleFile(ctx, input.Path, collection)
if err != nil {
log.Error("mcp: rag ingest file failed", "path", input.Path, "collection", collection, "err", err)
return nil, RAGIngestOutput{}, fmt.Errorf("failed to ingest file: %w", err)
}
message = fmt.Sprintf("Successfully ingested file %s (%d chunks) into collection %s", input.Path, chunks, collection)
}
return nil, RAGIngestOutput{
Success: true,
Path: input.Path,
Collection: collection,
Chunks: chunks,
Message: message,
}, nil
}
// 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())
// Create Qdrant client with default config
qdrantClient, err := rag.NewQdrantClient(rag.DefaultQdrantConfig())
if err != nil {
log.Error("mcp: rag collections connect failed", "err", err)
return nil, RAGCollectionsOutput{}, fmt.Errorf("failed to connect to Qdrant: %w", err)
}
defer func() { _ = qdrantClient.Close() }()
// List collections
collectionNames, err := qdrantClient.ListCollections(ctx)
if err != nil {
log.Error("mcp: rag collections list failed", "err", err)
return nil, RAGCollectionsOutput{}, fmt.Errorf("failed to list collections: %w", err)
}
// Build collection info list
collections := make([]CollectionInfo, len(collectionNames))
for i, name := range collectionNames {
collections[i] = CollectionInfo{Name: name}
// Fetch stats if requested
if input.ShowStats {
info, err := qdrantClient.CollectionInfo(ctx, name)
if err != nil {
log.Error("mcp: rag collection info failed", "collection", name, "err", err)
// Continue with defaults on error
continue
}
collections[i].PointsCount = info.PointCount
collections[i].Status = info.Status
}
}
return nil, RAGCollectionsOutput{
Collections: collections,
}, nil
}

View file

@ -0,0 +1,181 @@
package mcp
import (
"context"
"strings"
"testing"
)
// RAG tools use package-level functions (rag.QueryDocs, rag.IngestDirectory, etc.)
// which require live Qdrant + Ollama services. Since those are not injectable,
// we test handler input validation, default application, and struct behaviour
// at the MCP handler level without requiring live services.
// --- ragQuery validation ---
// TestRagQuery_Bad_EmptyQuestion verifies empty question returns error.
func TestRagQuery_Bad_EmptyQuestion(t *testing.T) {
s, err := New()
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
ctx := context.Background()
_, _, err = s.ragQuery(ctx, nil, RAGQueryInput{})
if err == nil {
t.Fatal("Expected error for empty question")
}
if !strings.Contains(err.Error(), "question cannot be empty") {
t.Errorf("Unexpected error: %v", err)
}
}
// TestRagQuery_Good_DefaultsApplied verifies defaults are applied before validation.
// Because the handler applies defaults then validates, a non-empty question with
// zero Collection/TopK should have defaults applied. We cannot verify the actual
// query (needs live Qdrant), but we can verify it gets past validation.
func TestRagQuery_Good_DefaultsApplied(t *testing.T) {
s, err := New()
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
ctx := context.Background()
// This will fail when it tries to connect to Qdrant, but AFTER applying defaults.
// The error should NOT be about empty question.
_, _, err = s.ragQuery(ctx, nil, RAGQueryInput{Question: "test query"})
if err == nil {
t.Skip("RAG query succeeded — live Qdrant available, skip default test")
}
// The error should be about connection failure, not validation
if strings.Contains(err.Error(), "question cannot be empty") {
t.Error("Defaults should have been applied before validation check")
}
}
// --- ragIngest validation ---
// TestRagIngest_Bad_EmptyPath verifies empty path returns error.
func TestRagIngest_Bad_EmptyPath(t *testing.T) {
s, err := New()
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
ctx := context.Background()
_, _, err = s.ragIngest(ctx, nil, RAGIngestInput{})
if err == nil {
t.Fatal("Expected error for empty path")
}
if !strings.Contains(err.Error(), "path cannot be empty") {
t.Errorf("Unexpected error: %v", err)
}
}
// TestRagIngest_Bad_NonexistentPath verifies nonexistent path returns error.
func TestRagIngest_Bad_NonexistentPath(t *testing.T) {
s, err := New()
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
ctx := context.Background()
_, _, err = s.ragIngest(ctx, nil, RAGIngestInput{
Path: "/nonexistent/path/that/does/not/exist/at/all",
})
if err == nil {
t.Fatal("Expected error for nonexistent path")
}
}
// TestRagIngest_Good_DefaultCollection verifies the default collection is applied.
func TestRagIngest_Good_DefaultCollection(t *testing.T) {
s, err := New()
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
ctx := context.Background()
// Use a real but inaccessible path to trigger stat error (not validation error).
// The collection default should be applied first.
_, _, err = s.ragIngest(ctx, nil, RAGIngestInput{
Path: "/nonexistent/path/for/default/test",
})
if err == nil {
t.Skip("Ingest succeeded unexpectedly")
}
// The error should NOT be about empty path
if strings.Contains(err.Error(), "path cannot be empty") {
t.Error("Default collection should have been applied")
}
}
// --- ragCollections validation ---
// TestRagCollections_Bad_NoQdrant verifies graceful error when Qdrant is not available.
func TestRagCollections_Bad_NoQdrant(t *testing.T) {
s, err := New()
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
ctx := context.Background()
_, _, err = s.ragCollections(ctx, nil, RAGCollectionsInput{})
if err == nil {
t.Skip("Qdrant is available — skip connection error test")
}
// Should get a connection error, not a panic
if !strings.Contains(err.Error(), "failed to connect") && !strings.Contains(err.Error(), "failed to list") {
t.Logf("Got error (expected connection failure): %v", err)
}
}
// --- Struct round-trip tests ---
// TestRAGQueryResult_Good_AllFields verifies all fields can be set and read.
func TestRAGQueryResult_Good_AllFields(t *testing.T) {
r := RAGQueryResult{
Content: "test content",
Source: "source.md",
Section: "Overview",
Category: "docs",
ChunkIndex: 3,
Score: 0.88,
}
if r.Content != "test content" {
t.Errorf("Expected content 'test content', got %q", r.Content)
}
if r.ChunkIndex != 3 {
t.Errorf("Expected chunkIndex 3, got %d", r.ChunkIndex)
}
if r.Score != 0.88 {
t.Errorf("Expected score 0.88, got %f", r.Score)
}
}
// TestCollectionInfo_Good_AllFields verifies CollectionInfo field access.
func TestCollectionInfo_Good_AllFields(t *testing.T) {
c := CollectionInfo{
Name: "test-collection",
PointsCount: 12345,
Status: "green",
}
if c.Name != "test-collection" {
t.Errorf("Expected name 'test-collection', got %q", c.Name)
}
if c.PointsCount != 12345 {
t.Errorf("Expected PointsCount 12345, got %d", c.PointsCount)
}
}
// TestRAGDefaults_Good verifies default constants are sensible.
func TestRAGDefaults_Good(t *testing.T) {
if DefaultRAGCollection != "hostuk-docs" {
t.Errorf("Expected default collection 'hostuk-docs', got %q", DefaultRAGCollection)
}
if DefaultRAGTopK != 5 {
t.Errorf("Expected default topK 5, got %d", DefaultRAGTopK)
}
}

173
pkg/mcp/tools_rag_test.go Normal file
View file

@ -0,0 +1,173 @@
package mcp
import (
"testing"
)
// TestRAGToolsRegistered_Good verifies that RAG tools are registered with the MCP server.
func TestRAGToolsRegistered_Good(t *testing.T) {
// Create a new MCP service - this should register all tools including RAG
s, err := New()
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
// The server should have registered the RAG tools
// We verify by checking that the tool handlers exist on the service
// (The actual MCP registration is tested by the SDK)
if s.server == nil {
t.Fatal("Server should not be nil")
}
// Verify the service was created with expected defaults
if s.logger == nil {
t.Error("Logger should not be nil")
}
}
// TestRAGQueryInput_Good verifies the RAGQueryInput struct has expected fields.
func TestRAGQueryInput_Good(t *testing.T) {
input := RAGQueryInput{
Question: "test question",
Collection: "test-collection",
TopK: 10,
}
if input.Question != "test question" {
t.Errorf("Expected question 'test question', got %q", input.Question)
}
if input.Collection != "test-collection" {
t.Errorf("Expected collection 'test-collection', got %q", input.Collection)
}
if input.TopK != 10 {
t.Errorf("Expected topK 10, got %d", input.TopK)
}
}
// TestRAGQueryInput_Defaults verifies default values are handled correctly.
func TestRAGQueryInput_Defaults(t *testing.T) {
// Empty input should use defaults when processed
input := RAGQueryInput{
Question: "test",
}
// Defaults should be applied in the handler, not in the struct
if input.Collection != "" {
t.Errorf("Expected empty collection before defaults, got %q", input.Collection)
}
if input.TopK != 0 {
t.Errorf("Expected zero topK before defaults, got %d", input.TopK)
}
}
// TestRAGIngestInput_Good verifies the RAGIngestInput struct has expected fields.
func TestRAGIngestInput_Good(t *testing.T) {
input := RAGIngestInput{
Path: "/path/to/docs",
Collection: "my-collection",
Recreate: true,
}
if input.Path != "/path/to/docs" {
t.Errorf("Expected path '/path/to/docs', got %q", input.Path)
}
if input.Collection != "my-collection" {
t.Errorf("Expected collection 'my-collection', got %q", input.Collection)
}
if !input.Recreate {
t.Error("Expected recreate to be true")
}
}
// TestRAGCollectionsInput_Good verifies the RAGCollectionsInput struct exists.
func TestRAGCollectionsInput_Good(t *testing.T) {
// RAGCollectionsInput has optional ShowStats parameter
input := RAGCollectionsInput{}
if input.ShowStats {
t.Error("Expected ShowStats to default to false")
}
}
// TestRAGQueryOutput_Good verifies the RAGQueryOutput struct has expected fields.
func TestRAGQueryOutput_Good(t *testing.T) {
output := RAGQueryOutput{
Results: []RAGQueryResult{
{
Content: "some content",
Source: "doc.md",
Section: "Introduction",
Category: "docs",
Score: 0.95,
},
},
Query: "test query",
Collection: "test-collection",
Context: "<retrieved_context>...</retrieved_context>",
}
if len(output.Results) != 1 {
t.Fatalf("Expected 1 result, got %d", len(output.Results))
}
if output.Results[0].Content != "some content" {
t.Errorf("Expected content 'some content', got %q", output.Results[0].Content)
}
if output.Results[0].Score != 0.95 {
t.Errorf("Expected score 0.95, got %f", output.Results[0].Score)
}
if output.Context == "" {
t.Error("Expected context to be set")
}
}
// TestRAGIngestOutput_Good verifies the RAGIngestOutput struct has expected fields.
func TestRAGIngestOutput_Good(t *testing.T) {
output := RAGIngestOutput{
Success: true,
Path: "/path/to/docs",
Collection: "my-collection",
Chunks: 10,
Message: "Ingested successfully",
}
if !output.Success {
t.Error("Expected success to be true")
}
if output.Path != "/path/to/docs" {
t.Errorf("Expected path '/path/to/docs', got %q", output.Path)
}
if output.Chunks != 10 {
t.Errorf("Expected chunks 10, got %d", output.Chunks)
}
}
// TestRAGCollectionsOutput_Good verifies the RAGCollectionsOutput struct has expected fields.
func TestRAGCollectionsOutput_Good(t *testing.T) {
output := RAGCollectionsOutput{
Collections: []CollectionInfo{
{Name: "collection1", PointsCount: 100, Status: "green"},
{Name: "collection2", PointsCount: 200, Status: "green"},
},
}
if len(output.Collections) != 2 {
t.Fatalf("Expected 2 collections, got %d", len(output.Collections))
}
if output.Collections[0].Name != "collection1" {
t.Errorf("Expected 'collection1', got %q", output.Collections[0].Name)
}
if output.Collections[0].PointsCount != 100 {
t.Errorf("Expected PointsCount 100, got %d", output.Collections[0].PointsCount)
}
}
// TestRAGCollectionsInput_Good verifies the RAGCollectionsInput struct has expected fields.
func TestRAGCollectionsInput_ShowStats(t *testing.T) {
input := RAGCollectionsInput{
ShowStats: true,
}
if !input.ShowStats {
t.Error("Expected ShowStats to be true")
}
}

497
pkg/mcp/tools_webview.go Normal file
View file

@ -0,0 +1,497 @@
package mcp
import (
"context"
"encoding/base64"
"errors"
"fmt"
"time"
"forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go-webview"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// webviewInstance holds the current webview connection.
// This is managed by the MCP service.
var webviewInstance *webview.Webview
// Sentinel errors for webview tools.
var (
errNotConnected = errors.New("not connected; use webview_connect first")
errSelectorRequired = errors.New("selector is required")
)
// WebviewConnectInput contains parameters for connecting to Chrome DevTools.
type WebviewConnectInput struct {
DebugURL string `json:"debug_url"` // Chrome DevTools URL (e.g., http://localhost:9222)
Timeout int `json:"timeout,omitempty"` // Default timeout in seconds (default: 30)
}
// WebviewConnectOutput contains the result of connecting to Chrome.
type WebviewConnectOutput struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
}
// WebviewNavigateInput contains parameters for navigating to a URL.
type WebviewNavigateInput struct {
URL string `json:"url"` // URL to navigate to
}
// WebviewNavigateOutput contains the result of navigation.
type WebviewNavigateOutput struct {
Success bool `json:"success"`
URL string `json:"url"`
}
// WebviewClickInput contains parameters for clicking an element.
type WebviewClickInput struct {
Selector string `json:"selector"` // CSS selector
}
// WebviewClickOutput contains the result of a click action.
type WebviewClickOutput struct {
Success bool `json:"success"`
}
// WebviewTypeInput contains parameters for typing text.
type WebviewTypeInput struct {
Selector string `json:"selector"` // CSS selector
Text string `json:"text"` // Text to type
}
// WebviewTypeOutput contains the result of a type action.
type WebviewTypeOutput struct {
Success bool `json:"success"`
}
// WebviewQueryInput contains parameters for querying an element.
type WebviewQueryInput struct {
Selector string `json:"selector"` // CSS selector
All bool `json:"all,omitempty"` // If true, return all matching elements
}
// WebviewQueryOutput contains the result of a query.
type WebviewQueryOutput struct {
Found bool `json:"found"`
Count int `json:"count"`
Elements []WebviewElementInfo `json:"elements,omitempty"`
}
// WebviewElementInfo represents information about a DOM element.
type WebviewElementInfo struct {
NodeID int `json:"nodeId"`
TagName string `json:"tagName"`
Attributes map[string]string `json:"attributes,omitempty"`
BoundingBox *webview.BoundingBox `json:"boundingBox,omitempty"`
}
// WebviewConsoleInput contains parameters for getting console output.
type WebviewConsoleInput struct {
Clear bool `json:"clear,omitempty"` // If true, clear console after getting messages
}
// WebviewConsoleOutput contains console messages.
type WebviewConsoleOutput struct {
Messages []WebviewConsoleMessage `json:"messages"`
Count int `json:"count"`
}
// WebviewConsoleMessage represents a console message.
type WebviewConsoleMessage struct {
Type string `json:"type"`
Text string `json:"text"`
Timestamp string `json:"timestamp"`
URL string `json:"url,omitempty"`
Line int `json:"line,omitempty"`
}
// WebviewEvalInput contains parameters for evaluating JavaScript.
type WebviewEvalInput struct {
Script string `json:"script"` // JavaScript to evaluate
}
// WebviewEvalOutput contains the result of JavaScript evaluation.
type WebviewEvalOutput struct {
Success bool `json:"success"`
Result any `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
// WebviewScreenshotInput contains parameters for taking a screenshot.
type WebviewScreenshotInput struct {
Format string `json:"format,omitempty"` // "png" or "jpeg" (default: png)
}
// WebviewScreenshotOutput contains the screenshot data.
type WebviewScreenshotOutput struct {
Success bool `json:"success"`
Data string `json:"data"` // Base64 encoded image
Format string `json:"format"`
}
// WebviewWaitInput contains parameters for waiting operations.
type WebviewWaitInput struct {
Selector string `json:"selector,omitempty"` // Wait for selector
Timeout int `json:"timeout,omitempty"` // Timeout in seconds
}
// WebviewWaitOutput contains the result of waiting.
type WebviewWaitOutput struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
}
// WebviewDisconnectInput contains parameters for disconnecting.
type WebviewDisconnectInput struct{}
// WebviewDisconnectOutput contains the result of disconnecting.
type WebviewDisconnectOutput struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
}
// registerWebviewTools adds webview tools to the MCP server.
func (s *Service) registerWebviewTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "webview_connect",
Description: "Connect to Chrome DevTools Protocol. Start Chrome with --remote-debugging-port=9222 first.",
}, s.webviewConnect)
mcp.AddTool(server, &mcp.Tool{
Name: "webview_disconnect",
Description: "Disconnect from Chrome DevTools.",
}, s.webviewDisconnect)
mcp.AddTool(server, &mcp.Tool{
Name: "webview_navigate",
Description: "Navigate the browser to a URL.",
}, s.webviewNavigate)
mcp.AddTool(server, &mcp.Tool{
Name: "webview_click",
Description: "Click on an element by CSS selector.",
}, s.webviewClick)
mcp.AddTool(server, &mcp.Tool{
Name: "webview_type",
Description: "Type text into an element by CSS selector.",
}, s.webviewType)
mcp.AddTool(server, &mcp.Tool{
Name: "webview_query",
Description: "Query DOM elements by CSS selector.",
}, s.webviewQuery)
mcp.AddTool(server, &mcp.Tool{
Name: "webview_console",
Description: "Get browser console output.",
}, s.webviewConsole)
mcp.AddTool(server, &mcp.Tool{
Name: "webview_eval",
Description: "Evaluate JavaScript in the browser context.",
}, s.webviewEval)
mcp.AddTool(server, &mcp.Tool{
Name: "webview_screenshot",
Description: "Capture a screenshot of the browser window.",
}, s.webviewScreenshot)
mcp.AddTool(server, &mcp.Tool{
Name: "webview_wait",
Description: "Wait for an element to appear by CSS selector.",
}, s.webviewWait)
}
// webviewConnect handles the webview_connect tool call.
func (s *Service) webviewConnect(ctx context.Context, req *mcp.CallToolRequest, input WebviewConnectInput) (*mcp.CallToolResult, WebviewConnectOutput, error) {
s.logger.Security("MCP tool execution", "tool", "webview_connect", "debug_url", input.DebugURL, "user", log.Username())
if input.DebugURL == "" {
return nil, WebviewConnectOutput{}, errors.New("debug_url is required")
}
// Close existing connection if any
if webviewInstance != nil {
_ = webviewInstance.Close()
webviewInstance = nil
}
// Set up options
opts := []webview.Option{
webview.WithDebugURL(input.DebugURL),
}
if input.Timeout > 0 {
opts = append(opts, webview.WithTimeout(time.Duration(input.Timeout)*time.Second))
}
// Create new webview instance
wv, err := webview.New(opts...)
if err != nil {
log.Error("mcp: webview connect failed", "debug_url", input.DebugURL, "err", err)
return nil, WebviewConnectOutput{}, fmt.Errorf("failed to connect: %w", err)
}
webviewInstance = wv
return nil, WebviewConnectOutput{
Success: true,
Message: fmt.Sprintf("Connected to Chrome DevTools at %s", input.DebugURL),
}, nil
}
// webviewDisconnect handles the webview_disconnect tool call.
func (s *Service) webviewDisconnect(ctx context.Context, req *mcp.CallToolRequest, input WebviewDisconnectInput) (*mcp.CallToolResult, WebviewDisconnectOutput, error) {
s.logger.Info("MCP tool execution", "tool", "webview_disconnect", "user", log.Username())
if webviewInstance == nil {
return nil, WebviewDisconnectOutput{
Success: true,
Message: "No active connection",
}, nil
}
if err := webviewInstance.Close(); err != nil {
log.Error("mcp: webview disconnect failed", "err", err)
return nil, WebviewDisconnectOutput{}, fmt.Errorf("failed to disconnect: %w", err)
}
webviewInstance = nil
return nil, WebviewDisconnectOutput{
Success: true,
Message: "Disconnected from Chrome DevTools",
}, nil
}
// webviewNavigate handles the webview_navigate tool call.
func (s *Service) webviewNavigate(ctx context.Context, req *mcp.CallToolRequest, input WebviewNavigateInput) (*mcp.CallToolResult, WebviewNavigateOutput, error) {
s.logger.Info("MCP tool execution", "tool", "webview_navigate", "url", input.URL, "user", log.Username())
if webviewInstance == nil {
return nil, WebviewNavigateOutput{}, errNotConnected
}
if input.URL == "" {
return nil, WebviewNavigateOutput{}, errors.New("url is required")
}
if err := webviewInstance.Navigate(input.URL); err != nil {
log.Error("mcp: webview navigate failed", "url", input.URL, "err", err)
return nil, WebviewNavigateOutput{}, fmt.Errorf("failed to navigate: %w", err)
}
return nil, WebviewNavigateOutput{
Success: true,
URL: input.URL,
}, nil
}
// webviewClick handles the webview_click tool call.
func (s *Service) webviewClick(ctx context.Context, req *mcp.CallToolRequest, input WebviewClickInput) (*mcp.CallToolResult, WebviewClickOutput, error) {
s.logger.Info("MCP tool execution", "tool", "webview_click", "selector", input.Selector, "user", log.Username())
if webviewInstance == nil {
return nil, WebviewClickOutput{}, errNotConnected
}
if input.Selector == "" {
return nil, WebviewClickOutput{}, errSelectorRequired
}
if err := webviewInstance.Click(input.Selector); err != nil {
log.Error("mcp: webview click failed", "selector", input.Selector, "err", err)
return nil, WebviewClickOutput{}, fmt.Errorf("failed to click: %w", err)
}
return nil, WebviewClickOutput{Success: true}, nil
}
// webviewType handles the webview_type tool call.
func (s *Service) webviewType(ctx context.Context, req *mcp.CallToolRequest, input WebviewTypeInput) (*mcp.CallToolResult, WebviewTypeOutput, error) {
s.logger.Info("MCP tool execution", "tool", "webview_type", "selector", input.Selector, "user", log.Username())
if webviewInstance == nil {
return nil, WebviewTypeOutput{}, errNotConnected
}
if input.Selector == "" {
return nil, WebviewTypeOutput{}, errSelectorRequired
}
if err := webviewInstance.Type(input.Selector, input.Text); err != nil {
log.Error("mcp: webview type failed", "selector", input.Selector, "err", err)
return nil, WebviewTypeOutput{}, fmt.Errorf("failed to type: %w", err)
}
return nil, WebviewTypeOutput{Success: true}, nil
}
// webviewQuery handles the webview_query tool call.
func (s *Service) webviewQuery(ctx context.Context, req *mcp.CallToolRequest, input WebviewQueryInput) (*mcp.CallToolResult, WebviewQueryOutput, error) {
s.logger.Info("MCP tool execution", "tool", "webview_query", "selector", input.Selector, "all", input.All, "user", log.Username())
if webviewInstance == nil {
return nil, WebviewQueryOutput{}, errNotConnected
}
if input.Selector == "" {
return nil, WebviewQueryOutput{}, errSelectorRequired
}
if input.All {
elements, err := webviewInstance.QuerySelectorAll(input.Selector)
if err != nil {
log.Error("mcp: webview query all failed", "selector", input.Selector, "err", err)
return nil, WebviewQueryOutput{}, fmt.Errorf("failed to query: %w", err)
}
output := WebviewQueryOutput{
Found: len(elements) > 0,
Count: len(elements),
Elements: make([]WebviewElementInfo, len(elements)),
}
for i, elem := range elements {
output.Elements[i] = WebviewElementInfo{
NodeID: elem.NodeID,
TagName: elem.TagName,
Attributes: elem.Attributes,
BoundingBox: elem.BoundingBox,
}
}
return nil, output, nil
}
elem, err := webviewInstance.QuerySelector(input.Selector)
if err != nil {
// Element not found is not necessarily an error
return nil, WebviewQueryOutput{
Found: false,
Count: 0,
}, nil
}
return nil, WebviewQueryOutput{
Found: true,
Count: 1,
Elements: []WebviewElementInfo{{
NodeID: elem.NodeID,
TagName: elem.TagName,
Attributes: elem.Attributes,
BoundingBox: elem.BoundingBox,
}},
}, nil
}
// webviewConsole handles the webview_console tool call.
func (s *Service) webviewConsole(ctx context.Context, req *mcp.CallToolRequest, input WebviewConsoleInput) (*mcp.CallToolResult, WebviewConsoleOutput, error) {
s.logger.Info("MCP tool execution", "tool", "webview_console", "clear", input.Clear, "user", log.Username())
if webviewInstance == nil {
return nil, WebviewConsoleOutput{}, errNotConnected
}
messages := webviewInstance.GetConsole()
output := WebviewConsoleOutput{
Messages: make([]WebviewConsoleMessage, len(messages)),
Count: len(messages),
}
for i, msg := range messages {
output.Messages[i] = WebviewConsoleMessage{
Type: msg.Type,
Text: msg.Text,
Timestamp: msg.Timestamp.Format(time.RFC3339),
URL: msg.URL,
Line: msg.Line,
}
}
if input.Clear {
webviewInstance.ClearConsole()
}
return nil, output, nil
}
// webviewEval handles the webview_eval tool call.
func (s *Service) webviewEval(ctx context.Context, req *mcp.CallToolRequest, input WebviewEvalInput) (*mcp.CallToolResult, WebviewEvalOutput, error) {
s.logger.Security("MCP tool execution", "tool", "webview_eval", "user", log.Username())
if webviewInstance == nil {
return nil, WebviewEvalOutput{}, errNotConnected
}
if input.Script == "" {
return nil, WebviewEvalOutput{}, errors.New("script is required")
}
result, err := webviewInstance.Evaluate(input.Script)
if err != nil {
log.Error("mcp: webview eval failed", "err", err)
return nil, WebviewEvalOutput{
Success: false,
Error: err.Error(),
}, nil
}
return nil, WebviewEvalOutput{
Success: true,
Result: result,
}, nil
}
// webviewScreenshot handles the webview_screenshot tool call.
func (s *Service) webviewScreenshot(ctx context.Context, req *mcp.CallToolRequest, input WebviewScreenshotInput) (*mcp.CallToolResult, WebviewScreenshotOutput, error) {
s.logger.Info("MCP tool execution", "tool", "webview_screenshot", "format", input.Format, "user", log.Username())
if webviewInstance == nil {
return nil, WebviewScreenshotOutput{}, errNotConnected
}
format := input.Format
if format == "" {
format = "png"
}
data, err := webviewInstance.Screenshot()
if err != nil {
log.Error("mcp: webview screenshot failed", "err", err)
return nil, WebviewScreenshotOutput{}, fmt.Errorf("failed to capture screenshot: %w", err)
}
return nil, WebviewScreenshotOutput{
Success: true,
Data: base64.StdEncoding.EncodeToString(data),
Format: format,
}, nil
}
// webviewWait handles the webview_wait tool call.
func (s *Service) webviewWait(ctx context.Context, req *mcp.CallToolRequest, input WebviewWaitInput) (*mcp.CallToolResult, WebviewWaitOutput, error) {
s.logger.Info("MCP tool execution", "tool", "webview_wait", "selector", input.Selector, "timeout", input.Timeout, "user", log.Username())
if webviewInstance == nil {
return nil, WebviewWaitOutput{}, errNotConnected
}
if input.Selector == "" {
return nil, WebviewWaitOutput{}, errSelectorRequired
}
if err := webviewInstance.WaitForSelector(input.Selector); err != nil {
log.Error("mcp: webview wait failed", "selector", input.Selector, "err", err)
return nil, WebviewWaitOutput{}, fmt.Errorf("failed to wait for selector: %w", err)
}
return nil, WebviewWaitOutput{
Success: true,
Message: fmt.Sprintf("Element found: %s", input.Selector),
}, nil
}

View file

@ -0,0 +1,452 @@
package mcp
import (
"testing"
"time"
"forge.lthn.ai/core/go-webview"
)
// skipIfShort skips webview tests in short mode (go test -short).
// Webview tool handlers require a running Chrome instance with
// --remote-debugging-port, which is not available in CI.
// Struct-level tests below are safe without Chrome, but any future
// tests that call webview tool handlers MUST use this guard.
func skipIfShort(t *testing.T) {
t.Helper()
if testing.Short() {
t.Skip("webview tests skipped in short mode (no Chrome available)")
}
}
// TestWebviewToolsRegistered_Good verifies that webview tools are registered with the MCP server.
func TestWebviewToolsRegistered_Good(t *testing.T) {
// Create a new MCP service - this should register all tools including webview
s, err := New()
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
// The server should have registered the webview tools
if s.server == nil {
t.Fatal("Server should not be nil")
}
// Verify the service was created with expected defaults
if s.logger == nil {
t.Error("Logger should not be nil")
}
}
// TestWebviewToolHandlers_RequiresChrome demonstrates the CI guard
// for tests that would require a running Chrome instance. Any future
// test that calls webview tool handlers (webviewConnect, webviewNavigate,
// etc.) should call skipIfShort(t) at the top.
func TestWebviewToolHandlers_RequiresChrome(t *testing.T) {
skipIfShort(t)
// This test verifies that webview tool handlers correctly reject
// calls when not connected to Chrome.
tmpDir := t.TempDir()
s, err := New(WithWorkspaceRoot(tmpDir))
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
ctx := t.Context()
// webview_navigate should fail without a connection
_, _, err = s.webviewNavigate(ctx, nil, WebviewNavigateInput{URL: "https://example.com"})
if err == nil {
t.Error("Expected error when navigating without a webview connection")
}
// webview_click should fail without a connection
_, _, err = s.webviewClick(ctx, nil, WebviewClickInput{Selector: "#btn"})
if err == nil {
t.Error("Expected error when clicking without a webview connection")
}
// webview_eval should fail without a connection
_, _, err = s.webviewEval(ctx, nil, WebviewEvalInput{Script: "1+1"})
if err == nil {
t.Error("Expected error when evaluating without a webview connection")
}
// webview_connect with invalid URL should fail
_, _, err = s.webviewConnect(ctx, nil, WebviewConnectInput{DebugURL: ""})
if err == nil {
t.Error("Expected error when connecting with empty debug URL")
}
}
// TestWebviewConnectInput_Good verifies the WebviewConnectInput struct has expected fields.
func TestWebviewConnectInput_Good(t *testing.T) {
input := WebviewConnectInput{
DebugURL: "http://localhost:9222",
Timeout: 30,
}
if input.DebugURL != "http://localhost:9222" {
t.Errorf("Expected debug_url 'http://localhost:9222', got %q", input.DebugURL)
}
if input.Timeout != 30 {
t.Errorf("Expected timeout 30, got %d", input.Timeout)
}
}
// TestWebviewNavigateInput_Good verifies the WebviewNavigateInput struct has expected fields.
func TestWebviewNavigateInput_Good(t *testing.T) {
input := WebviewNavigateInput{
URL: "https://example.com",
}
if input.URL != "https://example.com" {
t.Errorf("Expected URL 'https://example.com', got %q", input.URL)
}
}
// TestWebviewClickInput_Good verifies the WebviewClickInput struct has expected fields.
func TestWebviewClickInput_Good(t *testing.T) {
input := WebviewClickInput{
Selector: "#submit-button",
}
if input.Selector != "#submit-button" {
t.Errorf("Expected selector '#submit-button', got %q", input.Selector)
}
}
// TestWebviewTypeInput_Good verifies the WebviewTypeInput struct has expected fields.
func TestWebviewTypeInput_Good(t *testing.T) {
input := WebviewTypeInput{
Selector: "#email-input",
Text: "test@example.com",
}
if input.Selector != "#email-input" {
t.Errorf("Expected selector '#email-input', got %q", input.Selector)
}
if input.Text != "test@example.com" {
t.Errorf("Expected text 'test@example.com', got %q", input.Text)
}
}
// TestWebviewQueryInput_Good verifies the WebviewQueryInput struct has expected fields.
func TestWebviewQueryInput_Good(t *testing.T) {
input := WebviewQueryInput{
Selector: "div.container",
All: true,
}
if input.Selector != "div.container" {
t.Errorf("Expected selector 'div.container', got %q", input.Selector)
}
if !input.All {
t.Error("Expected all to be true")
}
}
// TestWebviewQueryInput_Defaults verifies default values are handled correctly.
func TestWebviewQueryInput_Defaults(t *testing.T) {
input := WebviewQueryInput{
Selector: ".test",
}
if input.All {
t.Error("Expected all to default to false")
}
}
// TestWebviewConsoleInput_Good verifies the WebviewConsoleInput struct has expected fields.
func TestWebviewConsoleInput_Good(t *testing.T) {
input := WebviewConsoleInput{
Clear: true,
}
if !input.Clear {
t.Error("Expected clear to be true")
}
}
// TestWebviewEvalInput_Good verifies the WebviewEvalInput struct has expected fields.
func TestWebviewEvalInput_Good(t *testing.T) {
input := WebviewEvalInput{
Script: "document.title",
}
if input.Script != "document.title" {
t.Errorf("Expected script 'document.title', got %q", input.Script)
}
}
// TestWebviewScreenshotInput_Good verifies the WebviewScreenshotInput struct has expected fields.
func TestWebviewScreenshotInput_Good(t *testing.T) {
input := WebviewScreenshotInput{
Format: "png",
}
if input.Format != "png" {
t.Errorf("Expected format 'png', got %q", input.Format)
}
}
// TestWebviewScreenshotInput_Defaults verifies default values are handled correctly.
func TestWebviewScreenshotInput_Defaults(t *testing.T) {
input := WebviewScreenshotInput{}
if input.Format != "" {
t.Errorf("Expected format to default to empty, got %q", input.Format)
}
}
// TestWebviewWaitInput_Good verifies the WebviewWaitInput struct has expected fields.
func TestWebviewWaitInput_Good(t *testing.T) {
input := WebviewWaitInput{
Selector: "#loading",
Timeout: 10,
}
if input.Selector != "#loading" {
t.Errorf("Expected selector '#loading', got %q", input.Selector)
}
if input.Timeout != 10 {
t.Errorf("Expected timeout 10, got %d", input.Timeout)
}
}
// TestWebviewConnectOutput_Good verifies the WebviewConnectOutput struct has expected fields.
func TestWebviewConnectOutput_Good(t *testing.T) {
output := WebviewConnectOutput{
Success: true,
Message: "Connected to Chrome DevTools",
}
if !output.Success {
t.Error("Expected success to be true")
}
if output.Message == "" {
t.Error("Expected message to be set")
}
}
// TestWebviewNavigateOutput_Good verifies the WebviewNavigateOutput struct has expected fields.
func TestWebviewNavigateOutput_Good(t *testing.T) {
output := WebviewNavigateOutput{
Success: true,
URL: "https://example.com",
}
if !output.Success {
t.Error("Expected success to be true")
}
if output.URL != "https://example.com" {
t.Errorf("Expected URL 'https://example.com', got %q", output.URL)
}
}
// TestWebviewQueryOutput_Good verifies the WebviewQueryOutput struct has expected fields.
func TestWebviewQueryOutput_Good(t *testing.T) {
output := WebviewQueryOutput{
Found: true,
Count: 3,
Elements: []WebviewElementInfo{
{
NodeID: 1,
TagName: "DIV",
Attributes: map[string]string{
"class": "container",
},
},
},
}
if !output.Found {
t.Error("Expected found to be true")
}
if output.Count != 3 {
t.Errorf("Expected count 3, got %d", output.Count)
}
if len(output.Elements) != 1 {
t.Fatalf("Expected 1 element, got %d", len(output.Elements))
}
if output.Elements[0].TagName != "DIV" {
t.Errorf("Expected tagName 'DIV', got %q", output.Elements[0].TagName)
}
}
// TestWebviewConsoleOutput_Good verifies the WebviewConsoleOutput struct has expected fields.
func TestWebviewConsoleOutput_Good(t *testing.T) {
output := WebviewConsoleOutput{
Messages: []WebviewConsoleMessage{
{
Type: "log",
Text: "Hello, world!",
Timestamp: "2024-01-01T00:00:00Z",
},
{
Type: "error",
Text: "An error occurred",
Timestamp: "2024-01-01T00:00:01Z",
URL: "https://example.com/script.js",
Line: 42,
},
},
Count: 2,
}
if output.Count != 2 {
t.Errorf("Expected count 2, got %d", output.Count)
}
if len(output.Messages) != 2 {
t.Fatalf("Expected 2 messages, got %d", len(output.Messages))
}
if output.Messages[0].Type != "log" {
t.Errorf("Expected type 'log', got %q", output.Messages[0].Type)
}
if output.Messages[1].Line != 42 {
t.Errorf("Expected line 42, got %d", output.Messages[1].Line)
}
}
// TestWebviewEvalOutput_Good verifies the WebviewEvalOutput struct has expected fields.
func TestWebviewEvalOutput_Good(t *testing.T) {
output := WebviewEvalOutput{
Success: true,
Result: "Example Page",
}
if !output.Success {
t.Error("Expected success to be true")
}
if output.Result != "Example Page" {
t.Errorf("Expected result 'Example Page', got %v", output.Result)
}
}
// TestWebviewEvalOutput_Error verifies the WebviewEvalOutput struct handles errors.
func TestWebviewEvalOutput_Error(t *testing.T) {
output := WebviewEvalOutput{
Success: false,
Error: "ReferenceError: foo is not defined",
}
if output.Success {
t.Error("Expected success to be false")
}
if output.Error == "" {
t.Error("Expected error message to be set")
}
}
// TestWebviewScreenshotOutput_Good verifies the WebviewScreenshotOutput struct has expected fields.
func TestWebviewScreenshotOutput_Good(t *testing.T) {
output := WebviewScreenshotOutput{
Success: true,
Data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
Format: "png",
}
if !output.Success {
t.Error("Expected success to be true")
}
if output.Data == "" {
t.Error("Expected data to be set")
}
if output.Format != "png" {
t.Errorf("Expected format 'png', got %q", output.Format)
}
}
// TestWebviewElementInfo_Good verifies the WebviewElementInfo struct has expected fields.
func TestWebviewElementInfo_Good(t *testing.T) {
elem := WebviewElementInfo{
NodeID: 123,
TagName: "INPUT",
Attributes: map[string]string{
"type": "text",
"name": "email",
"class": "form-control",
},
BoundingBox: &webview.BoundingBox{
X: 100,
Y: 200,
Width: 300,
Height: 50,
},
}
if elem.NodeID != 123 {
t.Errorf("Expected nodeId 123, got %d", elem.NodeID)
}
if elem.TagName != "INPUT" {
t.Errorf("Expected tagName 'INPUT', got %q", elem.TagName)
}
if elem.Attributes["type"] != "text" {
t.Errorf("Expected type attribute 'text', got %q", elem.Attributes["type"])
}
if elem.BoundingBox == nil {
t.Fatal("Expected bounding box to be set")
}
if elem.BoundingBox.Width != 300 {
t.Errorf("Expected width 300, got %f", elem.BoundingBox.Width)
}
}
// TestWebviewConsoleMessage_Good verifies the WebviewConsoleMessage struct has expected fields.
func TestWebviewConsoleMessage_Good(t *testing.T) {
msg := WebviewConsoleMessage{
Type: "error",
Text: "Failed to load resource",
Timestamp: time.Now().Format(time.RFC3339),
URL: "https://example.com/api/data",
Line: 1,
}
if msg.Type != "error" {
t.Errorf("Expected type 'error', got %q", msg.Type)
}
if msg.Text == "" {
t.Error("Expected text to be set")
}
if msg.URL == "" {
t.Error("Expected URL to be set")
}
}
// TestWebviewDisconnectInput_Good verifies the WebviewDisconnectInput struct exists.
func TestWebviewDisconnectInput_Good(t *testing.T) {
// WebviewDisconnectInput has no fields
input := WebviewDisconnectInput{}
_ = input // Just verify the struct exists
}
// TestWebviewDisconnectOutput_Good verifies the WebviewDisconnectOutput struct has expected fields.
func TestWebviewDisconnectOutput_Good(t *testing.T) {
output := WebviewDisconnectOutput{
Success: true,
Message: "Disconnected from Chrome DevTools",
}
if !output.Success {
t.Error("Expected success to be true")
}
if output.Message == "" {
t.Error("Expected message to be set")
}
}
// TestWebviewWaitOutput_Good verifies the WebviewWaitOutput struct has expected fields.
func TestWebviewWaitOutput_Good(t *testing.T) {
output := WebviewWaitOutput{
Success: true,
Message: "Element found: #login-form",
}
if !output.Success {
t.Error("Expected success to be true")
}
if output.Message == "" {
t.Error("Expected message to be set")
}
}

142
pkg/mcp/tools_ws.go Normal file
View file

@ -0,0 +1,142 @@
package mcp
import (
"context"
"fmt"
"net"
"net/http"
"forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go-ws"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// WSStartInput contains parameters for starting the WebSocket server.
type WSStartInput struct {
Addr string `json:"addr,omitempty"` // Address to listen on (default: ":8080")
}
// WSStartOutput contains the result of starting the WebSocket server.
type WSStartOutput struct {
Success bool `json:"success"`
Addr string `json:"addr"`
Message string `json:"message,omitempty"`
}
// WSInfoInput contains parameters for getting WebSocket hub info.
type WSInfoInput struct{}
// WSInfoOutput contains WebSocket hub statistics.
type WSInfoOutput struct {
Clients int `json:"clients"`
Channels int `json:"channels"`
}
// registerWSTools adds WebSocket tools to the MCP server.
// Returns false if WebSocket hub is not available.
func (s *Service) registerWSTools(server *mcp.Server) bool {
if s.wsHub == nil {
return false
}
mcp.AddTool(server, &mcp.Tool{
Name: "ws_start",
Description: "Start the WebSocket server for real-time process output streaming.",
}, s.wsStart)
mcp.AddTool(server, &mcp.Tool{
Name: "ws_info",
Description: "Get WebSocket hub statistics (connected clients and active channels).",
}, s.wsInfo)
return true
}
// wsStart handles the ws_start tool call.
func (s *Service) wsStart(ctx context.Context, req *mcp.CallToolRequest, input WSStartInput) (*mcp.CallToolResult, WSStartOutput, error) {
addr := input.Addr
if addr == "" {
addr = ":8080"
}
s.logger.Security("MCP tool execution", "tool", "ws_start", "addr", addr, "user", log.Username())
// Check if server is already running
if s.wsServer != nil {
return nil, WSStartOutput{
Success: true,
Addr: s.wsAddr,
Message: "WebSocket server already running",
}, nil
}
// Create HTTP server with WebSocket handler
mux := http.NewServeMux()
mux.HandleFunc("/ws", s.wsHub.Handler())
server := &http.Server{
Addr: addr,
Handler: mux,
}
// Start listener to get actual address
ln, err := net.Listen("tcp", addr)
if err != nil {
log.Error("mcp: ws start listen failed", "addr", addr, "err", err)
return nil, WSStartOutput{}, fmt.Errorf("failed to listen on %s: %w", addr, err)
}
actualAddr := ln.Addr().String()
s.wsServer = server
s.wsAddr = actualAddr
// Start server in background
go func() {
if err := server.Serve(ln); err != nil && err != http.ErrServerClosed {
log.Error("mcp: ws server error", "err", err)
}
}()
return nil, WSStartOutput{
Success: true,
Addr: actualAddr,
Message: fmt.Sprintf("WebSocket server started at ws://%s/ws", actualAddr),
}, nil
}
// wsInfo handles the ws_info tool call.
func (s *Service) wsInfo(ctx context.Context, req *mcp.CallToolRequest, input WSInfoInput) (*mcp.CallToolResult, WSInfoOutput, error) {
s.logger.Info("MCP tool execution", "tool", "ws_info", "user", log.Username())
stats := s.wsHub.Stats()
return nil, WSInfoOutput{
Clients: stats.Clients,
Channels: stats.Channels,
}, nil
}
// ProcessEventCallback is a callback function for process events.
// It can be registered with the process service to forward events to WebSocket.
type ProcessEventCallback struct {
hub *ws.Hub
}
// NewProcessEventCallback creates a callback that forwards process events to WebSocket.
func NewProcessEventCallback(hub *ws.Hub) *ProcessEventCallback {
return &ProcessEventCallback{hub: hub}
}
// OnProcessOutput forwards process output to WebSocket subscribers.
func (c *ProcessEventCallback) OnProcessOutput(processID string, line string) {
if c.hub != nil {
_ = c.hub.SendProcessOutput(processID, line)
}
}
// OnProcessStatus forwards process status changes to WebSocket subscribers.
func (c *ProcessEventCallback) OnProcessStatus(processID string, status string, exitCode int) {
if c.hub != nil {
_ = c.hub.SendProcessStatus(processID, status, exitCode)
}
}

174
pkg/mcp/tools_ws_test.go Normal file
View file

@ -0,0 +1,174 @@
package mcp
import (
"testing"
"forge.lthn.ai/core/go-ws"
)
// TestWSToolsRegistered_Good verifies that WebSocket tools are registered when hub is available.
func TestWSToolsRegistered_Good(t *testing.T) {
// Create a new MCP service without ws hub - tools should not be registered
s, err := New()
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
if s.wsHub != nil {
t.Error("WS hub should be nil by default")
}
if s.server == nil {
t.Fatal("Server should not be nil")
}
}
// TestWSStartInput_Good verifies the WSStartInput struct has expected fields.
func TestWSStartInput_Good(t *testing.T) {
input := WSStartInput{
Addr: ":9090",
}
if input.Addr != ":9090" {
t.Errorf("Expected addr ':9090', got %q", input.Addr)
}
}
// TestWSStartInput_Defaults verifies default values.
func TestWSStartInput_Defaults(t *testing.T) {
input := WSStartInput{}
if input.Addr != "" {
t.Errorf("Expected addr to default to empty, got %q", input.Addr)
}
}
// TestWSStartOutput_Good verifies the WSStartOutput struct has expected fields.
func TestWSStartOutput_Good(t *testing.T) {
output := WSStartOutput{
Success: true,
Addr: "127.0.0.1:8080",
Message: "WebSocket server started",
}
if !output.Success {
t.Error("Expected Success to be true")
}
if output.Addr != "127.0.0.1:8080" {
t.Errorf("Expected addr '127.0.0.1:8080', got %q", output.Addr)
}
if output.Message != "WebSocket server started" {
t.Errorf("Expected message 'WebSocket server started', got %q", output.Message)
}
}
// TestWSInfoInput_Good verifies the WSInfoInput struct exists (it's empty).
func TestWSInfoInput_Good(t *testing.T) {
input := WSInfoInput{}
_ = input // Just verify it compiles
}
// TestWSInfoOutput_Good verifies the WSInfoOutput struct has expected fields.
func TestWSInfoOutput_Good(t *testing.T) {
output := WSInfoOutput{
Clients: 5,
Channels: 3,
}
if output.Clients != 5 {
t.Errorf("Expected clients 5, got %d", output.Clients)
}
if output.Channels != 3 {
t.Errorf("Expected channels 3, got %d", output.Channels)
}
}
// TestWithWSHub_Good verifies the WithWSHub option.
func TestWithWSHub_Good(t *testing.T) {
hub := ws.NewHub()
s, err := New(WithWSHub(hub))
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
if s.wsHub != hub {
t.Error("Expected wsHub to be set")
}
}
// TestWithWSHub_Nil verifies the WithWSHub option with nil.
func TestWithWSHub_Nil(t *testing.T) {
s, err := New(WithWSHub(nil))
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
if s.wsHub != nil {
t.Error("Expected wsHub to be nil when passed nil")
}
}
// TestProcessEventCallback_Good verifies the ProcessEventCallback struct.
func TestProcessEventCallback_Good(t *testing.T) {
hub := ws.NewHub()
callback := NewProcessEventCallback(hub)
if callback.hub != hub {
t.Error("Expected callback hub to be set")
}
// Test that methods don't panic
callback.OnProcessOutput("proc-1", "test output")
callback.OnProcessStatus("proc-1", "exited", 0)
}
// TestProcessEventCallback_NilHub verifies the ProcessEventCallback with nil hub doesn't panic.
func TestProcessEventCallback_NilHub(t *testing.T) {
callback := NewProcessEventCallback(nil)
if callback.hub != nil {
t.Error("Expected callback hub to be nil")
}
// Test that methods don't panic with nil hub
callback.OnProcessOutput("proc-1", "test output")
callback.OnProcessStatus("proc-1", "exited", 0)
}
// TestServiceWSHub_Good verifies the WSHub getter method.
func TestServiceWSHub_Good(t *testing.T) {
hub := ws.NewHub()
s, err := New(WithWSHub(hub))
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
if s.WSHub() != hub {
t.Error("Expected WSHub() to return the hub")
}
}
// TestServiceWSHub_Nil verifies the WSHub getter returns nil when not configured.
func TestServiceWSHub_Nil(t *testing.T) {
s, err := New()
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
if s.WSHub() != nil {
t.Error("Expected WSHub() to return nil when not configured")
}
}
// TestServiceProcessService_Nil verifies the ProcessService getter returns nil when not configured.
func TestServiceProcessService_Nil(t *testing.T) {
s, err := New()
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
if s.ProcessService() != nil {
t.Error("Expected ProcessService() to return nil when not configured")
}
}

View file

@ -0,0 +1,742 @@
package mcp
import (
"bufio"
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
"context"
)
// jsonRPCRequest builds a raw JSON-RPC 2.0 request string with newline delimiter.
func jsonRPCRequest(id int, method string, params any) string {
msg := map[string]any{
"jsonrpc": "2.0",
"id": id,
"method": method,
}
if params != nil {
msg["params"] = params
}
data, _ := json.Marshal(msg)
return string(data) + "\n"
}
// jsonRPCNotification builds a raw JSON-RPC 2.0 notification (no id).
func jsonRPCNotification(method string) string {
msg := map[string]any{
"jsonrpc": "2.0",
"method": method,
}
data, _ := json.Marshal(msg)
return string(data) + "\n"
}
// readJSONRPCResponse reads a single line-delimited JSON-RPC response and
// returns the decoded map. It handles the case where the server sends a
// ping request interleaved with responses (responds to it and keeps reading).
func readJSONRPCResponse(t *testing.T, scanner *bufio.Scanner, conn net.Conn) map[string]any {
t.Helper()
for {
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
t.Fatalf("scanner error: %v", err)
}
t.Fatal("unexpected EOF reading JSON-RPC response")
}
line := scanner.Text()
var msg map[string]any
if err := json.Unmarshal([]byte(line), &msg); err != nil {
t.Fatalf("failed to unmarshal response: %v\nraw: %s", err, line)
}
// If this is a server-initiated request (e.g. ping), respond and keep reading.
if method, ok := msg["method"]; ok {
if id, hasID := msg["id"]; hasID {
resp := map[string]any{
"jsonrpc": "2.0",
"id": id,
"result": map[string]any{},
}
data, _ := json.Marshal(resp)
_, _ = conn.Write(append(data, '\n'))
_ = method // consume
continue
}
// Notification from server — ignore and keep reading
continue
}
return msg
}
}
// --- TCP E2E Tests ---
func TestTCPTransport_E2E_FullRoundTrip(t *testing.T) {
// Create a temp workspace with a known file
tmpDir := t.TempDir()
testContent := "hello from tcp e2e test"
if err := os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte(testContent), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
s, err := New(WithWorkspaceRoot(tmpDir))
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Start TCP server on a random port
errCh := make(chan error, 1)
go func() {
errCh <- s.ServeTCP(ctx, "127.0.0.1:0")
}()
// Wait for the server to start and get the actual address.
// ServeTCP creates its own listener internally, so we need to probe.
// We'll retry connecting for up to 2 seconds.
var conn net.Conn
deadline := time.Now().Add(2 * time.Second)
// Since ServeTCP binds :0, we can't predict the port. Instead, create
// our own listener to find a free port, close it, then pass that port
// to ServeTCP. This is a known race, but fine for tests.
cancel()
<-errCh
// Restart with a known port: find a free port first
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Failed to find free port: %v", err)
}
addr := ln.Addr().String()
ln.Close()
ctx2, cancel2 := context.WithCancel(context.Background())
defer cancel2()
errCh2 := make(chan error, 1)
go func() {
errCh2 <- s.ServeTCP(ctx2, addr)
}()
// Wait for server to accept connections
deadline = time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
conn, err = net.DialTimeout("tcp", addr, 200*time.Millisecond)
if err == nil {
break
}
time.Sleep(50 * time.Millisecond)
}
if err != nil {
t.Fatalf("Failed to connect to TCP server at %s: %v", addr, err)
}
defer conn.Close()
// Set a read deadline to avoid hanging
conn.SetDeadline(time.Now().Add(10 * time.Second))
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, 64*1024), 10*1024*1024)
// Step 1: Send initialize request
initReq := jsonRPCRequest(1, "initialize", map[string]any{
"protocolVersion": "2024-11-05",
"capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "TestClient", "version": "1.0.0"},
})
if _, err := conn.Write([]byte(initReq)); err != nil {
t.Fatalf("Failed to send initialize: %v", err)
}
// Read initialize response
initResp := readJSONRPCResponse(t, scanner, conn)
if initResp["error"] != nil {
t.Fatalf("Initialize returned error: %v", initResp["error"])
}
result, ok := initResp["result"].(map[string]any)
if !ok {
t.Fatalf("Expected result object, got %T", initResp["result"])
}
serverInfo, _ := result["serverInfo"].(map[string]any)
if serverInfo["name"] != "core-cli" {
t.Errorf("Expected server name 'core-cli', got %v", serverInfo["name"])
}
// Step 2: Send notifications/initialized
if _, err := conn.Write([]byte(jsonRPCNotification("notifications/initialized"))); err != nil {
t.Fatalf("Failed to send initialized notification: %v", err)
}
// Step 3: Send tools/list
if _, err := conn.Write([]byte(jsonRPCRequest(2, "tools/list", nil))); err != nil {
t.Fatalf("Failed to send tools/list: %v", err)
}
toolsResp := readJSONRPCResponse(t, scanner, conn)
if toolsResp["error"] != nil {
t.Fatalf("tools/list returned error: %v", toolsResp["error"])
}
toolsResult, ok := toolsResp["result"].(map[string]any)
if !ok {
t.Fatalf("Expected result object for tools/list, got %T", toolsResp["result"])
}
tools, ok := toolsResult["tools"].([]any)
if !ok || len(tools) == 0 {
t.Fatal("Expected non-empty tools list")
}
// Verify file_read is among the tools
foundFileRead := false
for _, tool := range tools {
toolMap, _ := tool.(map[string]any)
if toolMap["name"] == "file_read" {
foundFileRead = true
break
}
}
if !foundFileRead {
t.Error("Expected file_read tool in tools/list response")
}
// Step 4: Call file_read
callReq := jsonRPCRequest(3, "tools/call", map[string]any{
"name": "file_read",
"arguments": map[string]any{"path": "test.txt"},
})
if _, err := conn.Write([]byte(callReq)); err != nil {
t.Fatalf("Failed to send tools/call: %v", err)
}
callResp := readJSONRPCResponse(t, scanner, conn)
if callResp["error"] != nil {
t.Fatalf("tools/call file_read returned error: %v", callResp["error"])
}
callResult, ok := callResp["result"].(map[string]any)
if !ok {
t.Fatalf("Expected result object for tools/call, got %T", callResp["result"])
}
// The MCP SDK wraps tool results in content array
content, ok := callResult["content"].([]any)
if !ok || len(content) == 0 {
t.Fatal("Expected non-empty content in tools/call response")
}
firstContent, _ := content[0].(map[string]any)
text, _ := firstContent["text"].(string)
if !strings.Contains(text, testContent) {
t.Errorf("Expected file content to contain %q, got %q", testContent, text)
}
// Graceful shutdown
cancel2()
err = <-errCh2
if err != nil {
t.Errorf("ServeTCP returned error: %v", err)
}
}
func TestTCPTransport_E2E_FileWrite(t *testing.T) {
tmpDir := t.TempDir()
s, err := New(WithWorkspaceRoot(tmpDir))
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Find free port
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Failed to find free port: %v", err)
}
addr := ln.Addr().String()
ln.Close()
errCh := make(chan error, 1)
go func() {
errCh <- s.ServeTCP(ctx, addr)
}()
// Connect
var conn net.Conn
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
conn, err = net.DialTimeout("tcp", addr, 200*time.Millisecond)
if err == nil {
break
}
time.Sleep(50 * time.Millisecond)
}
if err != nil {
t.Fatalf("Failed to connect: %v", err)
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(10 * time.Second))
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, 64*1024), 10*1024*1024)
// Initialize handshake
conn.Write([]byte(jsonRPCRequest(1, "initialize", map[string]any{
"protocolVersion": "2024-11-05",
"capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "TestClient", "version": "1.0.0"},
})))
readJSONRPCResponse(t, scanner, conn)
conn.Write([]byte(jsonRPCNotification("notifications/initialized")))
// Write a file
writeContent := "written via tcp transport"
conn.Write([]byte(jsonRPCRequest(2, "tools/call", map[string]any{
"name": "file_write",
"arguments": map[string]any{"path": "tcp-written.txt", "content": writeContent},
})))
writeResp := readJSONRPCResponse(t, scanner, conn)
if writeResp["error"] != nil {
t.Fatalf("file_write returned error: %v", writeResp["error"])
}
// Verify file on disk
diskContent, err := os.ReadFile(filepath.Join(tmpDir, "tcp-written.txt"))
if err != nil {
t.Fatalf("Failed to read written file: %v", err)
}
if string(diskContent) != writeContent {
t.Errorf("Expected %q on disk, got %q", writeContent, string(diskContent))
}
cancel()
<-errCh
}
// --- Unix Socket E2E Tests ---
// shortSocketPath returns a Unix socket path under /tmp that fits within
// the macOS 104-byte sun_path limit. t.TempDir() paths on macOS are
// often too long (>104 bytes) for Unix sockets.
func shortSocketPath(t *testing.T, suffix string) string {
t.Helper()
path := fmt.Sprintf("/tmp/mcp-test-%s-%d.sock", suffix, os.Getpid())
t.Cleanup(func() { os.Remove(path) })
return path
}
func TestUnixTransport_E2E_FullRoundTrip(t *testing.T) {
// Create a temp workspace with a known file
tmpDir := t.TempDir()
testContent := "hello from unix e2e test"
if err := os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte(testContent), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
s, err := New(WithWorkspaceRoot(tmpDir))
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Use a short socket path to avoid macOS 104-byte sun_path limit
socketPath := shortSocketPath(t, "full")
errCh := make(chan error, 1)
go func() {
errCh <- s.ServeUnix(ctx, socketPath)
}()
// Wait for socket to appear
var conn net.Conn
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
conn, err = net.DialTimeout("unix", socketPath, 200*time.Millisecond)
if err == nil {
break
}
time.Sleep(50 * time.Millisecond)
}
if err != nil {
t.Fatalf("Failed to connect to Unix socket at %s: %v", socketPath, err)
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(10 * time.Second))
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, 64*1024), 10*1024*1024)
// Step 1: Initialize
conn.Write([]byte(jsonRPCRequest(1, "initialize", map[string]any{
"protocolVersion": "2024-11-05",
"capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "TestClient", "version": "1.0.0"},
})))
initResp := readJSONRPCResponse(t, scanner, conn)
if initResp["error"] != nil {
t.Fatalf("Initialize returned error: %v", initResp["error"])
}
// Step 2: Send initialized notification
conn.Write([]byte(jsonRPCNotification("notifications/initialized")))
// Step 3: tools/list
conn.Write([]byte(jsonRPCRequest(2, "tools/list", nil)))
toolsResp := readJSONRPCResponse(t, scanner, conn)
if toolsResp["error"] != nil {
t.Fatalf("tools/list returned error: %v", toolsResp["error"])
}
toolsResult, ok := toolsResp["result"].(map[string]any)
if !ok {
t.Fatalf("Expected result object, got %T", toolsResp["result"])
}
tools, ok := toolsResult["tools"].([]any)
if !ok || len(tools) == 0 {
t.Fatal("Expected non-empty tools list")
}
// Step 4: Call file_read
conn.Write([]byte(jsonRPCRequest(3, "tools/call", map[string]any{
"name": "file_read",
"arguments": map[string]any{"path": "test.txt"},
})))
callResp := readJSONRPCResponse(t, scanner, conn)
if callResp["error"] != nil {
t.Fatalf("tools/call file_read returned error: %v", callResp["error"])
}
callResult, ok := callResp["result"].(map[string]any)
if !ok {
t.Fatalf("Expected result object, got %T", callResp["result"])
}
content, ok := callResult["content"].([]any)
if !ok || len(content) == 0 {
t.Fatal("Expected non-empty content")
}
firstContent, _ := content[0].(map[string]any)
text, _ := firstContent["text"].(string)
if !strings.Contains(text, testContent) {
t.Errorf("Expected content to contain %q, got %q", testContent, text)
}
// Graceful shutdown
cancel()
err = <-errCh
if err != nil {
t.Errorf("ServeUnix returned error: %v", err)
}
// Verify socket file is cleaned up
if _, err := os.Stat(socketPath); !os.IsNotExist(err) {
t.Error("Expected socket file to be cleaned up after shutdown")
}
}
func TestUnixTransport_E2E_DirList(t *testing.T) {
tmpDir := t.TempDir()
// Create some files and dirs
os.MkdirAll(filepath.Join(tmpDir, "subdir"), 0755)
os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("one"), 0644)
os.WriteFile(filepath.Join(tmpDir, "subdir", "file2.txt"), []byte("two"), 0644)
s, err := New(WithWorkspaceRoot(tmpDir))
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
socketPath := shortSocketPath(t, "dir")
errCh := make(chan error, 1)
go func() {
errCh <- s.ServeUnix(ctx, socketPath)
}()
var conn net.Conn
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
conn, err = net.DialTimeout("unix", socketPath, 200*time.Millisecond)
if err == nil {
break
}
time.Sleep(50 * time.Millisecond)
}
if err != nil {
t.Fatalf("Failed to connect: %v", err)
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(10 * time.Second))
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, 64*1024), 10*1024*1024)
// Initialize
conn.Write([]byte(jsonRPCRequest(1, "initialize", map[string]any{
"protocolVersion": "2024-11-05",
"capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "TestClient", "version": "1.0.0"},
})))
readJSONRPCResponse(t, scanner, conn)
conn.Write([]byte(jsonRPCNotification("notifications/initialized")))
// Call dir_list on root
conn.Write([]byte(jsonRPCRequest(2, "tools/call", map[string]any{
"name": "dir_list",
"arguments": map[string]any{"path": "."},
})))
dirResp := readJSONRPCResponse(t, scanner, conn)
if dirResp["error"] != nil {
t.Fatalf("dir_list returned error: %v", dirResp["error"])
}
dirResult, ok := dirResp["result"].(map[string]any)
if !ok {
t.Fatalf("Expected result object, got %T", dirResp["result"])
}
dirContent, ok := dirResult["content"].([]any)
if !ok || len(dirContent) == 0 {
t.Fatal("Expected non-empty content in dir_list response")
}
// The response content should mention our files
firstItem, _ := dirContent[0].(map[string]any)
text, _ := firstItem["text"].(string)
if !strings.Contains(text, "file1.txt") && !strings.Contains(text, "subdir") {
t.Errorf("Expected dir_list to contain file1.txt or subdir, got: %s", text)
}
cancel()
<-errCh
}
// --- Stdio Transport Tests ---
func TestStdioTransport_Documented_Skip(t *testing.T) {
// The MCP SDK's StdioTransport binds directly to os.Stdin and os.Stdout,
// with no way to inject custom io.Reader/io.Writer. Testing stdio transport
// would require spawning the binary as a subprocess and piping JSON-RPC
// messages through its stdin/stdout.
//
// Since the core MCP protocol handling is identical across all transports
// (the transport layer only handles framing), and we thoroughly test the
// protocol via TCP and Unix socket e2e tests, the stdio transport is
// effectively covered. The only untested code path is the StdioTransport
// adapter itself, which is a thin wrapper in the upstream SDK.
//
// If process-level testing is needed in the future, the approach would be:
// 1. Build the binary: `go build -o /tmp/mcp-test ./cmd/...`
// 2. Spawn it: exec.Command("/tmp/mcp-test", "mcp", "serve")
// 3. Pipe JSON-RPC to stdin, read from stdout
// 4. Verify responses match expected protocol
t.Skip("stdio transport requires process spawning; protocol is covered by TCP and Unix e2e tests")
}
// --- Helper: verify a specific tool exists in tools/list response ---
func assertToolExists(t *testing.T, tools []any, name string) {
t.Helper()
for _, tool := range tools {
toolMap, _ := tool.(map[string]any)
if toolMap["name"] == name {
return
}
}
toolNames := make([]string, 0, len(tools))
for _, tool := range tools {
toolMap, _ := tool.(map[string]any)
if n, ok := toolMap["name"].(string); ok {
toolNames = append(toolNames, n)
}
}
t.Errorf("Expected tool %q in list, got: %v", name, toolNames)
}
func TestTCPTransport_E2E_ToolsDiscovery(t *testing.T) {
tmpDir := t.TempDir()
s, err := New(WithWorkspaceRoot(tmpDir))
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Failed to find free port: %v", err)
}
addr := ln.Addr().String()
ln.Close()
errCh := make(chan error, 1)
go func() {
errCh <- s.ServeTCP(ctx, addr)
}()
var conn net.Conn
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
conn, err = net.DialTimeout("tcp", addr, 200*time.Millisecond)
if err == nil {
break
}
time.Sleep(50 * time.Millisecond)
}
if err != nil {
t.Fatalf("Failed to connect: %v", err)
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(10 * time.Second))
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, 64*1024), 10*1024*1024)
// Initialize
conn.Write([]byte(jsonRPCRequest(1, "initialize", map[string]any{
"protocolVersion": "2024-11-05",
"capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "TestClient", "version": "1.0.0"},
})))
readJSONRPCResponse(t, scanner, conn)
conn.Write([]byte(jsonRPCNotification("notifications/initialized")))
// Get tools list
conn.Write([]byte(jsonRPCRequest(2, "tools/list", nil)))
toolsResp := readJSONRPCResponse(t, scanner, conn)
if toolsResp["error"] != nil {
t.Fatalf("tools/list error: %v", toolsResp["error"])
}
toolsResult, _ := toolsResp["result"].(map[string]any)
tools, _ := toolsResult["tools"].([]any)
// Verify all core tools are registered
expectedTools := []string{
"file_read", "file_write", "file_delete", "file_rename",
"file_exists", "file_edit", "dir_list", "dir_create",
"lang_detect", "lang_list",
}
for _, name := range expectedTools {
assertToolExists(t, tools, name)
}
// Log total tool count for visibility
t.Logf("Server registered %d tools", len(tools))
cancel()
<-errCh
}
func TestTCPTransport_E2E_ErrorHandling(t *testing.T) {
tmpDir := t.TempDir()
s, err := New(WithWorkspaceRoot(tmpDir))
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Failed to find free port: %v", err)
}
addr := ln.Addr().String()
ln.Close()
errCh := make(chan error, 1)
go func() {
errCh <- s.ServeTCP(ctx, addr)
}()
var conn net.Conn
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
conn, err = net.DialTimeout("tcp", addr, 200*time.Millisecond)
if err == nil {
break
}
time.Sleep(50 * time.Millisecond)
}
if err != nil {
t.Fatalf("Failed to connect: %v", err)
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(10 * time.Second))
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, 64*1024), 10*1024*1024)
// Initialize
conn.Write([]byte(jsonRPCRequest(1, "initialize", map[string]any{
"protocolVersion": "2024-11-05",
"capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "TestClient", "version": "1.0.0"},
})))
readJSONRPCResponse(t, scanner, conn)
conn.Write([]byte(jsonRPCNotification("notifications/initialized")))
// Try to read a nonexistent file
conn.Write([]byte(jsonRPCRequest(2, "tools/call", map[string]any{
"name": "file_read",
"arguments": map[string]any{"path": "nonexistent.txt"},
})))
errResp := readJSONRPCResponse(t, scanner, conn)
// The MCP SDK wraps tool errors as isError content, not JSON-RPC errors.
// Check both possibilities.
if errResp["error"] != nil {
// JSON-RPC level error — this is acceptable
t.Logf("Got JSON-RPC error for nonexistent file: %v", errResp["error"])
} else {
errResult, _ := errResp["result"].(map[string]any)
isError, _ := errResult["isError"].(bool)
if !isError {
// Check content for error indicator
content, _ := errResult["content"].([]any)
if len(content) > 0 {
firstContent, _ := content[0].(map[string]any)
text, _ := firstContent["text"].(string)
t.Logf("Tool response for nonexistent file: %s", text)
}
}
}
// Verify tools/call without params returns an error
conn.Write([]byte(jsonRPCRequest(3, "tools/call", nil)))
noParamsResp := readJSONRPCResponse(t, scanner, conn)
if noParamsResp["error"] == nil {
t.Log("tools/call without params did not return JSON-RPC error (SDK may handle differently)")
} else {
errObj, _ := noParamsResp["error"].(map[string]any)
code, _ := errObj["code"].(float64)
if code != -32600 {
t.Logf("tools/call without params returned error code: %v", code)
}
}
cancel()
<-errCh
}
// Suppress "unused import" for fmt — used in helpers
var _ = fmt.Sprintf

View file

@ -0,0 +1,15 @@
package mcp
import (
"context"
"forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// ServeStdio starts the MCP server over stdin/stdout.
// This is the default transport for CLI integrations.
func (s *Service) ServeStdio(ctx context.Context) error {
s.logger.Info("MCP Stdio server starting", "user", log.Username())
return s.server.Run(ctx, &mcp.StdioTransport{})
}

177
pkg/mcp/transport_tcp.go Normal file
View file

@ -0,0 +1,177 @@
package mcp
import (
"bufio"
"context"
"fmt"
"io"
"net"
"os"
"sync"
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// DefaultTCPAddr is the default address for the MCP TCP server.
const DefaultTCPAddr = "127.0.0.1:9100"
// diagMu protects diagWriter from concurrent access across tests and goroutines.
var diagMu sync.Mutex
// diagWriter is the destination for warning and diagnostic messages.
// Use diagPrintf to write to it safely.
var diagWriter io.Writer = os.Stderr
// diagPrintf writes a formatted message to diagWriter under the mutex.
func diagPrintf(format string, args ...any) {
diagMu.Lock()
defer diagMu.Unlock()
fmt.Fprintf(diagWriter, format, args...)
}
// setDiagWriter swaps the diagnostic writer and returns the previous one.
// Used by tests to capture output without racing.
func setDiagWriter(w io.Writer) io.Writer {
diagMu.Lock()
defer diagMu.Unlock()
old := diagWriter
diagWriter = w
return old
}
// maxMCPMessageSize is the maximum size for MCP JSON-RPC messages (10 MB).
const maxMCPMessageSize = 10 * 1024 * 1024
// TCPTransport manages a TCP listener for MCP.
type TCPTransport struct {
addr string
listener net.Listener
}
// NewTCPTransport creates a new TCP transport listener.
// It listens on the provided address (e.g. "localhost:9100").
// Defaults to 127.0.0.1 when the host component is empty (e.g. ":9100").
// Emits a security warning when explicitly binding to 0.0.0.0 (all interfaces).
func NewTCPTransport(addr string) (*TCPTransport, error) {
host, port, _ := net.SplitHostPort(addr)
if host == "" {
addr = net.JoinHostPort("127.0.0.1", port)
} else if host == "0.0.0.0" {
diagPrintf("WARNING: MCP TCP server binding to all interfaces (%s). Use 127.0.0.1 for local-only access.\n", addr)
}
listener, err := net.Listen("tcp", addr)
if err != nil {
return nil, err
}
return &TCPTransport{addr: addr, listener: listener}, nil
}
// ServeTCP starts a TCP server for the MCP service.
// It accepts connections and spawns a new MCP server session for each connection.
func (s *Service) ServeTCP(ctx context.Context, addr string) error {
t, err := NewTCPTransport(addr)
if err != nil {
return err
}
defer func() { _ = t.listener.Close() }()
// Close listener when context is cancelled to unblock Accept
go func() {
<-ctx.Done()
_ = t.listener.Close()
}()
if addr == "" {
addr = t.listener.Addr().String()
}
diagPrintf("MCP TCP server listening on %s\n", addr)
for {
conn, err := t.listener.Accept()
if err != nil {
select {
case <-ctx.Done():
return nil
default:
diagPrintf("Accept error: %v\n", err)
continue
}
}
go s.handleConnection(ctx, conn)
}
}
func (s *Service) handleConnection(ctx context.Context, conn net.Conn) {
// Note: We don't defer conn.Close() here because it's closed by the Server/Transport
// Create new server instance for this connection
impl := &mcp.Implementation{
Name: "core-cli",
Version: "0.1.0",
}
server := mcp.NewServer(impl, nil)
s.registerTools(server)
// Create transport for this connection
transport := &connTransport{conn: conn}
// Run server (blocks until connection closed)
// Server.Run calls Connect, then Read loop.
if err := server.Run(ctx, transport); err != nil {
diagPrintf("Connection error: %v\n", err)
}
}
// connTransport adapts net.Conn to mcp.Transport
type connTransport struct {
conn net.Conn
}
func (t *connTransport) Connect(ctx context.Context) (mcp.Connection, error) {
scanner := bufio.NewScanner(t.conn)
scanner.Buffer(make([]byte, 64*1024), maxMCPMessageSize)
return &connConnection{
conn: t.conn,
scanner: scanner,
}, nil
}
// connConnection implements mcp.Connection
type connConnection struct {
conn net.Conn
scanner *bufio.Scanner
}
func (c *connConnection) Read(ctx context.Context) (jsonrpc.Message, error) {
// Blocks until line is read
if !c.scanner.Scan() {
if err := c.scanner.Err(); err != nil {
return nil, err
}
// EOF - connection closed cleanly
return nil, io.EOF
}
line := c.scanner.Bytes()
return jsonrpc.DecodeMessage(line)
}
func (c *connConnection) Write(ctx context.Context, msg jsonrpc.Message) error {
data, err := jsonrpc.EncodeMessage(msg)
if err != nil {
return err
}
// Append newline for line-delimited JSON
data = append(data, '\n')
_, err = c.conn.Write(data)
return err
}
func (c *connConnection) Close() error {
return c.conn.Close()
}
func (c *connConnection) SessionID() string {
return "tcp-session" // Unique ID might be better, but optional
}

View file

@ -0,0 +1,184 @@
package mcp
import (
"bytes"
"context"
"net"
"os"
"strings"
"testing"
"time"
)
func TestNewTCPTransport_Defaults(t *testing.T) {
// Test that empty string gets replaced with default address constant
// Note: We can't actually bind to 9100 as it may be in use,
// so we verify the address is set correctly before Listen is called
if DefaultTCPAddr != "127.0.0.1:9100" {
t.Errorf("Expected default constant 127.0.0.1:9100, got %s", DefaultTCPAddr)
}
// Test with a dynamic port to verify transport creation works
tr, err := NewTCPTransport("127.0.0.1:0")
if err != nil {
t.Fatalf("Failed to create transport with dynamic port: %v", err)
}
defer tr.listener.Close()
// Verify we got a valid address
if tr.addr != "127.0.0.1:0" {
t.Errorf("Expected address to be set, got %s", tr.addr)
}
}
func TestNewTCPTransport_Warning(t *testing.T) {
// Capture warning output via setDiagWriter (mutex-protected, no race).
var buf bytes.Buffer
old := setDiagWriter(&buf)
defer setDiagWriter(old)
// Trigger warning
tr, err := NewTCPTransport("0.0.0.0:9101")
if err != nil {
t.Fatalf("Failed to create transport: %v", err)
}
defer tr.listener.Close()
output := buf.String()
if !strings.Contains(output, "WARNING") {
t.Error("Expected warning for binding to 0.0.0.0, but didn't find it in stderr")
}
}
func TestServeTCP_Connection(t *testing.T) {
s, err := New()
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Use a random port for testing to avoid collisions
addr := "127.0.0.1:0"
// Create transport first to get the actual address if we use :0
tr, err := NewTCPTransport(addr)
if err != nil {
t.Fatalf("Failed to create transport: %v", err)
}
actualAddr := tr.listener.Addr().String()
tr.listener.Close() // Close it so ServeTCP can re-open it or use the same address
// Start server in background
errCh := make(chan error, 1)
go func() {
errCh <- s.ServeTCP(ctx, actualAddr)
}()
// Give it a moment to start
time.Sleep(100 * time.Millisecond)
// Connect to the server
conn, err := net.Dial("tcp", actualAddr)
if err != nil {
t.Fatalf("Failed to connect to server: %v", err)
}
defer conn.Close()
// Verify we can write to it
_, err = conn.Write([]byte("{}\n"))
if err != nil {
t.Errorf("Failed to write to connection: %v", err)
}
// Shutdown server
cancel()
err = <-errCh
if err != nil {
t.Errorf("ServeTCP returned error: %v", err)
}
}
func TestRun_TCPTrigger(t *testing.T) {
s, err := New()
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Set MCP_ADDR to empty to trigger default TCP
os.Setenv("MCP_ADDR", "")
defer os.Unsetenv("MCP_ADDR")
// We use a random port for testing, but Run will try to use 127.0.0.1:9100 by default if we don't override.
// Since 9100 might be in use, we'll set MCP_ADDR to use :0 (random port)
os.Setenv("MCP_ADDR", "127.0.0.1:0")
errCh := make(chan error, 1)
go func() {
errCh <- s.Run(ctx)
}()
// Give it a moment to start
time.Sleep(100 * time.Millisecond)
// Since we can't easily get the actual port used by Run (it's internal),
// we just verify it didn't immediately fail.
select {
case err := <-errCh:
t.Fatalf("Run failed immediately: %v", err)
default:
// still running, which is good
}
cancel()
_ = <-errCh
}
func TestServeTCP_MultipleConnections(t *testing.T) {
s, err := New()
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
addr := "127.0.0.1:0"
tr, err := NewTCPTransport(addr)
if err != nil {
t.Fatalf("Failed to create transport: %v", err)
}
actualAddr := tr.listener.Addr().String()
tr.listener.Close()
errCh := make(chan error, 1)
go func() {
errCh <- s.ServeTCP(ctx, actualAddr)
}()
time.Sleep(100 * time.Millisecond)
// Connect multiple clients
const numClients = 3
for i := range numClients {
conn, err := net.Dial("tcp", actualAddr)
if err != nil {
t.Fatalf("Client %d failed to connect: %v", i, err)
}
defer conn.Close()
_, err = conn.Write([]byte("{}\n"))
if err != nil {
t.Errorf("Client %d failed to write: %v", i, err)
}
}
cancel()
err = <-errCh
if err != nil {
t.Errorf("ServeTCP returned error: %v", err)
}
}

52
pkg/mcp/transport_unix.go Normal file
View file

@ -0,0 +1,52 @@
package mcp
import (
"context"
"net"
"os"
"forge.lthn.ai/core/go-log"
)
// ServeUnix starts a Unix domain socket server for the MCP service.
// The socket file is created at the given path and removed on shutdown.
// It accepts connections and spawns a new MCP server session for each connection.
func (s *Service) ServeUnix(ctx context.Context, socketPath string) error {
// Clean up any stale socket file
if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) {
s.logger.Warn("Failed to remove stale socket", "path", socketPath, "err", err)
}
listener, err := net.Listen("unix", socketPath)
if err != nil {
return err
}
defer func() {
_ = listener.Close()
_ = os.Remove(socketPath)
}()
// Close listener when context is cancelled to unblock Accept
go func() {
<-ctx.Done()
_ = listener.Close()
}()
s.logger.Security("MCP Unix server listening", "path", socketPath, "user", log.Username())
for {
conn, err := listener.Accept()
if err != nil {
select {
case <-ctx.Done():
return nil
default:
s.logger.Error("MCP Unix accept error", "err", err, "user", log.Username())
continue
}
}
s.logger.Security("MCP Unix connection accepted", "user", log.Username())
go s.handleConnection(ctx, conn)
}
}

24
src/php/.editorconfig Normal file
View file

@ -0,0 +1,24 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[*.{js,jsx,ts,tsx,vue}]
indent_size = 2
[*.json]
indent_size = 2
[docker-compose.yml]
indent_size = 2

76
src/php/.env.example Normal file
View file

@ -0,0 +1,76 @@
APP_NAME="Core PHP App"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_TIMEZONE=UTC
APP_URL=http://localhost
APP_LOCALE=en_GB
APP_FALLBACK_LOCALE=en_GB
APP_FAKER_LOCALE=en_GB
APP_MAINTENANCE_DRIVER=file
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=core
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# Core PHP Framework
CORE_CACHE_DISCOVERY=true
# CDN Configuration (optional)
CDN_ENABLED=false
CDN_DRIVER=bunny
BUNNYCDN_API_KEY=
BUNNYCDN_STORAGE_ZONE=
BUNNYCDN_PULL_ZONE=
# Flux Pro (optional)
FLUX_LICENSE_KEY=

View file

@ -0,0 +1,57 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: PHP ${{ matrix.php }}
runs-on: ubuntu-latest
container:
image: lthn/build:php-${{ matrix.php }}
strategy:
fail-fast: true
matrix:
php: ["8.3", "8.4"]
steps:
- uses: actions/checkout@v4
- name: Clone sister packages
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "Cloning php-framework into ../php-framework"
git clone --depth 1 \
"https://x-access-token:${GITHUB_TOKEN}@forge.lthn.ai/core/php-framework.git" \
../php-framework
ls -la ../php-framework/composer.json
- name: Configure path repositories
run: |
composer config repositories.core path ../php-framework --no-interaction
- name: Install dependencies
run: composer install --prefer-dist --no-interaction --no-progress
- name: Run Pint
run: |
if [ -f vendor/bin/pint ]; then
vendor/bin/pint --test
else
echo "Pint not installed, skipping"
fi
- name: Run tests
run: |
if [ -f vendor/bin/pest ]; then
vendor/bin/pest --ci --coverage
elif [ -f vendor/bin/phpunit ]; then
vendor/bin/phpunit --coverage-text
else
echo "No test runner found, skipping"
fi

View file

@ -0,0 +1,38 @@
name: Publish Composer Package
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Create package archive
run: |
apt-get update && apt-get install -y zip
zip -r package.zip . \
-x ".forgejo/*" \
-x ".git/*" \
-x "tests/*" \
-x "docker/*" \
-x "*.yaml" \
-x "infection.json5" \
-x "phpstan.neon" \
-x "phpunit.xml" \
-x "psalm.xml" \
-x "rector.php" \
-x "TODO.md" \
-x "ROADMAP.md" \
-x "CONTRIBUTING.md" \
-x "package.json" \
-x "package-lock.json"
- name: Publish to Forgejo Composer registry
run: |
curl --fail --user "${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_TOKEN }}" \
--upload-file package.zip \
"https://forge.lthn.ai/api/packages/core/composer?version=${FORGEJO_REF_NAME#v}"

View file

@ -0,0 +1,12 @@
{
"$schema": "https://schema.gemini.google.dev/settings.json",
"codeAssist": {
"enabled": true,
"contextFiles": [
"GEMINI.md",
"CLAUDE.md",
"composer.json",
"config/core.php"
]
}
}

4
src/php/.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1,4 @@
# GitHub Sponsors configuration
# Support Trees for the Future through our fundraiser
custom: ["https://donate.trees.org/-/NPMMSVUP?member=SWZTDDWH"]

83
src/php/.github/copilot-instructions.md vendored Normal file
View file

@ -0,0 +1,83 @@
# GitHub Copilot Instructions
## Project Overview
This is a Laravel application using the Core PHP Framework - a modular monolith architecture with event-driven module registration.
## Architecture
### Module System
- Modules live in `app/Mod/{ModuleName}/`
- Each module has a `Boot.php` class with event listeners
- Events: `WebRoutesRegistering`, `ApiRoutesRegistering`, `AdminPanelBooting`
### Example Boot.php
```php
<?php
namespace App\Mod\Blog;
use Core\Events\WebRoutesRegistering;
use Core\Events\ApiRoutesRegistering;
class Boot
{
public static array $listens = [
WebRoutesRegistering::class => 'onWebRoutes',
ApiRoutesRegistering::class => 'onApiRoutes',
];
public function onWebRoutes(WebRoutesRegistering $event): void
{
$event->routes(fn() => require __DIR__.'/Routes/web.php');
$event->views('blog', __DIR__.'/Views');
}
}
```
## Coding Standards
### Language
- Use UK English (colour, organisation, centre, behaviour)
- No American spellings (color, organization, center, behavior)
### PHP Style
- PSR-12 with Laravel conventions
- Strict types: `declare(strict_types=1);`
- Type hints on all parameters and return types
- Final classes by default unless inheritance is intended
### Naming
- Models: singular PascalCase (`Post`, `Comment`)
- Tables: plural snake_case (`posts`, `comments`)
- Controllers: `{Model}Controller`
- Livewire: `{Feature}Page`, `{Feature}Modal`
### Testing
- Use Pest, not PHPUnit directly
- Feature tests for HTTP/Livewire
- Unit tests for services/utilities
## UI Framework
- **Livewire 3** for reactive components
- **Flux Pro** for UI components (not vanilla Alpine)
- **Tailwind CSS** for styling
- **Font Awesome Pro** for icons (not Heroicons)
## Key Packages
| Package | Purpose |
|---------|---------|
| `host-uk/core` | Core framework, events, modules |
| `host-uk/core-admin` | Admin panel, modals |
| `host-uk/core-api` | REST API, rate limiting |
| `host-uk/core-mcp` | AI agent tools (MCP) |
## Don't
- Don't use Heroicons (use Font Awesome Pro)
- Don't use vanilla Alpine components (use Flux Pro)
- Don't create controllers for Livewire pages
- Don't use American English spellings
- Don't add unnecessary abstractions

29
src/php/.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,29 @@
version: 2
updates:
- package-ecosystem: composer
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
labels:
- dependencies
- php
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
labels:
- dependencies
- javascript
- package-ecosystem: github-actions
directory: /
schedule:
interval: monthly
open-pull-requests-limit: 3
labels:
- dependencies
- ci

View file

@ -0,0 +1,62 @@
# Package Workflows
These workflow templates are for **library packages** (host-uk/core, host-uk/core-api, etc.), not application projects.
## README Badges
Add these badges to your package README (replace `{package}` with your package name):
```markdown
[![CI](https://github.com/host-uk/{package}/actions/workflows/ci.yml/badge.svg)](https://github.com/host-uk/{package}/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/host-uk/{package}/graph/badge.svg)](https://codecov.io/gh/host-uk/{package})
[![Latest Version](https://img.shields.io/packagist/v/host-uk/{package})](https://packagist.org/packages/host-uk/{package})
[![PHP Version](https://img.shields.io/packagist/php-v/host-uk/{package})](https://packagist.org/packages/host-uk/{package})
[![License](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](LICENSE)
```
## Usage
Copy the relevant workflows to your library's `.github/workflows/` directory:
```bash
# In your library repo
mkdir -p .github/workflows
cp path/to/core-template/.github/package-workflows/ci.yml .github/workflows/
cp path/to/core-template/.github/package-workflows/release.yml .github/workflows/
```
## Workflows
### ci.yml
- Runs on push/PR to main
- Tests against PHP 8.2, 8.3, 8.4
- Tests against Laravel 11 and 12
- Runs Pint linting
- Runs Pest tests
### release.yml
- Triggers on version tags (v*)
- Generates changelog using git-cliff
- Creates GitHub release
## Requirements
For these workflows to work, your package needs:
1. **cliff.toml** - Copy from core-template root
2. **Pest configured** - `composer require pestphp/pest --dev`
3. **Pint configured** - `composer require laravel/pint --dev`
4. **CODECOV_TOKEN** - Add to repo secrets for coverage uploads
5. **FUNDING.yml** - Copy `.github/FUNDING.yml` for sponsor button
## Recommended composer.json scripts
```json
{
"scripts": {
"lint": "pint",
"test": "pest",
"test:coverage": "pest --coverage"
}
}
```

View file

@ -0,0 +1,55 @@
# CI workflow for library packages (host-uk/core-*, etc.)
# Copy this to .github/workflows/ci.yml in library repos
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
tests:
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
php: [8.2, 8.3, 8.4]
laravel: [11.*, 12.*]
exclude:
- php: 8.2
laravel: 12.*
name: PHP ${{ matrix.php }} / Laravel ${{ matrix.laravel }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite
coverage: pcov
- name: Install dependencies
run: |
composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update
composer update --prefer-dist --no-interaction --no-progress
- name: Run Pint
run: vendor/bin/pint --test
- name: Run tests
run: vendor/bin/pest --ci --coverage --coverage-clover coverage.xml
- name: Upload coverage to Codecov
if: matrix.php == '8.3' && matrix.laravel == '12.*'
uses: codecov/codecov-action@v4
with:
files: coverage.xml
fail_ci_if_error: false
token: ${{ secrets.CODECOV_TOKEN }}

View file

@ -0,0 +1,40 @@
# Release workflow for library packages
# Copy this to .github/workflows/release.yml in library repos
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
name: Create Release
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate changelog
id: changelog
uses: orhun/git-cliff-action@v3
with:
config: cliff.toml
args: --latest --strip header
env:
OUTPUT: CHANGELOG.md
- name: Create release
uses: softprops/action-gh-release@v2
with:
body_path: CHANGELOG.md
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

66
src/php/.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,66 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
tests:
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
php: [8.2, 8.3, 8.4]
name: PHP ${{ matrix.php }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite
coverage: pcov
- name: Install dependencies
run: composer install --prefer-dist --no-interaction --no-progress
- name: Run Pint
run: vendor/bin/pint --test
- name: Run tests
run: vendor/bin/pest --ci --coverage --coverage-clover coverage.xml
- name: Upload coverage to Codecov
if: matrix.php == '8.3'
uses: codecov/codecov-action@v5
with:
files: coverage.xml
fail_ci_if_error: false
token: ${{ secrets.CODECOV_TOKEN }}
assets:
runs-on: ubuntu-latest
name: Assets
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Build assets
run: npm run build

37
src/php/.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,37 @@
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
name: Create Release
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Generate changelog
id: changelog
uses: orhun/git-cliff-action@v4
with:
config: cliff.toml
args: --latest --strip header
env:
OUTPUT: CHANGELOG.md
- name: Create release
uses: softprops/action-gh-release@v2
with:
body_path: CHANGELOG.md
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

24
src/php/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
.DS_Store
/.phpunit.cache
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
Homestead.json
Homestead.yaml
auth.json
npm-debug.log
yarn-error.log
/.fleet
/.idea
/.nova
/.vscode
/.zed

124
src/php/AGENTS.md Normal file
View file

@ -0,0 +1,124 @@
# AI Agent Instructions
> For Jules, Devin, and other autonomous coding agents.
## Quick Start
1. This is a Laravel 12 + Livewire 3 application
2. Modules go in `app/Mod/{Name}/Boot.php`
3. Use UK English (colour, not color)
4. Run `vendor/bin/pint --dirty` before committing
5. Run `vendor/bin/pest` to test
## Architecture
**Modular monolith** - Features are self-contained modules that register via events.
### Creating a Module
```bash
php artisan make:mod {Name} --all
```
Or manually create `app/Mod/{Name}/Boot.php`:
```php
<?php
declare(strict_types=1);
namespace App\Mod\{Name};
use Core\Events\WebRoutesRegistering;
class Boot
{
public static array $listens = [
WebRoutesRegistering::class => 'onWebRoutes',
];
public function onWebRoutes(WebRoutesRegistering $event): void
{
$event->routes(fn() => require __DIR__.'/Routes/web.php');
$event->views('{name}', __DIR__.'/Views');
}
}
```
## Task Checklist
When implementing features:
- [ ] Create module in `app/Mod/{Name}/`
- [ ] Add `Boot.php` with event listeners
- [ ] Create routes in `Routes/web.php` or `Routes/api.php`
- [ ] Create Livewire components in `Livewire/`
- [ ] Create Blade views in `Views/`
- [ ] Add migrations in `Migrations/`
- [ ] Write tests in `Tests/`
- [ ] Run `vendor/bin/pint --dirty`
- [ ] Run `vendor/bin/pest`
## File Locations
| What | Where |
|------|-------|
| Models | `app/Mod/{Name}/Models/` |
| Livewire | `app/Mod/{Name}/Livewire/` |
| Views | `app/Mod/{Name}/Views/` |
| Routes | `app/Mod/{Name}/Routes/` |
| Migrations | `app/Mod/{Name}/Migrations/` |
| Tests | `app/Mod/{Name}/Tests/` |
| Services | `app/Mod/{Name}/Services/` |
## Critical Rules
1. **UK English** - colour, organisation, centre (never American spellings)
2. **Strict types** - `declare(strict_types=1);` in every PHP file
3. **Type hints** - All parameters and return types
4. **Flux Pro** - Use Flux components, not vanilla Alpine
5. **Font Awesome** - Use FA icons, not Heroicons
6. **Pest** - Write tests using Pest syntax, not PHPUnit
## Example Livewire Component
```php
<?php
declare(strict_types=1);
namespace App\Mod\Blog\Livewire;
use App\Mod\Blog\Models\Post;
use Illuminate\Contracts\View\View;
use Livewire\Component;
use Livewire\WithPagination;
class PostListPage extends Component
{
use WithPagination;
public function render(): View
{
return view('blog::posts.index', [
'posts' => Post::latest()->paginate(10),
]);
}
}
```
## Testing Example
```php
<?php
use App\Mod\Blog\Models\Post;
it('displays posts on the index page', function () {
$posts = Post::factory()->count(3)->create();
$this->get('/blog')
->assertOk()
->assertSee($posts->first()->title);
});
```

236
src/php/FINDINGS.md Normal file
View file

@ -0,0 +1,236 @@
# Phase 0 Findings: Environment Assessment
**Date:** 2026-02-21
**Branch:** feat/phase-0-assessment
**Issue:** #1
---
## 1. Environment Assessment
### Composer Install
**Result:** FAILED
```
Your requirements could not be resolved to an installable set of packages.
Problem 1
- Root composer.json requires host-uk/core, it could not be found in any version
```
`host-uk/core` is a private proprietary package (the Core PHP framework). It is not
published to Packagist or a configured private registry accessible in this environment.
**Impact:** Tests, lint, and static analysis cannot be executed without the vendor
directory. All tooling assessment below is based on static code review.
---
## 2. Test Baseline
**Status:** Unable to run (no vendor directory)
**Configured test runner:** Pest (`vendor/bin/pest`)
**Test file inventory:**
| File | Suite | Status |
|------|-------|--------|
| `tests/Unit/SqlQueryValidatorTest.php` | Unit | Present |
| `src/Mcp/Tests/Unit/WorkspaceContextSecurityTest.php` | Unit | Present |
| `src/Mcp/Tests/Unit/ToolAnalyticsServiceTest.php` | Unit | Present |
| `src/Mcp/Tests/Unit/McpQuotaServiceTest.php` | Unit | Present |
| `src/Mcp/Tests/Unit/QueryAuditServiceTest.php` | Unit | Present |
| `src/Mcp/Tests/Unit/QueryExecutionServiceTest.php` | Unit | Present |
| `src/Mcp/Tests/Unit/ToolDependencyServiceTest.php` | Unit | Present |
| `src/Mcp/Tests/Unit/ToolVersionServiceTest.php` | Unit | Present |
| `src/Mcp/Tests/Unit/ValidateWorkspaceContextMiddlewareTest.php` | Unit | Present |
| `src/Mcp/Tests/UseCase/ApiKeyManagerBasic.php` | UseCase | Present |
**Notable gaps:**
- No test for `QueryDatabase` tool (the primary entry point)
- No test for `ToolRegistry` / `AgentToolRegistry` service
- No test for `McpAgentServerCommand` (stdio MCP server)
- No test for `AuditLogService` (tamper-evidence verification)
- No test for `CircuitBreaker` service
- No integration tests at all (`tests/Feature/` is empty)
---
## 3. Lint Baseline
**Tool:** `vendor/bin/pint`
**Status:** Unable to run (no vendor directory)
**Static observation:** All reviewed files contain `declare(strict_types=1)` at the top
and follow PSR-12 conventions. Consistent UK English spelling observed throughout
(colour, organisation, licence, sanitise, normalise).
---
## 4. Static Analysis Baseline
**Tool:** `vendor/bin/phpstan` (level unknown — not declared in composer.json)
**Status:** Unable to run (no vendor directory)
**Observations from code review:**
### Type Safety
- All public methods have complete parameter and return type hints
- Private methods are consistently typed
- `SqlQueryValidator::$whitelist` is `array` — could be `array<int, string>` for PHPStan level 5+
- `Boot::$listens` uses `array<class-string, string>` — correct
### Potential PHPStan Issues (estimated level 5)
1. `QueryDatabase::getWorkspaceId()` calls `workspace()` global helper — not declared in stubs
2. `QueryDatabase::getUserId()` calls `auth()->id()` — return type is `int|string|null`, cast to int without null check
3. `QueryDatabase::interpretExplain()` accesses `$rowArray['type']` on `array<string, mixed>` — likely needs type narrowing
4. `QueryDatabase::handleExplain()` passes `$explainResults` (array of stdClass) to `interpretExplain()` typed as `array` — needs `array<int, object>`
5. `Boot::onMcpTools()` has empty body — PHPStan will warn about unused parameter
---
## 5. Architecture Review
### Package Structure
```
src/Mcp/ # Core\Mcp namespace (103 PHP files)
├── Boot.php # ServiceProvider + event-driven registration
├── Console/ # 5 Artisan commands
├── Context/ # WorkspaceContext value object
├── Controllers/ # McpApiController (REST)
├── Dependencies/ # Tool dependency system (interface + DTO + enum)
├── DTO/ # ToolStats data transfer object
├── Events/ # ToolExecuted domain event
├── Exceptions/ # 6 custom exceptions (typed hierarchy)
├── Lang/en_GB/ # UK English translations
├── Listeners/ # RecordToolExecution event listener
├── Middleware/ # 5 middleware (auth, quota, workspace context, deps)
├── Migrations/ # 5 database migrations
├── Models/ # 8 Eloquent models
├── Resources/ # AppConfig, ContentResource, DatabaseSchema
├── Routes/ # admin.php route file
├── Services/ # 18 business logic services
├── Tests/ # Unit tests co-located with package
├── Tools/ # 10 MCP tool implementations
└── View/ # 12 Blade templates + 9 Livewire components
src/Website/Mcp/ # Core\Website\Mcp namespace
└── ... # Web-facing UI module
```
### Key Architectural Patterns
**1. Event-Driven Module Registration**
`Boot.php` uses a `$listens` static array to subscribe to framework lifecycle events
(`AdminPanelBooting`, `ConsoleBooting`, `McpToolsRegistering`). This enables lazy-loading
of admin UI, commands, and tool registrations without booting the full framework.
**2. Tool Contract**
Tools extend `Laravel\Mcp\Server\Tool` and implement:
- `$description` property
- `schema(JsonSchema $schema): array` — declares MCP input schema
- `handle(Request $request): Response` — executes tool logic
**3. Defence-in-Depth SQL Validation** (`SqlQueryValidator`)
Four sequential layers:
1. Dangerous pattern check on raw query (before comment stripping)
2. Comment stripping (removes `--`, `#`, `/* */`, `/*!` obfuscation)
3. Blocked keyword check (write/admin/export operations)
4. Whitelist regex matching (only known-safe SELECT structures pass)
**4. Workspace Tenant Isolation**
`RequiresWorkspaceContext` trait + `ValidateWorkspaceContext` middleware enforce per-request
tenant scoping. `MissingWorkspaceContextException` is thrown for unauthenticated context.
**5. Tier-Based Resource Limits**
`McpQuotaService` and `QueryExecutionService` apply different limits per subscription tier:
- free / starter / pro / business / enterprise / unlimited
- Limits cover: row count, query timeout, daily request quota
**6. Singleton Service Container**
All 8 core services registered as singletons in `Boot::register()`. Each is independently
testable and injected via Laravel's container.
### Notable Issues
**Issue A — `onMcpTools` is a stub**
`Boot::onMcpTools()` contains only a comment:
```php
public function onMcpTools(McpToolsRegistering $event): void
{
// MCP tool handlers will be registered here once extracted
// from the monolithic McpAgentServerCommand
}
```
This means MCP tools are registered inside `McpAgentServerCommand` rather than being
injected via the service container. Refactoring this is P1 work.
**Issue B — `McpAgentServerCommand` is monolithic**
The stdio MCP server command handles tool registration, JSON-RPC dispatch, and tool
execution in a single command class. This makes it untestable in isolation.
**Issue C — `ListTables` tool exists but Schema Exploration is listed as TODO**
`src/Mcp/Tools/ListTables.php` exists but the TODO.md item "Schema Exploration Tools"
lists adding `ListTables` as pending. This is already implemented.
**Issue D — No `composer.lock`**
No `composer.lock` file is present. Dependency versions are not pinned, which creates
reproducibility risk in CI/CD.
**Issue E — `phpunit.xml` references `vendor/phpunit/phpunit`**
The test runner is configured for PHPUnit XML format but Pest is the stated test runner.
This is compatible (Pest uses PHPUnit under the hood) but the XML namespace warning will
appear until `composer.lock` is generated.
**Issue F — `tests/Feature/` is empty**
No feature/integration tests exist. All tests are unit tests that mock the database.
End-to-end request-response flows have no test coverage.
---
## 6. Security Observations
| Finding | Severity | Status |
|---------|----------|--------|
| SQL injection prevention (multi-layer) | GOOD | Implemented |
| Read-only connection enforcement | GOOD | Implemented |
| Workspace tenant isolation | GOOD | Implemented |
| Audit trail with HMAC verification | GOOD | Implemented |
| Tier-based resource limits | GOOD | Implemented |
| Circuit breaker for external calls | GOOD | Implemented |
| Tool registration outside DI container | MEDIUM | Issue A above |
| No integration tests for auth flow | MEDIUM | Issue F above |
| Missing `composer.lock` | LOW | Issue D above |
| `INFORMATION_SCHEMA` access blocked | GOOD | Implemented |
| System table access blocked | GOOD | Implemented |
---
## 7. Phased Work Recommendations
### Phase 1 — Unblock Testing (Prerequisite)
1. Resolve `host-uk/core` dependency access (private registry credentials or mock stubs)
2. Generate `composer.lock` after successful install
3. Run `vendor/bin/pest` to establish a numerical test baseline
### Phase 2 — Critical Gaps
1. **Extract tools from `McpAgentServerCommand`** into the `McpToolsRegistering` event
handler in `Boot::onMcpTools()` — makes the command testable
2. **Write `QueryDatabase` tool tests** — primary public surface has zero test coverage
3. **Write `AuditLogService` tests** — tamper-evident logging is security-critical
4. **Write integration tests** for the full HTTP → tool → response flow
### Phase 3 — Code Quality
1. Fix estimated PHPStan level 5 type errors (see §4)
2. Add `phpstan.neon` configuration file (currently absent)
3. Add `pint.json` configuration file (currently absent)
4. Resolve TODO.md items marked medium priority
### Phase 4 — Features
Refer to `TODO.md` for the full backlog.

122
src/php/GEMINI.md Normal file
View file

@ -0,0 +1,122 @@
# Core PHP Framework - AI Assistant Context
> For Gemini Code Assist, Jules, and other Google AI tools.
## Project Type
Laravel 12 application using Core PHP Framework - a modular monolith with event-driven architecture.
## Directory Structure
```
app/
├── Mod/ # Feature modules (your code)
│ └── {Name}/
│ ├── Boot.php # Event listeners
│ ├── Models/
│ ├── Routes/
│ ├── Views/
│ └── Livewire/
├── Core/ # Local framework overrides (EUPL-1.2)
└── Providers/
config/core.php # Framework configuration
```
## Module Pattern
Every module has a `Boot.php` with static `$listens` array:
```php
<?php
declare(strict_types=1);
namespace App\Mod\Shop;
use Core\Events\WebRoutesRegistering;
use Core\Events\ApiRoutesRegistering;
use Core\Events\AdminPanelBooting;
class Boot
{
public static array $listens = [
WebRoutesRegistering::class => 'onWebRoutes',
ApiRoutesRegistering::class => 'onApiRoutes',
AdminPanelBooting::class => 'onAdminPanel',
];
public function onWebRoutes(WebRoutesRegistering $event): void
{
$event->routes(fn() => require __DIR__.'/Routes/web.php');
$event->views('shop', __DIR__.'/Views');
}
public function onApiRoutes(ApiRoutesRegistering $event): void
{
$event->routes(fn() => require __DIR__.'/Routes/api.php');
}
public function onAdminPanel(AdminPanelBooting $event): void
{
$event->navigation('Shop', 'shop.admin.index', 'shopping-cart');
}
}
```
## Code Style Requirements
### Language: UK English
- colour (not color)
- organisation (not organization)
- centre (not center)
- behaviour (not behavior)
- licence (noun), license (verb)
### PHP Standards
- `declare(strict_types=1);` in all files
- Full type hints (parameters + return types)
- PSR-12 formatting (use Laravel Pint)
- Pest for testing
### Naming Conventions
| Type | Convention | Example |
|------|------------|---------|
| Model | Singular PascalCase | `Product` |
| Table | Plural snake_case | `products` |
| Controller | `{Model}Controller` | `ProductController` |
| Livewire Page | `{Feature}Page` | `ProductListPage` |
| Livewire Modal | `{Feature}Modal` | `EditProductModal` |
## UI Stack
- **Livewire 3** - Server-side reactivity
- **Flux Pro** - UI component library (NOT vanilla Alpine)
- **Tailwind CSS** - Utility-first styling
- **Font Awesome Pro** - Icons (NOT Heroicons)
## Common Commands
```bash
php artisan make:mod Blog --all # Create module with all features
php artisan serve # Development server
vendor/bin/pint --dirty # Format changed files
vendor/bin/pest # Run tests
```
## Packages
| Package | Namespace | Purpose |
|---------|-----------|---------|
| host-uk/core | `Core\` | Framework core |
| host-uk/core-admin | `Core\Admin\` | Admin panel |
| host-uk/core-api | `Core\Api\` | REST API |
| host-uk/core-mcp | `Core\Mcp\` | AI agent tools |
## Avoid
- American English spellings
- Heroicons (use Font Awesome)
- Vanilla Alpine components (use Flux)
- Over-engineering / premature abstraction
- PHPUnit syntax (use Pest)

373
src/php/TODO.md Normal file
View file

@ -0,0 +1,373 @@
# Core-MCP TODO
> See [FINDINGS.md](FINDINGS.md) for the full Phase 0 environment assessment report.
## Phase 0 — Environment Blockers (February 2026)
- [ ] **Resolve `host-uk/core` dependency access**
- Package is not available via Packagist; private registry credentials needed
- Blocks: `composer install`, all tests, lint, and static analysis
- **Action:** Configure private composer repository or provide mock stubs
- [ ] **Generate `composer.lock`** after successful install
- Currently absent — dependency versions are unpinned
- Reproducibility risk in CI/CD
- [ ] **Establish numeric test baseline**
- Run `vendor/bin/pest` and record pass/fail counts
- Targeted after dependency access is resolved
- [ ] **Run PHPStan analysis**
- `vendor/bin/phpstan analyse --memory-limit=512M`
- No `phpstan.neon` config file present — needs creating
- [ ] **Run lint baseline**
- `vendor/bin/pint --test`
- No `pint.json` config file present — needs creating
## Phase 1 — Critical Architecture (Prerequisite)
- [ ] **Refactor: Extract tools from `McpAgentServerCommand`**
- MCP tools are registered inside the command, not via DI container
- Implement `Boot::onMcpTools()` handler (currently a stub)
- Enables unit testing of individual tools in isolation
- **Files:** `src/Mcp/Boot.php`, `src/Mcp/Console/Commands/McpAgentServerCommand.php`
- **Estimated effort:** 4-6 hours
- [ ] **Test Coverage: QueryDatabase Tool** — primary public surface has zero tests
- Test SELECT execution, EXPLAIN analysis, connection validation
- Test blocked keywords and injection prevention end-to-end
- Test tier-based row limit truncation
- Test timeout handling
- **Files:** `tests/Unit/QueryDatabaseTest.php`
- **Estimated effort:** 3-4 hours
- [ ] **Test Coverage: AuditLogService** — security-critical, no tests exist
- Test HMAC tamper-evident logging
- Test log verification command (`mcp:verify-audit-log`)
- **Files:** `src/Mcp/Tests/Unit/AuditLogServiceTest.php`
- **Estimated effort:** 2-3 hours
- [ ] **Add integration tests**`tests/Feature/` is currently empty
- Test full HTTP → tool → response flow
- Test authentication and quota enforcement via middleware stack
- **Estimated effort:** 4-5 hours
## Testing & Quality Assurance
### High Priority
- [x] **Test Coverage: SQL Query Validator** - Test injection prevention
- [x] Test all forbidden SQL keywords (DROP, INSERT, UPDATE, DELETE, etc.)
- [x] Test SQL injection attempts (UNION, boolean blinds, etc.)
- [x] Test parameterized query validation
- [x] Test subquery restrictions
- [x] Test multi-statement detection
- **Completed:** 29 January 2026
- **File:** `tests/Unit/SqlQueryValidatorTest.php`
- [x] **Test Coverage: Workspace Context** - Test isolation and validation
- [x] Test WorkspaceContext resolution from headers
- [x] Test automatic workspace scoping in queries
- [x] Test MissingWorkspaceContextException
- [x] Test workspace boundary enforcement
- [x] Test cross-workspace query prevention
- **Completed:** 29 January 2026
- **File:** `src/Mcp/Tests/Unit/WorkspaceContextSecurityTest.php`
- [x] **Test Coverage: Tool Analytics** - Test metrics tracking
- [x] Test ToolAnalyticsService recording
- [x] Test ToolStats DTO calculations
- [x] Test error rate calculations
- [x] Test daily trend aggregation
- [x] Test reporting functions (popular tools, error-prone tools, workspace stats)
- [x] Test tool combination tracking
- **Completed:** 29 January 2026
- **File:** `src/Mcp/Tests/Unit/ToolAnalyticsServiceTest.php`
- [x] **Test Coverage: Quota System** - Test limits and enforcement
- [x] Test McpQuotaService tier limits (free, starter, pro, business, enterprise)
- [x] Test quota exceeded detection
- [x] Test quota reset timing
- [x] Test workspace-scoped quotas
- [x] Test CheckMcpQuota middleware (429 responses, headers)
- [x] Test edge cases (concurrent requests, cache invalidation, month boundaries)
- **Completed:** 29 January 2026
- **File:** `src/Mcp/Tests/Unit/McpQuotaServiceTest.php`
### Medium Priority
- [ ] **Test Coverage: Tool Dependencies** - Test dependency validation
- [ ] Test ToolDependencyService resolution
- [ ] Test MissingDependencyException
- [ ] Test circular dependency detection
- [ ] Test version compatibility checking
- **Estimated effort:** 2-3 hours
- [ ] **Test Coverage: Query Database Tool** - Test complete workflow
- [ ] Test SELECT query execution
- [ ] Test EXPLAIN plan analysis
- [ ] Test connection validation
- [ ] Test result formatting
- [ ] Test error handling
- **Estimated effort:** 3-4 hours
### Low Priority
- [ ] **Test Coverage: Tool Registry** - Test tool registration
- [ ] Test AgentToolRegistry with multiple tools
- [ ] Test tool discovery
- [ ] Test tool metadata
- **Estimated effort:** 2-3 hours
## Security (Critical)
### High Priority - Security Fixes Needed
- [x] **COMPLETED: Database Connection Fallback** - Throw exception instead of fallback
- [x] Fixed to throw ForbiddenConnectionException
- [x] No silent fallback to default connection
- [x] Prevents accidental production data exposure
- **Completed:** January 2026
- [x] **COMPLETED: SQL Validator Regex Strengthening** - Stricter WHERE clause validation
- [x] Replaced permissive `.+` with restrictive character classes
- [x] Added explicit structure validation
- [x] Better detection of injection attempts
- **Completed:** January 2026
### Medium Priority - Additional Security
- [x] **COMPLETED: Query Result Size Limits** - Prevent data exfiltration
- [x] Add max_rows configuration per tier (free: 100, starter: 500, professional: 1000, enterprise: 5000, unlimited: 10000)
- [x] Enforce result set limits via QueryExecutionService
- [x] Return truncation warnings in response metadata
- [x] Tests in QueryExecutionServiceTest.php
- **Completed:** 29 January 2026
- **Files:** `src/Mcp/Services/QueryExecutionService.php`, `src/Mcp/Exceptions/ResultSizeLimitException.php`
- [x] **COMPLETED: Query Timeout Enforcement** - Prevent resource exhaustion
- [x] Add per-query timeout configuration per tier (free: 5s, starter: 10s, professional: 30s, enterprise: 60s, unlimited: 120s)
- [x] Database-specific timeout application (MySQL/MariaDB, PostgreSQL, SQLite)
- [x] Throw QueryTimeoutException on timeout
- [x] Log timeout attempts via QueryAuditService
- **Completed:** 29 January 2026
- **Files:** `src/Mcp/Services/QueryExecutionService.php`, `src/Mcp/Exceptions/QueryTimeoutException.php`
- [x] **COMPLETED: Audit Logging for Queries** - Complete query audit trail
- [x] Log all query attempts (success, blocked, timeout, error, truncated)
- [x] Include user, workspace, query, bindings count, duration, row count
- [x] Sanitise queries and error messages for security
- [x] Security channel logging for blocked queries
- [x] Session and tier context tracking
- **Completed:** 29 January 2026
- **Files:** `src/Mcp/Services/QueryAuditService.php`, `src/Mcp/Tests/Unit/QueryAuditServiceTest.php`
## Features & Enhancements
### High Priority
- [x] **COMPLETED: EXPLAIN Plan Analysis** - Query optimization insights
- [x] Added `explain` parameter to QueryDatabase tool
- [x] Returns human-readable performance analysis
- [x] Shows index usage and optimization opportunities
- **Completed:** January 2026
- [ ] **Feature: Query Templates** - Reusable parameterized queries
- [ ] Create query template system
- [ ] Support named parameters
- [ ] Add template validation
- [ ] Store templates per workspace
- [ ] Test with complex queries
- **Estimated effort:** 5-6 hours
- **Files:** `src/Mod/Mcp/Templates/`
- [ ] **Feature: Schema Exploration Tools** - Database metadata access
- [ ] Add ListTables tool
- [ ] Add DescribeTable tool
- [ ] Add ListIndexes tool
- [ ] Respect information_schema restrictions
- [ ] Test with multiple database types
- **Estimated effort:** 4-5 hours
- **Files:** `src/Mod/Mcp/Tools/Schema/`
### Medium Priority
- [ ] **Enhancement: Query Result Caching** - Cache frequent queries
- [ ] Implement result caching with TTL
- [ ] Add cache key generation
- [ ] Support cache invalidation
- [ ] Test cache hit rates
- **Estimated effort:** 3-4 hours
- [ ] **Enhancement: Query History** - Track agent queries
- [ ] Store query history per workspace
- [ ] Add query rerun capability
- [ ] Create history browser UI
- [ ] Add favorite queries
- **Estimated effort:** 4-5 hours
- **Files:** `src/Mod/Mcp/History/`
- [ ] **Enhancement: Advanced Analytics** - Deeper insights
- [ ] Add query complexity scoring
- [ ] Track table access patterns
- [ ] Identify slow query patterns
- [ ] Create optimization recommendations
- **Estimated effort:** 5-6 hours
- **Files:** `src/Mod/Mcp/Analytics/`
### Low Priority
- [ ] **Enhancement: Multi-Database Support** - Query multiple databases
- [ ] Support cross-database queries
- [ ] Add database selection parameter
- [ ] Test with MySQL, PostgreSQL, SQLite
- **Estimated effort:** 4-5 hours
- [ ] **Enhancement: Query Builder UI** - Visual query construction
- [ ] Create Livewire query builder component
- [ ] Add table/column selection
- [ ] Support WHERE clause builder
- [ ] Generate safe SQL
- **Estimated effort:** 8-10 hours
- **Files:** `src/Mod/Mcp/QueryBuilder/`
## Tool Development
### High Priority
- [ ] **Tool: Create/Update Records** - Controlled data modification
- [ ] Create InsertRecord tool with strict validation
- [ ] Create UpdateRecord tool with WHERE requirements
- [ ] Implement record-level permissions
- [ ] Require explicit confirmation for modifications
- [ ] Test with workspace scoping
- **Estimated effort:** 6-8 hours
- **Files:** `src/Mod/Mcp/Tools/Modify/`
- **Note:** Requires careful security review
- [ ] **Tool: Export Data** - Export query results
- [ ] Add ExportResults tool
- [ ] Support CSV, JSON, Excel formats
- [ ] Add row limits per tier
- [ ] Implement streaming for large exports
- **Estimated effort:** 4-5 hours
- **Files:** `src/Mod/Mcp/Tools/Export/`
### Medium Priority
- [ ] **Tool: Analyze Performance** - Database health insights
- [ ] Add TableStats tool (row count, size, etc.)
- [ ] Add SlowQueries tool
- [ ] Add IndexUsage tool
- [ ] Create performance dashboard
- **Estimated effort:** 5-6 hours
- **Files:** `src/Mod/Mcp/Tools/Performance/`
- [ ] **Tool: Data Validation** - Validate data quality
- [ ] Add ValidateData tool
- [ ] Check for NULL values, duplicates
- [ ] Validate foreign key integrity
- [ ] Generate data quality report
- **Estimated effort:** 4-5 hours
- **Files:** `src/Mod/Mcp/Tools/Validation/`
## Documentation
- [x] **Guide: Creating MCP Tools** - Comprehensive tutorial
- [x] Document tool interface
- [x] Show parameter validation
- [x] Explain workspace context
- [x] Add dependency examples
- [x] Include security best practices
- **Completed:** January 2026
- **File:** `docs/packages/mcp/creating-mcp-tools.md`
- [x] **Guide: SQL Security** - Safe query patterns
- [x] Document allowed SQL patterns
- [x] Show parameterized query examples
- [x] Explain validation rules
- [x] List forbidden operations
- **Completed:** January 2026
- **File:** `docs/packages/mcp/sql-security.md`
- [x] **API Reference: All MCP Tools** - Complete tool catalog
- [x] Document each tool's parameters
- [x] Add usage examples
- [x] Show response formats
- [x] Include error cases
- **Completed:** January 2026
- **File:** `docs/packages/mcp/tools-reference.md`
## Code Quality
- [ ] **Refactor: Extract SQL Parser** - Better query validation
- [ ] Create proper SQL parser
- [ ] Replace regex with AST parsing
- [ ] Support dialect-specific syntax
- [ ] Add comprehensive tests
- **Estimated effort:** 8-10 hours
- [ ] **Refactor: Standardize Tool Responses** - Consistent API
- [ ] Create ToolResult DTO
- [ ] Standardize error responses
- [ ] Add response metadata
- [ ] Update all tools
- **Estimated effort:** 3-4 hours
- [ ] **PHPStan: Fix Level 5 Errors** - Improve type safety
- [ ] Fix property type declarations
- [ ] Add missing return types
- [ ] Fix array shape types
- **Estimated effort:** 2-3 hours
## Performance
- [ ] **Optimization: Query Result Streaming** - Handle large results
- [ ] Implement cursor-based result streaming
- [ ] Add chunked response delivery
- [ ] Test with millions of rows
- **Estimated effort:** 3-4 hours
- [ ] **Optimization: Connection Pooling** - Reuse database connections
- [ ] Implement connection pool
- [ ] Add connection health checks
- [ ] Test connection lifecycle
- **Estimated effort:** 3-4 hours
## Infrastructure
- [ ] **Monitoring: Alert on Suspicious Queries** - Security monitoring
- [ ] Detect unusual query patterns
- [ ] Alert on potential injection attempts
- [ ] Track query anomalies
- [ ] Create security dashboard
- **Estimated effort:** 4-5 hours
- [ ] **CI/CD: Add Security Regression Tests** - Prevent vulnerabilities
- [ ] Test SQL injection prevention
- [ ] Test workspace isolation
- [ ] Test quota enforcement
- [ ] Fail CI on security issues
- **Estimated effort:** 3-4 hours
---
## Completed (January 2026)
- [x] **Test Coverage: Workspace Context** - Comprehensive Pest tests for workspace isolation and context injection (P2-014)
- [x] **Test Coverage: SQL Query Validator** - Comprehensive Pest tests for SQL injection prevention (P2-013)
- [x] **Security: Database Connection Validation** - Throws exception for invalid connections
- [x] **Security: SQL Validator Strengthening** - Stricter WHERE clause patterns
- [x] **Security: Query Result Size Limits** - Tier-based max_rows with truncation warnings (P1-007)
- [x] **Security: Query Timeout Enforcement** - Per-query timeout with database-specific implementation (P1-008)
- [x] **Security: Audit Logging for Queries** - Comprehensive logging of all query attempts (P1-009)
- [x] **Feature: EXPLAIN Plan Analysis** - Query optimization insights
- [x] **Tool Analytics System** - Complete usage tracking and metrics
- [x] **Quota System** - Tier-based limits with enforcement
- [x] **Workspace Context** - Automatic query scoping and validation
- [x] **Documentation: Creating MCP Tools Guide** - Complete tutorial with workspace context, dependencies, security
- [x] **Documentation: SQL Security Guide** - Allowed patterns, forbidden operations, injection prevention
- [x] **Documentation: MCP Tools API Reference** - All tools with parameters, examples, error handling
*See `changelog/2026/jan/` for completed features and security fixes.*

View file

0
src/php/app/Mod/.gitkeep Normal file
View file

View file

View file

@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}

15
src/php/artisan Executable file
View file

@ -0,0 +1,15 @@
#!/usr/bin/env php
<?php
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
$status = (require_once __DIR__.'/bootstrap/app.php')
->handleCommand(new ArgvInput);
exit($status);

26
src/php/bootstrap/app.php Normal file
View file

@ -0,0 +1,26 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withProviders([
// Core PHP Framework
\Core\LifecycleEventProvider::class,
\Core\Website\Boot::class,
\Core\Front\Boot::class,
\Core\Mod\Boot::class,
])
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
\Core\Front\Boot::middleware($middleware);
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();

2
src/php/bootstrap/cache/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*
!.gitignore

View file

@ -0,0 +1,5 @@
<?php
return [
App\Providers\AppServiceProvider::class,
];

24
src/php/config/core.php Normal file
View file

@ -0,0 +1,24 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Core PHP Framework Configuration
|--------------------------------------------------------------------------
*/
'module_paths' => [
app_path('Core'),
app_path('Mod'),
app_path('Website'),
],
'services' => [
'cache_discovery' => env('CORE_CACHE_DISCOVERY', true),
],
'cdn' => [
'enabled' => env('CDN_ENABLED', false),
'driver' => env('CDN_DRIVER', 'bunny'),
],
];

View file

View file

View file

@ -0,0 +1,16 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
/**
* Seed the application's database.
*/
public function run(): void
{
// Core modules handle their own seeding
}
}

436
src/php/docs/analytics.md Normal file
View file

@ -0,0 +1,436 @@
# Tool Analytics
Track MCP tool usage, performance, and patterns with comprehensive analytics.
## Overview
The MCP analytics system provides insights into:
- Tool execution frequency
- Performance metrics
- Error rates
- User patterns
- Workspace usage
## Recording Metrics
### Automatic Tracking
Tool executions are automatically tracked:
```php
use Core\Mcp\Listeners\RecordToolExecution;
use Core\Mcp\Events\ToolExecuted;
// Automatically recorded on tool execution
event(new ToolExecuted(
tool: 'query_database',
workspace: $workspace,
user: $user,
duration: 5.23,
success: true
));
```
### Manual Recording
```php
use Core\Mcp\Services\ToolAnalyticsService;
$analytics = app(ToolAnalyticsService::class);
$analytics->record([
'tool_name' => 'query_database',
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'execution_time_ms' => 5.23,
'success' => true,
'error_message' => null,
'metadata' => [
'query_rows' => 42,
'connection' => 'mysql',
],
]);
```
## Querying Analytics
### Tool Stats
```php
use Core\Mcp\Services\ToolAnalyticsService;
$analytics = app(ToolAnalyticsService::class);
// Get stats for specific tool
$stats = $analytics->getToolStats('query_database', [
'workspace_id' => $workspace->id,
'start_date' => now()->subDays(30),
'end_date' => now(),
]);
```
**Returns:**
```php
use Core\Mcp\DTO\ToolStats;
$stats = new ToolStats(
tool_name: 'query_database',
total_executions: 1234,
successful_executions: 1200,
failed_executions: 34,
avg_execution_time_ms: 5.23,
p95_execution_time_ms: 12.45,
p99_execution_time_ms: 24.67,
error_rate: 2.76, // percentage
);
```
### Most Used Tools
```php
$topTools = $analytics->mostUsedTools([
'workspace_id' => $workspace->id,
'limit' => 10,
'start_date' => now()->subDays(7),
]);
// Returns array:
[
['tool_name' => 'query_database', 'count' => 500],
['tool_name' => 'list_workspaces', 'count' => 120],
['tool_name' => 'get_billing_status', 'count' => 45],
]
```
### Error Analysis
```php
// Get failed executions
$errors = $analytics->getErrors([
'workspace_id' => $workspace->id,
'tool_name' => 'query_database',
'start_date' => now()->subDays(7),
]);
foreach ($errors as $error) {
echo "Error: {$error->error_message}\n";
echo "Occurred: {$error->created_at->diffForHumans()}\n";
echo "User: {$error->user->name}\n";
}
```
### Performance Trends
```php
// Get daily execution counts
$trend = $analytics->dailyTrend([
'tool_name' => 'query_database',
'workspace_id' => $workspace->id,
'days' => 30,
]);
// Returns:
[
'2026-01-01' => 45,
'2026-01-02' => 52,
'2026-01-03' => 48,
// ...
]
```
## Admin Dashboard
View analytics in admin panel:
```php
<?php
namespace Core\Mcp\View\Modal\Admin;
use Livewire\Component;
use Core\Mcp\Services\ToolAnalyticsService;
class ToolAnalyticsDashboard extends Component
{
public function render()
{
$analytics = app(ToolAnalyticsService::class);
return view('mcp::admin.analytics.dashboard', [
'totalExecutions' => $analytics->totalExecutions(),
'topTools' => $analytics->mostUsedTools(['limit' => 10]),
'errorRate' => $analytics->errorRate(),
'avgExecutionTime' => $analytics->averageExecutionTime(),
]);
}
}
```
**View:**
```blade
<x-admin::card>
<x-slot:header>
<h3>MCP Tool Analytics</h3>
</x-slot:header>
<div class="grid grid-cols-4 gap-4">
<x-admin::stat
label="Total Executions"
:value="$totalExecutions"
icon="heroicon-o-play-circle"
/>
<x-admin::stat
label="Error Rate"
:value="number_format($errorRate, 2) . '%'"
icon="heroicon-o-exclamation-triangle"
:color="$errorRate > 5 ? 'red' : 'green'"
/>
<x-admin::stat
label="Avg Execution Time"
:value="number_format($avgExecutionTime, 2) . 'ms'"
icon="heroicon-o-clock"
/>
<x-admin::stat
label="Active Tools"
:value="count($topTools)"
icon="heroicon-o-cube"
/>
</div>
<div class="mt-6">
<h4>Most Used Tools</h4>
<x-admin::table>
<x-slot:header>
<x-admin::table.th>Tool</x-admin::table.th>
<x-admin::table.th>Executions</x-admin::table.th>
</x-slot:header>
@foreach($topTools as $tool)
<x-admin::table.tr>
<x-admin::table.td>{{ $tool['tool_name'] }}</x-admin::table.td>
<x-admin::table.td>{{ number_format($tool['count']) }}</x-admin::table.td>
</x-admin::table.tr>
@endforeach
</x-admin::table>
</div>
</x-admin::card>
```
## Tool Detail View
Detailed analytics for specific tool:
```blade
<x-admin::card>
<x-slot:header>
<h3>{{ $toolName }} Analytics</h3>
</x-slot:header>
<div class="grid grid-cols-3 gap-4">
<x-admin::stat
label="Total Executions"
:value="$stats->total_executions"
/>
<x-admin::stat
label="Success Rate"
:value="number_format((1 - $stats->error_rate / 100) * 100, 1) . '%'"
:color="$stats->error_rate < 5 ? 'green' : 'red'"
/>
<x-admin::stat
label="P95 Latency"
:value="number_format($stats->p95_execution_time_ms, 2) . 'ms'"
/>
</div>
<div class="mt-6">
<h4>Performance Trend</h4>
<canvas id="performance-chart"></canvas>
</div>
<div class="mt-6">
<h4>Recent Errors</h4>
@foreach($recentErrors as $error)
<x-admin::alert type="error">
<strong>{{ $error->created_at->diffForHumans() }}</strong>
{{ $error->error_message }}
</x-admin::alert>
@endforeach
</div>
</x-admin::card>
```
## Pruning Old Metrics
```bash
# Prune metrics older than 90 days
php artisan mcp:prune-metrics --days=90
# Dry run
php artisan mcp:prune-metrics --days=90 --dry-run
```
**Scheduled Pruning:**
```php
// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
$schedule->command('mcp:prune-metrics --days=90')
->daily()
->at('02:00');
}
```
## Alerting
Set up alerts for anomalies:
```php
use Core\Mcp\Services\ToolAnalyticsService;
$analytics = app(ToolAnalyticsService::class);
// Check error rate
$errorRate = $analytics->errorRate([
'tool_name' => 'query_database',
'start_date' => now()->subHours(1),
]);
if ($errorRate > 10) {
// Alert: High error rate
Notification::route('slack', config('slack.webhook'))
->notify(new HighErrorRateNotification('query_database', $errorRate));
}
// Check slow executions
$p99 = $analytics->getToolStats('query_database')->p99_execution_time_ms;
if ($p99 > 1000) {
// Alert: Slow performance
Notification::route('slack', config('slack.webhook'))
->notify(new SlowToolNotification('query_database', $p99));
}
```
## Export Analytics
```php
use Core\Mcp\Services\ToolAnalyticsService;
$analytics = app(ToolAnalyticsService::class);
// Export to CSV
$csv = $analytics->exportToCsv([
'workspace_id' => $workspace->id,
'start_date' => now()->subDays(30),
'end_date' => now(),
]);
return response()->streamDownload(function () use ($csv) {
echo $csv;
}, 'mcp-analytics.csv');
```
## Best Practices
### 1. Set Retention Policies
```php
// config/mcp.php
return [
'analytics' => [
'retention_days' => 90, // Keep 90 days
'prune_schedule' => 'daily',
],
];
```
### 2. Monitor Error Rates
```php
// ✅ Good - alert on high error rate
if ($errorRate > 10) {
$this->alert('High error rate');
}
// ❌ Bad - ignore errors
// (problems go unnoticed)
```
### 3. Track Performance
```php
// ✅ Good - measure execution time
$start = microtime(true);
$result = $tool->execute($params);
$duration = (microtime(true) - $start) * 1000;
$analytics->record([
'execution_time_ms' => $duration,
]);
```
### 4. Use Aggregated Queries
```php
// ✅ Good - use analytics service
$stats = $analytics->getToolStats('query_database');
// ❌ Bad - query metrics table directly
$count = ToolMetric::where('tool_name', 'query_database')->count();
```
## Testing
```php
use Tests\TestCase;
use Core\Mcp\Services\ToolAnalyticsService;
class AnalyticsTest extends TestCase
{
public function test_records_tool_execution(): void
{
$analytics = app(ToolAnalyticsService::class);
$analytics->record([
'tool_name' => 'test_tool',
'workspace_id' => 1,
'success' => true,
]);
$this->assertDatabaseHas('mcp_tool_metrics', [
'tool_name' => 'test_tool',
'workspace_id' => 1,
]);
}
public function test_calculates_error_rate(): void
{
$analytics = app(ToolAnalyticsService::class);
// Record 100 successful, 10 failed
for ($i = 0; $i < 100; $i++) {
$analytics->record(['tool_name' => 'test', 'success' => true]);
}
for ($i = 0; $i < 10; $i++) {
$analytics->record(['tool_name' => 'test', 'success' => false]);
}
$errorRate = $analytics->errorRate(['tool_name' => 'test']);
$this->assertEquals(9.09, round($errorRate, 2)); // 10/110 = 9.09%
}
}
```
## Learn More
- [Quotas →](/packages/mcp/quotas)
- [Creating Tools →](/packages/mcp/tools)

View file

@ -0,0 +1,787 @@
# Guide: Creating MCP Tools
This guide covers everything you need to create MCP tools for AI agents, from basic tools to advanced patterns with workspace context, dependencies, and security best practices.
## Overview
MCP (Model Context Protocol) tools allow AI agents to interact with your application. Each tool:
- Has a unique name and description
- Defines input parameters with JSON Schema
- Executes logic and returns structured responses
- Can require workspace context for multi-tenant isolation
- Can declare dependencies on other tools
## Tool Interface
All MCP tools extend `Laravel\Mcp\Server\Tool` and implement two required methods:
```php
<?php
declare(strict_types=1);
namespace Mod\Blog\Tools;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class ListPostsTool extends Tool
{
protected string $description = 'List all blog posts with optional filters';
public function handle(Request $request): Response
{
// Tool logic here
$posts = Post::limit(10)->get();
return Response::text(json_encode($posts->toArray(), JSON_PRETTY_PRINT));
}
public function schema(JsonSchema $schema): array
{
return [
'status' => $schema->string('Filter by post status'),
'limit' => $schema->integer('Maximum posts to return')->default(10),
];
}
}
```
### Key Methods
| Method | Purpose |
|--------|---------|
| `$description` | Tool description shown to AI agents |
| `handle(Request)` | Execute the tool and return a Response |
| `schema(JsonSchema)` | Define input parameters |
## Parameter Validation
Define parameters using the `JsonSchema` builder in the `schema()` method:
### String Parameters
```php
public function schema(JsonSchema $schema): array
{
return [
// Basic string
'title' => $schema->string('Post title')->required(),
// Enum values
'status' => $schema->string('Post status: draft, published, archived'),
// With default
'format' => $schema->string('Output format')->default('json'),
];
}
```
### Numeric Parameters
```php
public function schema(JsonSchema $schema): array
{
return [
// Integer
'limit' => $schema->integer('Maximum results')->default(10),
// Number (float)
'price' => $schema->number('Product price'),
];
}
```
### Boolean Parameters
```php
public function schema(JsonSchema $schema): array
{
return [
'include_drafts' => $schema->boolean('Include draft posts')->default(false),
];
}
```
### Array Parameters
```php
public function schema(JsonSchema $schema): array
{
return [
'tags' => $schema->array('Filter by tags'),
'ids' => $schema->array('Specific post IDs to fetch'),
];
}
```
### Required vs Optional
```php
public function schema(JsonSchema $schema): array
{
return [
// Required - AI agent must provide this
'query' => $schema->string('SQL query to execute')->required(),
// Optional with default
'limit' => $schema->integer('Max rows')->default(100),
// Optional without default
'status' => $schema->string('Filter status'),
];
}
```
### Accessing Parameters
```php
public function handle(Request $request): Response
{
// Get single parameter
$query = $request->input('query');
// Get with default
$limit = $request->input('limit', 10);
// Check if parameter exists
if ($request->has('status')) {
// ...
}
// Get all parameters
$params = $request->all();
}
```
### Custom Validation
For validation beyond schema types, validate in `handle()`:
```php
public function handle(Request $request): Response
{
$email = $request->input('email');
// Custom validation
if ($email && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return Response::text(json_encode([
'error' => 'Invalid email format',
'code' => 'VALIDATION_ERROR',
]));
}
// Validate limit range
$limit = $request->input('limit', 10);
if ($limit < 1 || $limit > 100) {
return Response::text(json_encode([
'error' => 'Limit must be between 1 and 100',
'code' => 'VALIDATION_ERROR',
]));
}
// Continue with tool logic...
}
```
## Workspace Context
For multi-tenant applications, tools must access data scoped to the authenticated workspace. **Never accept workspace ID as a user-supplied parameter** - this prevents cross-tenant data access.
### Using RequiresWorkspaceContext
```php
<?php
declare(strict_types=1);
namespace Mod\Blog\Tools;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
use Mod\Mcp\Tools\Concerns\RequiresWorkspaceContext;
class ListWorkspacePostsTool extends Tool
{
use RequiresWorkspaceContext;
protected string $description = 'List posts in your workspace';
public function handle(Request $request): Response
{
// Get workspace from authenticated context (NOT from request params)
$workspace = $this->getWorkspace();
$workspaceId = $this->getWorkspaceId();
$posts = Post::where('workspace_id', $workspaceId)
->limit($request->input('limit', 10))
->get();
return Response::text(json_encode([
'workspace' => $workspace->name,
'posts' => $posts->toArray(),
], JSON_PRETTY_PRINT));
}
public function schema(JsonSchema $schema): array
{
// Note: No workspace_id parameter - comes from auth context
return [
'limit' => $schema->integer('Maximum posts to return'),
];
}
}
```
### Trait Methods
The `RequiresWorkspaceContext` trait provides:
| Method | Returns | Description |
|--------|---------|-------------|
| `getWorkspaceContext()` | `WorkspaceContext` | Full context object |
| `getWorkspaceId()` | `int` | Workspace ID only |
| `getWorkspace()` | `Workspace` | Workspace model |
| `hasWorkspaceContext()` | `bool` | Check if context available |
| `validateResourceOwnership(int, string)` | `void` | Validate resource belongs to workspace |
### Setting Workspace Context
Workspace context is set by middleware from authentication (API key or user session):
```php
// In middleware or controller
$tool = new ListWorkspacePostsTool();
$tool->setWorkspaceContext(WorkspaceContext::fromWorkspace($workspace));
// Or from ID
$tool->setWorkspaceId($workspaceId);
// Or from workspace model
$tool->setWorkspace($workspace);
```
### Validating Resource Ownership
When accessing specific resources, validate they belong to the workspace:
```php
public function handle(Request $request): Response
{
$postId = $request->input('post_id');
$post = Post::findOrFail($postId);
// Throws RuntimeException if post doesn't belong to workspace
$this->validateResourceOwnership($post->workspace_id, 'post');
// Safe to proceed
return Response::text(json_encode($post->toArray()));
}
```
## Tool Dependencies
Tools can declare dependencies that must be satisfied before execution. This is useful for workflows where tools must be called in a specific order.
### Declaring Dependencies
Implement `HasDependencies` or use `ValidatesDependencies` trait:
```php
<?php
declare(strict_types=1);
namespace Mod\Blog\Tools;
use Core\Mod\Mcp\Dependencies\DependencyType;
use Core\Mod\Mcp\Dependencies\HasDependencies;
use Core\Mod\Mcp\Dependencies\ToolDependency;
use Laravel\Mcp\Server\Tool;
class UpdateTaskTool extends Tool implements HasDependencies
{
protected string $description = 'Update a task in the current plan';
public function dependencies(): array
{
return [
// Another tool must be called first
ToolDependency::toolCalled(
'plan_create',
'A plan must be created before updating tasks'
),
// Session state must exist
ToolDependency::sessionState(
'active_plan_id',
'An active plan must be selected'
),
// Context value required
ToolDependency::contextExists(
'workspace_id',
'Workspace context is required'
),
];
}
public function handle(Request $request): Response
{
// Dependencies are validated before handle() is called
// ...
}
}
```
### Dependency Types
| Type | Use Case |
|------|----------|
| `TOOL_CALLED` | Another tool must be executed in session |
| `SESSION_STATE` | A session variable must exist |
| `CONTEXT_EXISTS` | A context value must be present |
| `ENTITY_EXISTS` | A database entity must exist |
| `CUSTOM` | Custom validation logic |
### Creating Dependencies
```php
// Tool must be called first
ToolDependency::toolCalled('list_tables');
// Session state required
ToolDependency::sessionState('selected_table');
// Context value required
ToolDependency::contextExists('workspace_id');
// Entity must exist
ToolDependency::entityExists('Plan', 'A plan must exist', [
'id_param' => 'plan_id',
]);
// Custom validation
ToolDependency::custom('billing_active', 'Billing must be active');
```
### Optional Dependencies
Mark dependencies as optional (warns but doesn't block):
```php
public function dependencies(): array
{
return [
ToolDependency::toolCalled('cache_warm')
->asOptional(), // Soft dependency
];
}
```
### Inline Dependency Validation
Use the `ValidatesDependencies` trait for inline validation:
```php
use Core\Mod\Mcp\Tools\Concerns\ValidatesDependencies;
class MyTool extends Tool
{
use ValidatesDependencies;
public function handle(Request $request): Response
{
$context = ['session_id' => $request->input('session_id')];
// Throws if dependencies not met
$this->validateDependencies($context);
// Or check without throwing
if (!$this->dependenciesMet($context)) {
$missing = $this->getMissingDependencies($context);
return Response::text(json_encode([
'error' => 'Dependencies not met',
'missing' => array_map(fn($d) => $d->key, $missing),
]));
}
// Continue...
}
}
```
## Registering Tools
Register tools via the `McpToolsRegistering` event in your module:
```php
<?php
namespace Mod\Blog;
use Core\Events\McpToolsRegistering;
use Mod\Blog\Tools\CreatePostTool;
use Mod\Blog\Tools\ListPostsTool;
class Boot
{
public static array $listens = [
McpToolsRegistering::class => 'onMcpTools',
];
public function onMcpTools(McpToolsRegistering $event): void
{
$event->tool('blog:list-posts', ListPostsTool::class);
$event->tool('blog:create-post', CreatePostTool::class);
}
}
```
### Tool Naming Conventions
Use consistent naming:
```php
// Pattern: module:action-resource
'blog:list-posts' // List resources
'blog:get-post' // Get single resource
'blog:create-post' // Create resource
'blog:update-post' // Update resource
'blog:delete-post' // Delete resource
// Sub-modules
'commerce:billing:get-status'
'commerce:coupon:create'
```
## Response Formats
### Success Response
```php
return Response::text(json_encode([
'success' => true,
'data' => $result,
], JSON_PRETTY_PRINT));
```
### Error Response
```php
return Response::text(json_encode([
'error' => 'Specific error message',
'code' => 'ERROR_CODE',
]));
```
### Paginated Response
```php
$posts = Post::paginate($perPage);
return Response::text(json_encode([
'data' => $posts->items(),
'pagination' => [
'current_page' => $posts->currentPage(),
'last_page' => $posts->lastPage(),
'per_page' => $posts->perPage(),
'total' => $posts->total(),
],
], JSON_PRETTY_PRINT));
```
### List Response
```php
return Response::text(json_encode([
'count' => $items->count(),
'items' => $items->map(fn($item) => [
'id' => $item->id,
'name' => $item->name,
])->all(),
], JSON_PRETTY_PRINT));
```
## Security Best Practices
### 1. Never Trust User-Supplied IDs for Authorization
```php
// BAD: Using workspace_id from request
public function handle(Request $request): Response
{
$workspaceId = $request->input('workspace_id'); // Attacker can change this!
$posts = Post::where('workspace_id', $workspaceId)->get();
}
// GOOD: Using authenticated workspace context
public function handle(Request $request): Response
{
$workspaceId = $this->getWorkspaceId(); // From auth context
$posts = Post::where('workspace_id', $workspaceId)->get();
}
```
### 2. Validate Resource Ownership
```php
public function handle(Request $request): Response
{
$postId = $request->input('post_id');
$post = Post::findOrFail($postId);
// Always validate ownership before access
$this->validateResourceOwnership($post->workspace_id, 'post');
return Response::text(json_encode($post->toArray()));
}
```
### 3. Sanitize and Limit Input
```php
public function handle(Request $request): Response
{
// Limit result sets
$limit = min($request->input('limit', 10), 100);
// Sanitize string input
$search = strip_tags($request->input('search', ''));
$search = substr($search, 0, 255);
// Validate enum values
$status = $request->input('status');
if ($status && !in_array($status, ['draft', 'published', 'archived'])) {
return Response::text(json_encode(['error' => 'Invalid status']));
}
}
```
### 4. Log Sensitive Operations
```php
public function handle(Request $request): Response
{
Log::info('MCP tool executed', [
'tool' => 'delete-post',
'workspace_id' => $this->getWorkspaceId(),
'post_id' => $request->input('post_id'),
'user' => auth()->id(),
]);
// Perform operation...
}
```
### 5. Use Read-Only Database Connections for Queries
```php
// For query tools, use read-only connection
$connection = config('mcp.database.connection', 'readonly');
$results = DB::connection($connection)->select($query);
```
### 6. Sanitize Error Messages
```php
try {
// Operation...
} catch (\Exception $e) {
// Log full error for debugging
report($e);
// Return sanitized message to client
return Response::text(json_encode([
'error' => 'Operation failed. Please try again.',
'code' => 'OPERATION_FAILED',
]));
}
```
### 7. Implement Rate Limiting
Tools should respect quota limits:
```php
use Core\Mcp\Services\McpQuotaService;
public function handle(Request $request): Response
{
$quota = app(McpQuotaService::class);
$workspace = $this->getWorkspace();
if (!$quota->canExecute($workspace, $this->name())) {
return Response::text(json_encode([
'error' => 'Rate limit exceeded',
'code' => 'QUOTA_EXCEEDED',
]));
}
// Execute tool...
$quota->recordExecution($workspace, $this->name());
}
```
## Testing Tools
```php
<?php
namespace Tests\Feature\Mcp;
use Tests\TestCase;
use Mod\Blog\Tools\ListPostsTool;
use Mod\Blog\Models\Post;
use Core\Mod\Tenant\Models\Workspace;
use Mod\Mcp\Context\WorkspaceContext;
class ListPostsToolTest extends TestCase
{
public function test_lists_posts(): void
{
$workspace = Workspace::factory()->create();
Post::factory()->count(5)->create([
'workspace_id' => $workspace->id,
]);
$tool = new ListPostsTool();
$tool->setWorkspaceContext(
WorkspaceContext::fromWorkspace($workspace)
);
$request = new \Laravel\Mcp\Request([
'limit' => 10,
]);
$response = $tool->handle($request);
$data = json_decode($response->getContent(), true);
$this->assertCount(5, $data['posts']);
}
public function test_respects_workspace_isolation(): void
{
$workspace1 = Workspace::factory()->create();
$workspace2 = Workspace::factory()->create();
Post::factory()->count(3)->create(['workspace_id' => $workspace1->id]);
Post::factory()->count(2)->create(['workspace_id' => $workspace2->id]);
$tool = new ListPostsTool();
$tool->setWorkspace($workspace1);
$request = new \Laravel\Mcp\Request([]);
$response = $tool->handle($request);
$data = json_decode($response->getContent(), true);
// Should only see workspace1's posts
$this->assertCount(3, $data['posts']);
}
public function test_throws_without_workspace_context(): void
{
$this->expectException(MissingWorkspaceContextException::class);
$tool = new ListPostsTool();
// Not setting workspace context
$tool->handle(new \Laravel\Mcp\Request([]));
}
}
```
## Complete Example
Here's a complete tool implementation following all best practices:
```php
<?php
declare(strict_types=1);
namespace Mod\Commerce\Tools;
use Core\Mod\Commerce\Models\Invoice;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
use Mod\Mcp\Tools\Concerns\RequiresWorkspaceContext;
/**
* List invoices for the authenticated workspace.
*
* SECURITY: Uses authenticated workspace context to prevent cross-tenant access.
*/
class ListInvoicesTool extends Tool
{
use RequiresWorkspaceContext;
protected string $description = 'List invoices for your workspace with optional status filter';
public function handle(Request $request): Response
{
// Get workspace from auth context (never from request params)
$workspaceId = $this->getWorkspaceId();
// Validate and sanitize inputs
$status = $request->input('status');
if ($status && !in_array($status, ['paid', 'pending', 'overdue', 'void'])) {
return Response::text(json_encode([
'error' => 'Invalid status. Use: paid, pending, overdue, void',
'code' => 'VALIDATION_ERROR',
]));
}
$limit = min($request->input('limit', 10), 50);
// Query with workspace scope
$query = Invoice::with('order')
->where('workspace_id', $workspaceId)
->latest();
if ($status) {
$query->where('status', $status);
}
$invoices = $query->limit($limit)->get();
return Response::text(json_encode([
'workspace_id' => $workspaceId,
'count' => $invoices->count(),
'invoices' => $invoices->map(fn ($invoice) => [
'id' => $invoice->id,
'invoice_number' => $invoice->invoice_number,
'status' => $invoice->status,
'total' => (float) $invoice->total,
'currency' => $invoice->currency,
'issue_date' => $invoice->issue_date?->toDateString(),
'due_date' => $invoice->due_date?->toDateString(),
'is_overdue' => $invoice->isOverdue(),
])->all(),
], JSON_PRETTY_PRINT));
}
public function schema(JsonSchema $schema): array
{
return [
'status' => $schema->string('Filter by status: paid, pending, overdue, void'),
'limit' => $schema->integer('Maximum invoices to return (default 10, max 50)'),
];
}
}
```
## Learn More
- [SQL Security](/packages/mcp/sql-security) - Safe query patterns
- [Workspace Context](/packages/mcp/workspace) - Multi-tenant isolation
- [Tool Analytics](/packages/mcp/analytics) - Usage tracking
- [Quotas](/packages/mcp/quotas) - Rate limiting

View file

@ -0,0 +1,52 @@
# Discovery Scan — 2026-02-21
Automated discovery scan performed for issue #2.
## Issues Created
### Test Coverage (12 issues)
- #4 — test: add tests for ToolRegistry service
- #5 — test: add tests for AuditLogService
- #6 — test: add tests for CircuitBreaker service
- #7 — test: add tests for DataRedactor service
- #8 — test: add tests for McpHealthService
- #9 — test: add tests for McpMetricsService
- #10 — test: add tests for McpWebhookDispatcher
- #11 — test: add tests for OpenApiGenerator
- #12 — test: add tests for ToolRateLimiter
- #13 — test: add tests for AgentSessionService
- #14 — test: add tests for AgentToolRegistry
- #15 — test: add integration tests for QueryDatabase tool
### Refactoring (4 issues)
- #16 — refactor: extract SQL parser from regex to AST-based validation
- #17 — refactor: standardise tool responses with ToolResult DTO
- #18 — refactor: fix PHPStan level 5 type errors across services
- #19 — refactor: extract McpToolsRegistering tool registration from McpAgentServerCommand
### Infrastructure / Chores (4 issues)
- #20 — chore: create missing ToolRegistry YAML server definition files
- #21 — chore: add PHPStan and static analysis to dev dependencies
- #22 — chore: add CI/CD security regression tests
- #31 — chore: add query result streaming for large result sets
### Features (6 issues)
- #23 — feat: add query template system
- #24 — feat: add schema exploration tools (ListTables, DescribeTable, ListIndexes)
- #25 — feat: add data export tool (CSV, JSON)
- #26 — feat: add query result caching
- #32 — feat: add query history tracking per workspace
- #33 — feat: add data validation tool for database quality checks
### Security (3 issues)
- #27 — security: add monitoring and alerting for suspicious query patterns
- #28 — security: review ContentTools for injection and data exposure risks
- #29 — security: review commerce tools for payment data exposure
### Documentation (1 issue)
- #30 — docs: add inline documentation for ContentTools and commerce tools
### Roadmap (1 issue)
- #34 — roadmap: php-mcp production readiness
**Total: 31 issues created**

436
src/php/docs/index.md Normal file
View file

@ -0,0 +1,436 @@
# MCP Package
The MCP (Model Context Protocol) package provides AI agent tools for database queries, commerce operations, and workspace management with built-in security and quota enforcement.
## Installation
```bash
composer require host-uk/core-mcp
```
## Quick Start
```php
<?php
namespace Mod\Blog;
use Core\Events\McpToolsRegistering;
class Boot
{
public static array $listens = [
McpToolsRegistering::class => 'onMcpTools',
];
public function onMcpTools(McpToolsRegistering $event): void
{
$event->tool('blog:create-post', Tools\CreatePostTool::class);
$event->tool('blog:list-posts', Tools\ListPostsTool::class);
}
}
```
## Key Features
### Database Tools
- **[Query Database](/packages/mcp/query-database)** - SQL query execution with validation and security
- **[SQL Validation](/packages/mcp/security#sql-validation)** - Prevent destructive queries and SQL injection
- **[EXPLAIN Plans](/packages/mcp/query-database#explain)** - Query optimization analysis
### Commerce Tools
- **[Get Billing Status](/packages/mcp/commerce#billing)** - Current billing and subscription status
- **[List Invoices](/packages/mcp/commerce#invoices)** - Invoice history and details
- **[Upgrade Plan](/packages/mcp/commerce#upgrades)** - Tier upgrades with entitlement validation
### Workspace Tools
- **[Workspace Context](/packages/mcp/workspace)** - Automatic workspace/namespace resolution
- **[Quota Enforcement](/packages/mcp/quotas)** - Tool usage limits and monitoring
- **[Tool Analytics](/packages/mcp/analytics)** - Usage tracking and statistics
### Developer Tools
- **[Tool Discovery](/packages/mcp/tools#discovery)** - Automatic tool registration
- **[Dependency Management](/packages/mcp/tools#dependencies)** - Tool dependency resolution
- **[Error Handling](/packages/mcp/tools#errors)** - Consistent error responses
## Creating Tools
### Basic Tool
```php
<?php
namespace Mod\Blog\Tools;
use Core\Mcp\Tools\BaseTool;
class ListPostsTool extends BaseTool
{
public function getName(): string
{
return 'blog:list-posts';
}
public function getDescription(): string
{
return 'List all blog posts with optional filters';
}
public function getParameters(): array
{
return [
'status' => [
'type' => 'string',
'description' => 'Filter by status',
'enum' => ['published', 'draft'],
'required' => false,
],
'limit' => [
'type' => 'integer',
'description' => 'Number of posts to return',
'default' => 10,
'required' => false,
],
];
}
public function execute(array $params): array
{
$query = Post::query();
if (isset($params['status'])) {
$query->where('status', $params['status']);
}
$posts = $query->limit($params['limit'] ?? 10)->get();
return [
'posts' => $posts->map(fn ($post) => [
'id' => $post->id,
'title' => $post->title,
'slug' => $post->slug,
'status' => $post->status,
])->toArray(),
];
}
}
```
### Tool with Workspace Context
```php
<?php
namespace Mod\Blog\Tools;
use Core\Mcp\Tools\BaseTool;
use Core\Mcp\Tools\Concerns\RequiresWorkspaceContext;
class CreatePostTool extends BaseTool
{
use RequiresWorkspaceContext;
public function getName(): string
{
return 'blog:create-post';
}
public function execute(array $params): array
{
// Workspace context automatically validated
$workspace = $this->getWorkspaceContext();
$post = Post::create([
'title' => $params['title'],
'content' => $params['content'],
'workspace_id' => $workspace->id,
]);
return [
'success' => true,
'post_id' => $post->id,
];
}
}
```
### Tool with Dependencies
```php
<?php
namespace Mod\Blog\Tools;
use Core\Mcp\Tools\BaseTool;
use Core\Mcp\Dependencies\HasDependencies;
use Core\Mcp\Dependencies\ToolDependency;
class ImportPostsTool extends BaseTool
{
use HasDependencies;
public function getDependencies(): array
{
return [
new ToolDependency('blog:list-posts', DependencyType::REQUIRED),
new ToolDependency('media:upload', DependencyType::OPTIONAL),
];
}
public function execute(array $params): array
{
// Dependencies automatically validated
// ...
}
}
```
## Query Database Tool
Execute SQL queries with built-in security:
```php
use Core\Mcp\Tools\QueryDatabase;
$tool = new QueryDatabase();
$result = $tool->execute([
'query' => 'SELECT * FROM posts WHERE status = ?',
'bindings' => ['published'],
'connection' => 'mysql',
]);
// Returns:
// [
// 'rows' => [...],
// 'count' => 10,
// 'execution_time_ms' => 5.23
// ]
```
### Security Features
- **Whitelist-based validation** - Only SELECT queries allowed by default
- **No destructive operations** - DROP, TRUNCATE, DELETE blocked
- **Binding enforcement** - Prevents SQL injection
- **Connection validation** - Only allowed connections accessible
- **EXPLAIN analysis** - Query optimization insights
[Learn more about SQL Security →](/packages/mcp/security)
## Quota System
Enforce tool usage limits per workspace:
```php
// config/mcp.php
'quotas' => [
'enabled' => true,
'limits' => [
'free' => ['calls' => 100, 'per' => 'day'],
'pro' => ['calls' => 1000, 'per' => 'day'],
'business' => ['calls' => 10000, 'per' => 'day'],
'enterprise' => ['calls' => null], // Unlimited
],
],
```
Check quota before execution:
```php
use Core\Mcp\Services\McpQuotaService;
$quotaService = app(McpQuotaService::class);
if (!$quotaService->canExecute($workspace, 'blog:create-post')) {
throw new QuotaExceededException('Daily tool quota exceeded');
}
$quotaService->recordExecution($workspace, 'blog:create-post');
```
[Learn more about Quotas →](/packages/mcp/quotas)
## Tool Analytics
Track tool usage and performance:
```php
use Core\Mcp\Services\ToolAnalyticsService;
$analytics = app(ToolAnalyticsService::class);
// Get tool stats
$stats = $analytics->getToolStats('blog:create-post', period: 'week');
// Returns: ToolStats with executions, errors, avg_duration_ms
// Get workspace usage
$usage = $analytics->getWorkspaceUsage($workspace, period: 'month');
// Get most used tools
$topTools = $analytics->getTopTools(limit: 10, period: 'week');
```
[Learn more about Analytics →](/packages/mcp/analytics)
## Configuration
```php
// config/mcp.php
return [
'enabled' => true,
'tools' => [
'auto_discover' => true,
'cache_enabled' => true,
],
'query_database' => [
'allowed_connections' => ['mysql', 'pgsql'],
'forbidden_keywords' => [
'DROP', 'TRUNCATE', 'DELETE', 'UPDATE', 'INSERT',
'ALTER', 'CREATE', 'GRANT', 'REVOKE',
],
'max_execution_time' => 5000, // ms
'enable_explain' => true,
],
'quotas' => [
'enabled' => true,
'limits' => [
'free' => ['calls' => 100, 'per' => 'day'],
'pro' => ['calls' => 1000, 'per' => 'day'],
'business' => ['calls' => 10000, 'per' => 'day'],
'enterprise' => ['calls' => null],
],
],
'analytics' => [
'enabled' => true,
'retention_days' => 90,
],
];
```
## Middleware
```php
use Core\Mcp\Middleware\ValidateWorkspaceContext;
use Core\Mcp\Middleware\CheckMcpQuota;
use Core\Mcp\Middleware\ValidateToolDependencies;
Route::middleware([
ValidateWorkspaceContext::class,
CheckMcpQuota::class,
ValidateToolDependencies::class,
])->group(function () {
// MCP tool routes
});
```
## Best Practices
### 1. Use Workspace Context
```php
// ✅ Good - workspace aware
class CreatePostTool extends BaseTool
{
use RequiresWorkspaceContext;
}
// ❌ Bad - no workspace context
class CreatePostTool extends BaseTool
{
public function execute(array $params): array
{
$post = Post::create($params); // No workspace_id!
}
}
```
### 2. Validate Parameters
```php
// ✅ Good - strict validation
public function getParameters(): array
{
return [
'title' => [
'type' => 'string',
'required' => true,
'maxLength' => 255,
],
];
}
```
### 3. Handle Errors Gracefully
```php
// ✅ Good - clear error messages
public function execute(array $params): array
{
try {
return ['success' => true, 'data' => $result];
} catch (\Exception $e) {
return [
'success' => false,
'error' => $e->getMessage(),
'code' => 'TOOL_EXECUTION_FAILED',
];
}
}
```
### 4. Document Tools Well
```php
// ✅ Good - comprehensive description
public function getDescription(): string
{
return 'Create a new blog post with title, content, and optional metadata. '
. 'Requires workspace context. Validates entitlements before creation.';
}
```
## Testing
```php
<?php
namespace Tests\Feature\Mcp;
use Tests\TestCase;
use Mod\Blog\Tools\ListPostsTool;
class ListPostsToolTest extends TestCase
{
public function test_lists_posts(): void
{
Post::factory()->count(5)->create(['status' => 'published']);
$tool = new ListPostsTool();
$result = $tool->execute([
'status' => 'published',
'limit' => 10,
]);
$this->assertArrayHasKey('posts', $result);
$this->assertCount(5, $result['posts']);
}
}
```
## Learn More
- [Query Database →](/packages/mcp/query-database)
- [SQL Security →](/packages/mcp/security)
- [Workspace Context →](/packages/mcp/workspace)
- [Tool Analytics →](/packages/mcp/analytics)
- [Quota System →](/packages/mcp/quotas)

View file

@ -0,0 +1,452 @@
# Query Database Tool
The MCP package provides a secure SQL query execution tool with validation, connection management, and EXPLAIN plan analysis.
## Overview
The Query Database tool allows AI agents to:
- Execute SELECT queries safely
- Analyze query performance
- Access multiple database connections
- Prevent destructive operations
- Enforce workspace context
## Basic Usage
```php
use Core\Mcp\Tools\QueryDatabase;
$tool = new QueryDatabase();
$result = $tool->execute([
'query' => 'SELECT * FROM posts WHERE status = ?',
'bindings' => ['published'],
'connection' => 'mysql',
]);
// Returns:
// [
// 'rows' => [...],
// 'count' => 10,
// 'execution_time_ms' => 5.23
// ]
```
## Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `query` | string | Yes | SQL SELECT query |
| `bindings` | array | No | Query parameters (prevents SQL injection) |
| `connection` | string | No | Database connection name (default: default) |
| `explain` | bool | No | Include EXPLAIN plan analysis |
## Security Validation
### Allowed Operations
✅ Only SELECT queries are allowed:
```php
// ✅ Allowed
'SELECT * FROM posts'
'SELECT id, title FROM posts WHERE status = ?'
'SELECT COUNT(*) FROM users'
// ❌ Blocked
'DELETE FROM posts'
'UPDATE posts SET status = ?'
'DROP TABLE posts'
'TRUNCATE posts'
```
### Forbidden Keywords
The following are automatically blocked:
- `DROP`
- `TRUNCATE`
- `DELETE`
- `UPDATE`
- `INSERT`
- `ALTER`
- `CREATE`
- `GRANT`
- `REVOKE`
### Required WHERE Clauses
Queries on large tables must include WHERE clauses:
```php
// ✅ Good - has WHERE clause
'SELECT * FROM posts WHERE user_id = ?'
// ⚠️ Warning - no WHERE clause
'SELECT * FROM posts'
// Returns warning if table has > 10,000 rows
```
### Connection Validation
Only whitelisted connections are accessible:
```php
// config/mcp.php
'query_database' => [
'allowed_connections' => ['mysql', 'pgsql', 'analytics'],
],
```
## EXPLAIN Plan Analysis
Enable query optimization insights:
```php
$result = $tool->execute([
'query' => 'SELECT * FROM posts WHERE status = ?',
'bindings' => ['published'],
'explain' => true,
]);
// Returns additional 'explain' key:
// [
// 'rows' => [...],
// 'explain' => [
// 'type' => 'ref',
// 'key' => 'idx_status',
// 'rows_examined' => 150,
// 'analysis' => 'Query uses index. Performance: Good',
// 'recommendations' => []
// ]
// ]
```
### Performance Analysis
The EXPLAIN analyzer provides human-readable insights:
**Good Performance:**
```
"Query uses index. Performance: Good"
```
**Index Missing:**
```
"Warning: Full table scan detected. Consider adding an index on 'status'"
```
**High Row Count:**
```
"Warning: Query examines 50,000 rows. Consider adding WHERE clause to limit results"
```
## Examples
### Basic SELECT
```php
$result = $tool->execute([
'query' => 'SELECT id, title, created_at FROM posts LIMIT 10',
]);
foreach ($result['rows'] as $row) {
echo "{$row['title']}\n";
}
```
### With Parameters
```php
$result = $tool->execute([
'query' => 'SELECT * FROM posts WHERE user_id = ? AND status = ?',
'bindings' => [42, 'published'],
]);
```
### Aggregation
```php
$result = $tool->execute([
'query' => 'SELECT status, COUNT(*) as count FROM posts GROUP BY status',
]);
// Returns: [
// ['status' => 'draft', 'count' => 15],
// ['status' => 'published', 'count' => 42],
// ]
```
### Join Query
```php
$result = $tool->execute([
'query' => '
SELECT posts.title, users.name
FROM posts
JOIN users ON posts.user_id = users.id
WHERE posts.status = ?
LIMIT 10
',
'bindings' => ['published'],
]);
```
### Date Filtering
```php
$result = $tool->execute([
'query' => '
SELECT *
FROM posts
WHERE created_at >= ?
AND created_at < ?
ORDER BY created_at DESC
',
'bindings' => ['2024-01-01', '2024-02-01'],
]);
```
## Multiple Connections
Query different databases:
```php
// Main application database
$posts = $tool->execute([
'query' => 'SELECT * FROM posts',
'connection' => 'mysql',
]);
// Analytics database
$stats = $tool->execute([
'query' => 'SELECT * FROM page_views',
'connection' => 'analytics',
]);
// PostgreSQL database
$data = $tool->execute([
'query' => 'SELECT * FROM logs',
'connection' => 'pgsql',
]);
```
## Error Handling
### Forbidden Query
```php
$result = $tool->execute([
'query' => 'DELETE FROM posts WHERE id = 1',
]);
// Returns:
// [
// 'success' => false,
// 'error' => 'Forbidden query: DELETE operations not allowed',
// 'code' => 'FORBIDDEN_QUERY'
// ]
```
### Invalid Connection
```php
$result = $tool->execute([
'query' => 'SELECT * FROM posts',
'connection' => 'unknown',
]);
// Returns:
// [
// 'success' => false,
// 'error' => 'Connection "unknown" not allowed',
// 'code' => 'INVALID_CONNECTION'
// ]
```
### SQL Error
```php
$result = $tool->execute([
'query' => 'SELECT * FROM nonexistent_table',
]);
// Returns:
// [
// 'success' => false,
// 'error' => 'Table "nonexistent_table" doesn\'t exist',
// 'code' => 'SQL_ERROR'
// ]
```
## Configuration
```php
// config/mcp.php
'query_database' => [
// Allowed database connections
'allowed_connections' => [
'mysql',
'pgsql',
'analytics',
],
// Forbidden SQL keywords
'forbidden_keywords' => [
'DROP', 'TRUNCATE', 'DELETE', 'UPDATE', 'INSERT',
'ALTER', 'CREATE', 'GRANT', 'REVOKE',
],
// Maximum execution time (milliseconds)
'max_execution_time' => 5000,
// Enable EXPLAIN plan analysis
'enable_explain' => true,
// Warn on queries without WHERE clause for tables larger than:
'warn_no_where_threshold' => 10000,
],
```
## Workspace Context
Queries are automatically scoped to the current workspace:
```php
// When workspace context is set
$result = $tool->execute([
'query' => 'SELECT * FROM posts',
]);
// Equivalent to:
// 'SELECT * FROM posts WHERE workspace_id = ?'
// with workspace_id automatically added
```
Disable automatic scoping:
```php
$result = $tool->execute([
'query' => 'SELECT * FROM global_settings',
'ignore_workspace_scope' => true,
]);
```
## Best Practices
### 1. Always Use Bindings
```php
// ✅ Good - prevents SQL injection
$tool->execute([
'query' => 'SELECT * FROM posts WHERE user_id = ?',
'bindings' => [$userId],
]);
// ❌ Bad - vulnerable to SQL injection
$tool->execute([
'query' => "SELECT * FROM posts WHERE user_id = {$userId}",
]);
```
### 2. Limit Results
```php
// ✅ Good - limits results
'SELECT * FROM posts LIMIT 100'
// ❌ Bad - could return millions of rows
'SELECT * FROM posts'
```
### 3. Use EXPLAIN for Optimization
```php
// ✅ Good - analyze slow queries
$result = $tool->execute([
'query' => 'SELECT * FROM posts WHERE status = ?',
'bindings' => ['published'],
'explain' => true,
]);
if (isset($result['explain']['recommendations'])) {
foreach ($result['explain']['recommendations'] as $rec) {
error_log("Query optimization: {$rec}");
}
}
```
### 4. Handle Errors Gracefully
```php
// ✅ Good - check for errors
$result = $tool->execute([...]);
if (!($result['success'] ?? true)) {
return [
'error' => $result['error'],
'code' => $result['code'],
];
}
return $result['rows'];
```
## Testing
```php
<?php
namespace Tests\Feature\Mcp;
use Tests\TestCase;
use Core\Mcp\Tools\QueryDatabase;
class QueryDatabaseTest extends TestCase
{
public function test_executes_select_query(): void
{
Post::factory()->create(['title' => 'Test Post']);
$tool = new QueryDatabase();
$result = $tool->execute([
'query' => 'SELECT * FROM posts WHERE title = ?',
'bindings' => ['Test Post'],
]);
$this->assertTrue($result['success'] ?? true);
$this->assertCount(1, $result['rows']);
}
public function test_blocks_delete_query(): void
{
$tool = new QueryDatabase();
$result = $tool->execute([
'query' => 'DELETE FROM posts WHERE id = 1',
]);
$this->assertFalse($result['success']);
$this->assertEquals('FORBIDDEN_QUERY', $result['code']);
}
public function test_validates_connection(): void
{
$tool = new QueryDatabase();
$result = $tool->execute([
'query' => 'SELECT 1',
'connection' => 'invalid',
]);
$this->assertFalse($result['success']);
$this->assertEquals('INVALID_CONNECTION', $result['code']);
}
}
```
## Learn More
- [SQL Security →](/packages/mcp/security)
- [Workspace Context →](/packages/mcp/workspace)
- [Tool Analytics →](/packages/mcp/analytics)

405
src/php/docs/quotas.md Normal file
View file

@ -0,0 +1,405 @@
# Usage Quotas
Tier-based rate limiting and usage quotas for MCP tools.
## Overview
The quota system enforces usage limits based on workspace subscription tiers:
**Tiers:**
- **Free:** 60 requests/hour, 500 queries/day
- **Pro:** 600 requests/hour, 10,000 queries/day
- **Enterprise:** Unlimited
## Quota Enforcement
### Middleware
```php
use Core\Mcp\Middleware\CheckMcpQuota;
Route::middleware([CheckMcpQuota::class])
->post('/mcp/tools/{tool}', [McpController::class, 'execute']);
```
**Process:**
1. Identifies workspace from context
2. Checks current usage against tier limits
3. Allows or denies request
4. Records usage on success
### Manual Checking
```php
use Core\Mcp\Services\McpQuotaService;
$quota = app(McpQuotaService::class);
// Check if within quota
if (!$quota->withinLimit($workspace)) {
return response()->json([
'error' => 'Quota exceeded',
'message' => 'You have reached your hourly limit',
'reset_at' => $quota->resetTime($workspace),
], 429);
}
// Record usage
$quota->recordUsage($workspace, 'query_database');
```
## Quota Configuration
```php
// config/mcp.php
return [
'quotas' => [
'free' => [
'requests_per_hour' => 60,
'queries_per_day' => 500,
'max_query_rows' => 1000,
],
'pro' => [
'requests_per_hour' => 600,
'queries_per_day' => 10000,
'max_query_rows' => 10000,
],
'enterprise' => [
'requests_per_hour' => null, // Unlimited
'queries_per_day' => null,
'max_query_rows' => 100000,
],
],
];
```
## Usage Tracking
### Current Usage
```php
use Core\Mcp\Services\McpQuotaService;
$quota = app(McpQuotaService::class);
// Get current hour's usage
$hourlyUsage = $quota->getHourlyUsage($workspace);
// Get current day's usage
$dailyUsage = $quota->getDailyUsage($workspace);
// Get usage percentage
$percentage = $quota->usagePercentage($workspace);
```
### Usage Response Headers
```
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 1706274000
X-RateLimit-Reset-At: 2026-01-26T13:00:00Z
```
**Implementation:**
```php
use Core\Mcp\Middleware\CheckMcpQuota;
class CheckMcpQuota
{
public function handle($request, Closure $next)
{
$workspace = $request->workspace;
$quota = app(McpQuotaService::class);
$response = $next($request);
// Add quota headers
$response->headers->set('X-RateLimit-Limit', $quota->getLimit($workspace));
$response->headers->set('X-RateLimit-Remaining', $quota->getRemaining($workspace));
$response->headers->set('X-RateLimit-Reset', $quota->resetTime($workspace)->timestamp);
return $response;
}
}
```
## Quota Exceeded Response
```json
{
"error": "quota_exceeded",
"message": "You have exceeded your hourly request limit",
"current_usage": 60,
"limit": 60,
"reset_at": "2026-01-26T13:00:00Z",
"upgrade_url": "https://example.com/billing/upgrade"
}
```
**HTTP Status:** 429 Too Many Requests
## Upgrading Tiers
```php
use Mod\Tenant\Models\Workspace;
$workspace = Workspace::find($id);
// Upgrade to Pro
$workspace->update([
'subscription_tier' => 'pro',
]);
// New limits immediately apply
$quota = app(McpQuotaService::class);
$newLimit = $quota->getLimit($workspace); // 600
```
## Quota Monitoring
### Admin Dashboard
```php
class QuotaUsage extends Component
{
public function render()
{
$quota = app(McpQuotaService::class);
$workspaces = Workspace::all()->map(function ($workspace) use ($quota) {
return [
'name' => $workspace->name,
'tier' => $workspace->subscription_tier,
'hourly_usage' => $quota->getHourlyUsage($workspace),
'hourly_limit' => $quota->getLimit($workspace, 'hourly'),
'daily_usage' => $quota->getDailyUsage($workspace),
'daily_limit' => $quota->getLimit($workspace, 'daily'),
];
});
return view('mcp::admin.quota-usage', compact('workspaces'));
}
}
```
**View:**
```blade
<x-admin::table>
<x-slot:header>
<x-admin::table.th>Workspace</x-admin::table.th>
<x-admin::table.th>Tier</x-admin::table.th>
<x-admin::table.th>Hourly Usage</x-admin::table.th>
<x-admin::table.th>Daily Usage</x-admin::table.th>
</x-slot:header>
@foreach($workspaces as $workspace)
<x-admin::table.tr>
<x-admin::table.td>{{ $workspace['name'] }}</x-admin::table.td>
<x-admin::table.td>
<x-admin::badge :color="$workspace['tier'] === 'enterprise' ? 'purple' : 'blue'">
{{ ucfirst($workspace['tier']) }}
</x-admin::badge>
</x-admin::table.td>
<x-admin::table.td>
{{ $workspace['hourly_usage'] }} / {{ $workspace['hourly_limit'] ?? '∞' }}
<progress
value="{{ $workspace['hourly_usage'] }}"
max="{{ $workspace['hourly_limit'] ?? 100 }}"
></progress>
</x-admin::table.td>
<x-admin::table.td>
{{ $workspace['daily_usage'] }} / {{ $workspace['daily_limit'] ?? '∞' }}
</x-admin::table.td>
</x-admin::table.tr>
@endforeach
</x-admin::table>
```
### Alerts
Send notifications when nearing limits:
```php
use Core\Mcp\Services\McpQuotaService;
$quota = app(McpQuotaService::class);
$usage = $quota->usagePercentage($workspace);
if ($usage >= 80) {
// Alert: 80% of quota used
$workspace->owner->notify(
new QuotaWarningNotification($workspace, $usage)
);
}
if ($usage >= 100) {
// Alert: Quota exceeded
$workspace->owner->notify(
new QuotaExceededNotification($workspace)
);
}
```
## Custom Quotas
Override default quotas for specific workspaces:
```php
use Core\Mcp\Models\McpUsageQuota;
// Set custom quota
McpUsageQuota::create([
'workspace_id' => $workspace->id,
'requests_per_hour' => 1000, // Custom limit
'queries_per_day' => 50000,
'expires_at' => now()->addMonths(3), // Temporary increase
]);
// Custom quota takes precedence over tier defaults
```
## Resetting Quotas
```bash
# Reset all quotas
php artisan mcp:reset-quotas
# Reset specific workspace
php artisan mcp:reset-quotas --workspace=123
# Reset specific period
php artisan mcp:reset-quotas --period=hourly
```
## Bypass Quotas (Admin)
```php
// Bypass quota enforcement
$result = $tool->execute($params, [
'bypass_quota' => true, // Requires admin permission
]);
```
**Use cases:**
- Internal tools
- Admin operations
- System maintenance
- Testing
## Testing
```php
use Tests\TestCase;
use Core\Mcp\Services\McpQuotaService;
class QuotaTest extends TestCase
{
public function test_enforces_hourly_limit(): void
{
$workspace = Workspace::factory()->create(['tier' => 'free']);
$quota = app(McpQuotaService::class);
// Exhaust quota
for ($i = 0; $i < 60; $i++) {
$quota->recordUsage($workspace, 'test');
}
$this->assertFalse($quota->withinLimit($workspace));
}
public function test_resets_after_hour(): void
{
$workspace = Workspace::factory()->create();
$quota = app(McpQuotaService::class);
// Use quota
$quota->recordUsage($workspace, 'test');
// Travel 1 hour
$this->travel(1)->hour();
$this->assertTrue($quota->withinLimit($workspace));
}
public function test_enterprise_has_no_limit(): void
{
$workspace = Workspace::factory()->create(['tier' => 'enterprise']);
$quota = app(McpQuotaService::class);
// Use quota 1000 times
for ($i = 0; $i < 1000; $i++) {
$quota->recordUsage($workspace, 'test');
}
$this->assertTrue($quota->withinLimit($workspace));
}
}
```
## Best Practices
### 1. Check Quotas Early
```php
// ✅ Good - check before processing
if (!$quota->withinLimit($workspace)) {
return response()->json(['error' => 'Quota exceeded'], 429);
}
$result = $tool->execute($params);
// ❌ Bad - check after processing
$result = $tool->execute($params);
if (!$quota->withinLimit($workspace)) {
// Too late!
}
```
### 2. Provide Clear Feedback
```php
// ✅ Good - helpful error message
return response()->json([
'error' => 'Quota exceeded',
'reset_at' => $quota->resetTime($workspace),
'upgrade_url' => route('billing.upgrade'),
], 429);
// ❌ Bad - generic error
return response()->json(['error' => 'Too many requests'], 429);
```
### 3. Monitor Usage Patterns
```php
// ✅ Good - alert at 80%
if ($usage >= 80) {
$this->notifyUser();
}
// ❌ Bad - only alert when exhausted
if ($usage >= 100) {
// User already hit limit
}
```
### 4. Use Appropriate Limits
```php
// ✅ Good - reasonable limits
'free' => ['requests_per_hour' => 60],
'pro' => ['requests_per_hour' => 600],
// ❌ Bad - too restrictive
'free' => ['requests_per_hour' => 5], // Unusable
```
## Learn More
- [Analytics →](/packages/mcp/analytics)
- [Security →](/packages/mcp/security)
- [Multi-Tenancy →](/packages/core/tenancy)

363
src/php/docs/security.md Normal file
View file

@ -0,0 +1,363 @@
# MCP Security
Security features for protecting database access and preventing SQL injection in MCP tools.
## SQL Query Validation
### Validation Rules
The `SqlQueryValidator` enforces strict rules on all queries:
**Allowed:**
- `SELECT` statements only
- Table/column qualifiers
- WHERE clauses
- JOINs
- ORDER BY, GROUP BY
- LIMIT clauses
- Subqueries (SELECT only)
**Forbidden:**
- `INSERT`, `UPDATE`, `DELETE`, `DROP`, `CREATE`, `ALTER`
- `TRUNCATE`, `GRANT`, `REVOKE`
- Database modification operations
- System table access
- Multiple statements (`;` separated)
### Usage
```php
use Core\Mcp\Services\SqlQueryValidator;
$validator = app(SqlQueryValidator::class);
// Valid query
$result = $validator->validate('SELECT * FROM posts WHERE id = ?');
// Returns: ['valid' => true]
// Invalid query
$result = $validator->validate('DROP TABLE users');
// Returns: ['valid' => false, 'error' => 'Only SELECT queries are allowed']
```
### Forbidden Patterns
```php
// ❌ Data modification
DELETE FROM users WHERE id = 1
UPDATE posts SET status = 'published'
INSERT INTO logs VALUES (...)
// ❌ Schema changes
DROP TABLE posts
ALTER TABLE users ADD COLUMN...
CREATE INDEX...
// ❌ Permission changes
GRANT ALL ON *.* TO user
REVOKE SELECT ON posts FROM user
// ❌ Multiple statements
SELECT * FROM posts; DROP TABLE users;
// ❌ System tables
SELECT * FROM information_schema.tables
SELECT * FROM mysql.user
```
### Parameterized Queries
Always use bindings to prevent SQL injection:
```php
// ✅ Good - parameterized
$tool->execute([
'query' => 'SELECT * FROM posts WHERE user_id = ? AND status = ?',
'bindings' => [$userId, 'published'],
]);
// ❌ Bad - SQL injection risk
$tool->execute([
'query' => "SELECT * FROM posts WHERE user_id = {$userId}",
]);
```
## Workspace Context Security
### Automatic Scoping
Queries are automatically scoped to the current workspace:
```php
use Core\Mcp\Context\WorkspaceContext;
// Get workspace context from request
$context = WorkspaceContext::fromRequest($request);
// Queries automatically filtered by workspace_id
$result = $tool->execute([
'query' => 'SELECT * FROM posts WHERE status = ?',
'bindings' => ['published'],
], $context);
// Internally becomes:
// SELECT * FROM posts WHERE status = ? AND workspace_id = ?
```
### Validation
Tools validate workspace context before execution:
```php
use Core\Mcp\Tools\Concerns\RequiresWorkspaceContext;
class MyTool
{
use RequiresWorkspaceContext;
public function execute(array $params)
{
// Throws MissingWorkspaceContextException if context missing
$this->validateWorkspaceContext();
// Safe to proceed
$workspace = $this->workspaceContext->workspace;
}
}
```
### Bypassing (Admin Only)
```php
// Requires admin permission
$result = $tool->execute([
'query' => 'SELECT * FROM posts',
'bypass_workspace_scope' => true, // Admin only
]);
```
## Connection Security
### Allowed Connections
Only specific connections can be queried:
```php
// config/mcp.php
return [
'database' => [
'allowed_connections' => [
'mysql', // Primary database
'analytics', // Read-only analytics
'logs', // Application logs
],
'default_connection' => 'mysql',
],
];
```
### Read-Only Connections
Use read-only database users for MCP:
```php
// config/database.php
'connections' => [
'mcp_readonly' => [
'driver' => 'mysql',
'host' => env('DB_HOST'),
'database' => env('DB_DATABASE'),
'username' => env('MCP_DB_USER'), // Read-only user
'password' => env('MCP_DB_PASSWORD'),
'charset' => 'utf8mb4',
],
],
```
**Database Setup:**
```sql
-- Create read-only user
CREATE USER 'mcp_readonly'@'%' IDENTIFIED BY 'secure_password';
-- Grant SELECT only
GRANT SELECT ON app_database.* TO 'mcp_readonly'@'%';
-- Explicitly deny modifications
REVOKE INSERT, UPDATE, DELETE, DROP, CREATE, ALTER ON app_database.* FROM 'mcp_readonly'@'%';
FLUSH PRIVILEGES;
```
### Connection Validation
```php
use Core\Mcp\Services\ConnectionValidator;
$validator = app(ConnectionValidator::class);
// Check if connection is allowed
if (!$validator->isAllowed('mysql')) {
throw new ForbiddenConnectionException();
}
// Check if connection exists
if (!$validator->exists('mysql')) {
throw new InvalidConnectionException();
}
```
## Rate Limiting
Prevent abuse with rate limits:
```php
use Core\Mcp\Middleware\CheckMcpQuota;
Route::middleware([CheckMcpQuota::class])
->post('/mcp/query', [McpApiController::class, 'query']);
```
**Limits:**
| Tier | Requests/Hour | Queries/Day |
|------|--------------|-------------|
| Free | 60 | 500 |
| Pro | 600 | 10,000 |
| Enterprise | Unlimited | Unlimited |
### Quota Enforcement
```php
use Core\Mcp\Services\McpQuotaService;
$quota = app(McpQuotaService::class);
// Check if within quota
if (!$quota->withinLimit($workspace)) {
throw new QuotaExceededException();
}
// Record usage
$quota->recordUsage($workspace, 'query_database');
```
## Query Logging
All queries are logged for audit:
```php
// storage/logs/mcp-queries.log
[2026-01-26 12:00:00] Query executed
Workspace: acme-corp
User: john@example.com
Query: SELECT * FROM posts WHERE status = ?
Bindings: ["published"]
Rows: 42
Duration: 5.23ms
```
### Log Configuration
```php
// config/logging.php
'channels' => [
'mcp' => [
'driver' => 'daily',
'path' => storage_path('logs/mcp-queries.log'),
'level' => 'info',
'days' => 90, // Retain for 90 days
],
],
```
## Best Practices
### 1. Always Use Bindings
```php
// ✅ Good - parameterized
'query' => 'SELECT * FROM posts WHERE id = ?',
'bindings' => [$id],
// ❌ Bad - SQL injection risk
'query' => "SELECT * FROM posts WHERE id = {$id}",
```
### 2. Limit Result Sets
```php
// ✅ Good - limited results
'query' => 'SELECT * FROM posts LIMIT 100',
// ❌ Bad - unbounded query
'query' => 'SELECT * FROM posts',
```
### 3. Use Read-Only Connections
```php
// ✅ Good - read-only user
'connection' => 'mcp_readonly',
// ❌ Bad - admin connection
'connection' => 'mysql_admin',
```
### 4. Validate Workspace Context
```php
// ✅ Good - validate context
$this->validateWorkspaceContext();
// ❌ Bad - no validation
// (workspace boundary bypass risk)
```
## Testing
```php
use Tests\TestCase;
use Core\Mcp\Services\SqlQueryValidator;
class SecurityTest extends TestCase
{
public function test_blocks_destructive_queries(): void
{
$validator = app(SqlQueryValidator::class);
$result = $validator->validate('DROP TABLE users');
$this->assertFalse($result['valid']);
$this->assertStringContainsString('Only SELECT', $result['error']);
}
public function test_allows_select_queries(): void
{
$validator = app(SqlQueryValidator::class);
$result = $validator->validate('SELECT * FROM posts WHERE id = ?');
$this->assertTrue($result['valid']);
}
public function test_enforces_workspace_scope(): void
{
$workspace = Workspace::factory()->create();
$context = new WorkspaceContext($workspace);
$result = $tool->execute([
'query' => 'SELECT * FROM posts',
], $context);
// Should only return workspace's posts
$this->assertEquals($workspace->id, $result['rows'][0]['workspace_id']);
}
}
```
## Learn More
- [Query Database →](/packages/mcp/query-database)
- [Workspace Context →](/packages/mcp/workspace)
- [Quotas →](/packages/mcp/quotas)

View file

@ -0,0 +1,605 @@
# Guide: SQL Security
This guide documents the security controls for the Query Database MCP tool, including allowed SQL patterns, forbidden operations, and parameterized query requirements.
## Overview
The MCP Query Database tool provides AI agents with read-only SQL access. Multiple security layers protect against:
- SQL injection attacks
- Data modification/destruction
- Cross-tenant data access
- Resource exhaustion
- Information leakage
## Allowed SQL Patterns
### SELECT-Only Queries
Only `SELECT` statements are permitted. All queries must begin with `SELECT`:
```sql
-- Allowed: Basic SELECT
SELECT * FROM posts WHERE status = 'published';
-- Allowed: Specific columns
SELECT id, title, created_at FROM posts;
-- Allowed: COUNT queries
SELECT COUNT(*) FROM users WHERE active = 1;
-- Allowed: Aggregation
SELECT status, COUNT(*) as count FROM posts GROUP BY status;
-- Allowed: JOIN queries
SELECT posts.title, users.name
FROM posts
JOIN users ON posts.user_id = users.id;
-- Allowed: ORDER BY and LIMIT
SELECT * FROM posts ORDER BY created_at DESC LIMIT 10;
-- Allowed: WHERE with multiple conditions
SELECT * FROM posts
WHERE status = 'published'
AND user_id = 42
AND created_at > '2024-01-01';
```
### Supported Operators
WHERE clauses support these operators:
| Operator | Example |
|----------|---------|
| `=` | `WHERE status = 'active'` |
| `!=`, `<>` | `WHERE status != 'deleted'` |
| `>`, `>=` | `WHERE created_at > '2024-01-01'` |
| `<`, `<=` | `WHERE views < 1000` |
| `LIKE` | `WHERE title LIKE '%search%'` |
| `IN` | `WHERE status IN ('draft', 'published')` |
| `BETWEEN` | `WHERE created_at BETWEEN '2024-01-01' AND '2024-12-31'` |
| `IS NULL` | `WHERE deleted_at IS NULL` |
| `IS NOT NULL` | `WHERE email IS NOT NULL` |
| `AND` | `WHERE a = 1 AND b = 2` |
| `OR` | `WHERE status = 'draft' OR status = 'review'` |
## Forbidden Operations
### Data Modification (Blocked)
```sql
-- BLOCKED: INSERT
INSERT INTO users (name) VALUES ('attacker');
-- BLOCKED: UPDATE
UPDATE users SET role = 'admin' WHERE id = 1;
-- BLOCKED: DELETE
DELETE FROM users WHERE id = 1;
-- BLOCKED: REPLACE
REPLACE INTO users (id, name) VALUES (1, 'changed');
```
### Schema Modification (Blocked)
```sql
-- BLOCKED: DROP
DROP TABLE users;
DROP DATABASE production;
-- BLOCKED: TRUNCATE
TRUNCATE TABLE logs;
-- BLOCKED: ALTER
ALTER TABLE users ADD COLUMN backdoor TEXT;
-- BLOCKED: CREATE
CREATE TABLE malicious_table (...);
-- BLOCKED: RENAME
RENAME TABLE users TO users_backup;
```
### Permission Operations (Blocked)
```sql
-- BLOCKED: GRANT
GRANT ALL ON *.* TO 'attacker'@'%';
-- BLOCKED: REVOKE
REVOKE SELECT ON database.* FROM 'user'@'%';
-- BLOCKED: FLUSH
FLUSH PRIVILEGES;
```
### System Operations (Blocked)
```sql
-- BLOCKED: File operations
SELECT * FROM posts INTO OUTFILE '/tmp/data.csv';
SELECT LOAD_FILE('/etc/passwd');
LOAD DATA INFILE '/etc/passwd' INTO TABLE users;
-- BLOCKED: Execution
EXECUTE prepared_statement;
CALL stored_procedure();
PREPARE stmt FROM 'SELECT ...';
-- BLOCKED: Variables
SET @var = (SELECT password FROM users);
SET GLOBAL max_connections = 1;
```
### Complete Blocked Keywords List
```php
// Data modification
'INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'TRUNCATE'
// Schema changes
'DROP', 'ALTER', 'CREATE', 'RENAME'
// Permissions
'GRANT', 'REVOKE', 'FLUSH'
// System
'KILL', 'RESET', 'PURGE'
// File operations
'INTO OUTFILE', 'INTO DUMPFILE', 'LOAD_FILE', 'LOAD DATA'
// Execution
'EXECUTE', 'EXEC', 'PREPARE', 'DEALLOCATE', 'CALL'
// Variables
'SET '
```
## SQL Injection Prevention
### Dangerous Patterns (Detected and Blocked)
The validator detects and blocks common injection patterns:
#### Stacked Queries
```sql
-- BLOCKED: Multiple statements
SELECT * FROM posts; DROP TABLE users;
SELECT * FROM posts; DELETE FROM logs;
```
#### UNION Injection
```sql
-- BLOCKED: UNION attacks
SELECT * FROM posts WHERE id = 1 UNION SELECT password FROM users;
SELECT * FROM posts UNION ALL SELECT * FROM secrets;
```
#### Comment Obfuscation
```sql
-- BLOCKED: Comments hiding keywords
SELECT * FROM posts WHERE id = 1 /**/UNION/**/SELECT password FROM users;
SELECT * FROM posts; -- DROP TABLE users
SELECT * FROM posts # DELETE FROM logs
```
#### Hex Encoding
```sql
-- BLOCKED: Hex-encoded strings
SELECT * FROM posts WHERE id = 0x313B44524F50205441424C4520757365727320;
```
#### Time-Based Attacks
```sql
-- BLOCKED: Timing attacks
SELECT * FROM posts WHERE id = 1 AND SLEEP(10);
SELECT * FROM posts WHERE BENCHMARK(10000000, SHA1('test'));
```
#### System Table Access
```sql
-- BLOCKED: Information schema
SELECT * FROM information_schema.tables;
SELECT * FROM information_schema.columns WHERE table_name = 'users';
-- BLOCKED: MySQL system tables
SELECT * FROM mysql.user;
SELECT * FROM performance_schema.threads;
SELECT * FROM sys.session;
```
#### Subquery in WHERE
```sql
-- BLOCKED: Potential data exfiltration
SELECT * FROM posts WHERE id = (SELECT user_id FROM admins LIMIT 1);
```
### Detection Patterns
The validator uses these regex patterns to detect attacks:
```php
// Stacked queries
'/;\s*\S/i'
// UNION injection
'/\bUNION\b/i'
// Hex encoding
'/0x[0-9a-f]+/i'
// Dangerous functions
'/\bCHAR\s*\(/i'
'/\bBENCHMARK\s*\(/i'
'/\bSLEEP\s*\(/i'
// System tables
'/\bINFORMATION_SCHEMA\b/i'
'/\bmysql\./i'
'/\bperformance_schema\./i'
'/\bsys\./i'
// Subquery in WHERE
'/WHERE\s+.*\(\s*SELECT/i'
// Comment obfuscation
'/\/\*[^*]*\*\/\s*(?:UNION|SELECT|INSERT|UPDATE|DELETE|DROP)/i'
```
## Parameterized Queries
**Always use parameter bindings** instead of string interpolation:
### Correct Usage
```php
// SAFE: Parameterized query
$result = $tool->execute([
'query' => 'SELECT * FROM posts WHERE user_id = ? AND status = ?',
'bindings' => [$userId, 'published'],
]);
// SAFE: Multiple parameters
$result = $tool->execute([
'query' => 'SELECT * FROM orders WHERE created_at BETWEEN ? AND ? AND total > ?',
'bindings' => ['2024-01-01', '2024-12-31', 100.00],
]);
```
### Incorrect Usage (Vulnerable)
```php
// VULNERABLE: String interpolation
$result = $tool->execute([
'query' => "SELECT * FROM posts WHERE user_id = {$userId}",
]);
// VULNERABLE: Concatenation
$query = "SELECT * FROM posts WHERE status = '" . $status . "'";
$result = $tool->execute(['query' => $query]);
// VULNERABLE: sprintf
$query = sprintf("SELECT * FROM posts WHERE id = %d", $id);
$result = $tool->execute(['query' => $query]);
```
### Why Bindings Matter
With bindings, malicious input is escaped automatically:
```php
// User input
$userInput = "'; DROP TABLE users; --";
// With bindings: SAFE (input is escaped)
$tool->execute([
'query' => 'SELECT * FROM posts WHERE title = ?',
'bindings' => [$userInput],
]);
// Executed as: SELECT * FROM posts WHERE title = '\'; DROP TABLE users; --'
// Without bindings: VULNERABLE
$tool->execute([
'query' => "SELECT * FROM posts WHERE title = '$userInput'",
]);
// Executed as: SELECT * FROM posts WHERE title = ''; DROP TABLE users; --'
```
## Whitelist-Based Validation
The validator uses a whitelist approach, only allowing queries matching known-safe patterns:
### Default Whitelist Patterns
```php
// Simple SELECT with optional WHERE
'/^\s*SELECT\s+[\w\s,.*`]+\s+FROM\s+`?\w+`?
(\s+WHERE\s+[\w\s`.,!=<>\'"%()]+)*
(\s+ORDER\s+BY\s+[\w\s,`]+)?
(\s+LIMIT\s+\d+)?;?\s*$/i'
// COUNT queries
'/^\s*SELECT\s+COUNT\s*\(\s*\*?\s*\)
\s+FROM\s+`?\w+`?
(\s+WHERE\s+[\w\s`.,!=<>\'"%()]+)*;?\s*$/i'
// Explicit column list
'/^\s*SELECT\s+`?\w+`?(\s*,\s*`?\w+`?)*
\s+FROM\s+`?\w+`?
(\s+WHERE\s+[\w\s`.,!=<>\'"%()]+)*
(\s+ORDER\s+BY\s+[\w\s,`]+)?
(\s+LIMIT\s+\d+)?;?\s*$/i'
```
### Adding Custom Patterns
```php
// config/mcp.php
'database' => [
'use_whitelist' => true,
'whitelist_patterns' => [
// Allow specific JOIN pattern
'/^\s*SELECT\s+[\w\s,.*`]+\s+FROM\s+posts\s+JOIN\s+users\s+ON\s+posts\.user_id\s*=\s*users\.id/i',
],
],
```
## Connection Security
### Allowed Connections
Only whitelisted database connections can be queried:
```php
// config/mcp.php
'database' => [
'allowed_connections' => [
'mysql', // Primary database
'analytics', // Read-only analytics
'logs', // Application logs
],
'connection' => 'mcp_readonly', // Default MCP connection
],
```
### Read-Only Database User
Create a dedicated read-only user for MCP:
```sql
-- Create read-only user
CREATE USER 'mcp_readonly'@'%' IDENTIFIED BY 'secure_password';
-- Grant SELECT only
GRANT SELECT ON app_database.* TO 'mcp_readonly'@'%';
-- Explicitly deny write operations
REVOKE INSERT, UPDATE, DELETE, DROP, CREATE, ALTER
ON app_database.* FROM 'mcp_readonly'@'%';
FLUSH PRIVILEGES;
```
Configure in Laravel:
```php
// config/database.php
'connections' => [
'mcp_readonly' => [
'driver' => 'mysql',
'host' => env('DB_HOST'),
'database' => env('DB_DATABASE'),
'username' => env('MCP_DB_USER', 'mcp_readonly'),
'password' => env('MCP_DB_PASSWORD'),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'strict' => true,
],
],
```
## Blocked Tables
Configure tables that cannot be queried:
```php
// config/mcp.php
'database' => [
'blocked_tables' => [
'users', // User credentials
'password_resets', // Password tokens
'sessions', // Session data
'api_keys', // API credentials
'oauth_access_tokens', // OAuth tokens
'personal_access_tokens', // Sanctum tokens
'failed_jobs', // Job queue data
],
],
```
The validator checks for table references in multiple formats:
```php
// All these are blocked for 'users' table:
'SELECT * FROM users'
'SELECT * FROM `users`'
'SELECT posts.*, users.name FROM posts JOIN users...'
'SELECT users.email FROM ...'
```
## Row Limits
Automatic row limits prevent data exfiltration:
```php
// config/mcp.php
'database' => [
'max_rows' => 1000, // Maximum rows per query
],
```
If query doesn't include LIMIT, one is added automatically:
```php
// Query without LIMIT
$tool->execute(['query' => 'SELECT * FROM posts']);
// Becomes: SELECT * FROM posts LIMIT 1000
// Query with smaller LIMIT (preserved)
$tool->execute(['query' => 'SELECT * FROM posts LIMIT 10']);
// Stays: SELECT * FROM posts LIMIT 10
```
## Error Handling
### Forbidden Query Response
```json
{
"error": "Query rejected: Disallowed SQL keyword 'DELETE' detected"
}
```
### Invalid Structure Response
```json
{
"error": "Query rejected: Query must begin with SELECT"
}
```
### Not Whitelisted Response
```json
{
"error": "Query rejected: Query does not match any allowed pattern"
}
```
### Sanitized SQL Errors
Database errors are sanitized to prevent information leakage:
```php
// Original error (logged for debugging)
"SQLSTATE[42S02]: Table 'production.secret_table' doesn't exist at 192.168.1.100"
// Sanitized response (returned to client)
"Query execution failed: Table '[path]' doesn't exist at [ip]"
```
## Configuration Reference
```php
// config/mcp.php
return [
'database' => [
// Database connection for MCP queries
'connection' => env('MCP_DB_CONNECTION', 'mcp_readonly'),
// Use whitelist validation (recommended: true)
'use_whitelist' => true,
// Custom whitelist patterns (regex)
'whitelist_patterns' => [],
// Tables that cannot be queried
'blocked_tables' => [
'users',
'password_resets',
'sessions',
'api_keys',
],
// Maximum rows per query
'max_rows' => 1000,
// Query execution timeout (milliseconds)
'timeout' => 5000,
// Enable EXPLAIN analysis
'enable_explain' => true,
],
];
```
## Testing Security
```php
use Tests\TestCase;
use Core\Mod\Mcp\Services\SqlQueryValidator;
use Core\Mod\Mcp\Exceptions\ForbiddenQueryException;
class SqlSecurityTest extends TestCase
{
private SqlQueryValidator $validator;
protected function setUp(): void
{
parent::setUp();
$this->validator = new SqlQueryValidator();
}
public function test_blocks_delete(): void
{
$this->expectException(ForbiddenQueryException::class);
$this->validator->validate('DELETE FROM users');
}
public function test_blocks_union_injection(): void
{
$this->expectException(ForbiddenQueryException::class);
$this->validator->validate("SELECT * FROM posts UNION SELECT password FROM users");
}
public function test_blocks_stacked_queries(): void
{
$this->expectException(ForbiddenQueryException::class);
$this->validator->validate("SELECT * FROM posts; DROP TABLE users");
}
public function test_blocks_system_tables(): void
{
$this->expectException(ForbiddenQueryException::class);
$this->validator->validate("SELECT * FROM information_schema.tables");
}
public function test_allows_safe_select(): void
{
$this->validator->validate("SELECT id, title FROM posts WHERE status = 'published'");
$this->assertTrue(true); // No exception = pass
}
public function test_allows_count(): void
{
$this->validator->validate("SELECT COUNT(*) FROM posts");
$this->assertTrue(true);
}
}
```
## Best Practices Summary
1. **Always use parameterized queries** - Never interpolate values into SQL strings
2. **Use a read-only database user** - Database-level protection against modifications
3. **Configure blocked tables** - Prevent access to sensitive data
4. **Enable whitelist validation** - Only allow known-safe query patterns
5. **Set appropriate row limits** - Prevent large data exports
6. **Review logs regularly** - Monitor for suspicious query patterns
7. **Test security controls** - Include injection tests in your test suite
## Learn More
- [Query Database Tool](/packages/mcp/query-database) - Tool usage
- [Workspace Context](/packages/mcp/workspace) - Multi-tenant isolation
- [Creating MCP Tools](/packages/mcp/creating-mcp-tools) - Tool development

View file

@ -0,0 +1,739 @@
# API Reference: MCP Tools
Complete reference for all MCP tools including parameters, response formats, and error handling.
## Database Tools
### query_database
Execute read-only SQL queries against the database.
**Description:** Execute a read-only SQL SELECT query against the database
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `query` | string | Yes | SQL SELECT query to execute. Only read-only SELECT queries are permitted. |
| `explain` | boolean | No | If true, runs EXPLAIN on the query instead of executing it. Useful for query optimization. Default: `false` |
**Example Request:**
```json
{
"tool": "query_database",
"arguments": {
"query": "SELECT id, title, status FROM posts WHERE status = 'published' LIMIT 10"
}
}
```
**Success Response:**
```json
[
{"id": 1, "title": "First Post", "status": "published"},
{"id": 2, "title": "Second Post", "status": "published"}
]
```
**With EXPLAIN:**
```json
{
"tool": "query_database",
"arguments": {
"query": "SELECT * FROM posts WHERE status = 'published'",
"explain": true
}
}
```
**EXPLAIN Response:**
```json
{
"explain": [
{
"id": 1,
"select_type": "SIMPLE",
"table": "posts",
"type": "ref",
"key": "idx_status",
"rows": 150,
"Extra": "Using index"
}
],
"query": "SELECT * FROM posts WHERE status = 'published' LIMIT 1000",
"interpretation": [
{
"table": "posts",
"analysis": [
"GOOD: Using index: idx_status"
]
}
]
}
```
**Error Response - Forbidden Query:**
```json
{
"error": "Query rejected: Disallowed SQL keyword 'DELETE' detected"
}
```
**Error Response - Invalid Structure:**
```json
{
"error": "Query rejected: Query must begin with SELECT"
}
```
**Security Notes:**
- Only SELECT queries are allowed
- Blocked keywords: INSERT, UPDATE, DELETE, DROP, TRUNCATE, ALTER, CREATE, GRANT, REVOKE
- UNION queries are blocked
- System tables (information_schema, mysql.*) are blocked
- Automatic LIMIT applied if not specified
- Use read-only database connection
---
### list_tables
List all database tables in the application.
**Description:** List all database tables
**Parameters:** None
**Example Request:**
```json
{
"tool": "list_tables",
"arguments": {}
}
```
**Success Response:**
```json
[
"users",
"posts",
"comments",
"tags",
"categories",
"media",
"migrations",
"jobs"
]
```
**Security Notes:**
- Returns table names only, not structure
- Some tables may be filtered based on configuration
---
## Commerce Tools
### get_billing_status
Get billing status for the authenticated workspace.
**Description:** Get billing status for your workspace including subscription, current plan, and billing period
**Parameters:** None (workspace from authentication context)
**Requires:** Workspace Context
**Example Request:**
```json
{
"tool": "get_billing_status",
"arguments": {}
}
```
**Success Response:**
```json
{
"workspace": {
"id": 42,
"name": "Acme Corp"
},
"subscription": {
"id": 123,
"status": "active",
"gateway": "stripe",
"billing_cycle": "monthly",
"current_period_start": "2024-01-01T00:00:00+00:00",
"current_period_end": "2024-02-01T00:00:00+00:00",
"days_until_renewal": 15,
"cancel_at_period_end": false,
"on_trial": false,
"trial_ends_at": null
},
"packages": [
{
"code": "professional",
"name": "Professional Plan",
"status": "active",
"expires_at": null
}
]
}
```
**Response Fields:**
| Field | Type | Description |
|-------|------|-------------|
| `workspace.id` | integer | Workspace ID |
| `workspace.name` | string | Workspace name |
| `subscription.status` | string | active, trialing, past_due, canceled |
| `subscription.billing_cycle` | string | monthly, yearly |
| `subscription.days_until_renewal` | integer | Days until next billing |
| `subscription.on_trial` | boolean | Currently in trial period |
| `packages` | array | Active feature packages |
**Error Response - No Workspace Context:**
```json
{
"error": "MCP tool 'get_billing_status' requires workspace context. Authenticate with an API key or user session."
}
```
---
### list_invoices
List invoices for the authenticated workspace.
**Description:** List invoices for your workspace with optional status filter
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `status` | string | No | Filter by status: paid, pending, overdue, void |
| `limit` | integer | No | Maximum invoices to return. Default: 10, Max: 50 |
**Requires:** Workspace Context
**Example Request:**
```json
{
"tool": "list_invoices",
"arguments": {
"status": "paid",
"limit": 5
}
}
```
**Success Response:**
```json
{
"workspace_id": 42,
"count": 5,
"invoices": [
{
"id": 1001,
"invoice_number": "INV-2024-001",
"status": "paid",
"subtotal": 99.00,
"discount_amount": 0.00,
"tax_amount": 19.80,
"total": 118.80,
"amount_paid": 118.80,
"amount_due": 0.00,
"currency": "GBP",
"issue_date": "2024-01-01",
"due_date": "2024-01-15",
"paid_at": "2024-01-10T14:30:00+00:00",
"is_overdue": false,
"order_number": "ORD-2024-001"
}
]
}
```
**Response Fields:**
| Field | Type | Description |
|-------|------|-------------|
| `invoice_number` | string | Unique invoice identifier |
| `status` | string | paid, pending, overdue, void |
| `total` | number | Total amount including tax |
| `amount_due` | number | Remaining amount to pay |
| `is_overdue` | boolean | Past due date with unpaid balance |
---
### upgrade_plan
Preview or execute a plan upgrade/downgrade.
**Description:** Preview or execute a plan upgrade/downgrade for your workspace subscription
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `package_code` | string | Yes | Code of the new package (e.g., agency, enterprise) |
| `preview` | boolean | No | If true, only preview without executing. Default: `true` |
| `immediate` | boolean | No | If true, apply immediately; false schedules for period end. Default: `true` |
**Requires:** Workspace Context
**Example Request - Preview:**
```json
{
"tool": "upgrade_plan",
"arguments": {
"package_code": "enterprise",
"preview": true
}
}
```
**Preview Response:**
```json
{
"preview": true,
"current_package": "professional",
"new_package": "enterprise",
"proration": {
"is_upgrade": true,
"is_downgrade": false,
"current_plan_price": 99.00,
"new_plan_price": 299.00,
"credit_amount": 49.50,
"prorated_new_cost": 149.50,
"net_amount": 100.00,
"requires_payment": true,
"days_remaining": 15,
"currency": "GBP"
}
}
```
**Execute Response:**
```json
{
"success": true,
"immediate": true,
"current_package": "professional",
"new_package": "enterprise",
"proration": {
"is_upgrade": true,
"net_amount": 100.00
},
"subscription_status": "active"
}
```
**Error Response - Package Not Found:**
```json
{
"error": "Package not found",
"available_packages": ["starter", "professional", "agency", "enterprise"]
}
```
---
### create_coupon
Create a new discount coupon code.
**Description:** Create a new discount coupon code
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `code` | string | Yes | Unique coupon code (uppercase letters, numbers, hyphens, underscores) |
| `name` | string | Yes | Display name for the coupon |
| `type` | string | No | Discount type: percentage or fixed_amount. Default: percentage |
| `value` | number | Yes | Discount value (1-100 for percentage, or fixed amount) |
| `duration` | string | No | How long discount applies: once, repeating, forever. Default: once |
| `max_uses` | integer | No | Maximum total uses (null for unlimited) |
| `valid_until` | string | No | Expiry date in YYYY-MM-DD format |
**Example Request:**
```json
{
"tool": "create_coupon",
"arguments": {
"code": "SUMMER25",
"name": "Summer Sale 2024",
"type": "percentage",
"value": 25,
"duration": "once",
"max_uses": 100,
"valid_until": "2024-08-31"
}
}
```
**Success Response:**
```json
{
"success": true,
"coupon": {
"id": 42,
"code": "SUMMER25",
"name": "Summer Sale 2024",
"type": "percentage",
"value": 25.0,
"duration": "once",
"max_uses": 100,
"valid_until": "2024-08-31",
"is_active": true
}
}
```
**Error Response - Invalid Code:**
```json
{
"error": "Invalid code format. Use only uppercase letters, numbers, hyphens, and underscores."
}
```
**Error Response - Duplicate Code:**
```json
{
"error": "A coupon with this code already exists."
}
```
**Error Response - Invalid Percentage:**
```json
{
"error": "Percentage value must be between 1 and 100."
}
```
---
## System Tools
### list_sites
List all sites managed by the platform.
**Description:** List all sites managed by Host Hub
**Parameters:** None
**Example Request:**
```json
{
"tool": "list_sites",
"arguments": {}
}
```
**Success Response:**
```json
[
{
"name": "BioHost",
"domain": "link.host.uk.com",
"type": "WordPress"
},
{
"name": "SocialHost",
"domain": "social.host.uk.com",
"type": "Laravel"
},
{
"name": "AnalyticsHost",
"domain": "analytics.host.uk.com",
"type": "Node.js"
}
]
```
---
### list_routes
List all web routes in the application.
**Description:** List all web routes in the application
**Parameters:** None
**Example Request:**
```json
{
"tool": "list_routes",
"arguments": {}
}
```
**Success Response:**
```json
[
{
"uri": "/",
"methods": ["GET", "HEAD"],
"name": "home"
},
{
"uri": "/login",
"methods": ["GET", "HEAD"],
"name": "login"
},
{
"uri": "/api/posts",
"methods": ["GET", "HEAD"],
"name": "api.posts.index"
},
{
"uri": "/api/posts/{post}",
"methods": ["GET", "HEAD"],
"name": "api.posts.show"
}
]
```
---
### get_stats
Get current system statistics.
**Description:** Get current system statistics for Host Hub
**Parameters:** None
**Example Request:**
```json
{
"tool": "get_stats",
"arguments": {}
}
```
**Success Response:**
```json
{
"total_sites": 6,
"active_users": 128,
"page_views_30d": 12500,
"server_load": "23%"
}
```
---
## Common Error Responses
### Missing Workspace Context
Tools requiring workspace context return this when no API key or session is provided:
```json
{
"error": "MCP tool 'tool_name' requires workspace context. Authenticate with an API key or user session."
}
```
**HTTP Status:** 403
### Missing Dependency
When a tool's dependencies aren't satisfied:
```json
{
"error": "dependency_not_met",
"message": "Dependencies not satisfied for tool 'update_task'",
"missing": [
{
"type": "tool_called",
"key": "create_plan",
"description": "A plan must be created before updating tasks"
}
],
"suggested_order": ["create_plan", "update_task"]
}
```
**HTTP Status:** 422
### Quota Exceeded
When workspace has exceeded their tool usage quota:
```json
{
"error": "quota_exceeded",
"message": "Daily tool quota exceeded for this workspace",
"current_usage": 1000,
"limit": 1000,
"resets_at": "2024-01-16T00:00:00+00:00"
}
```
**HTTP Status:** 429
### Validation Error
When parameters fail validation:
```json
{
"error": "Validation failed",
"code": "VALIDATION_ERROR",
"details": {
"query": ["The query field is required"]
}
}
```
**HTTP Status:** 422
### Internal Error
When an unexpected error occurs:
```json
{
"error": "An unexpected error occurred. Please try again.",
"code": "INTERNAL_ERROR"
}
```
**HTTP Status:** 500
---
## Authentication
### API Key Authentication
Include your API key in the Authorization header:
```bash
curl -X POST https://api.example.com/mcp/tools/call \
-H "Authorization: Bearer sk_live_xxxxx" \
-H "Content-Type: application/json" \
-d '{"tool": "get_billing_status", "arguments": {}}'
```
### Session Authentication
For browser-based access, use session cookies:
```javascript
fetch('/mcp/tools/call', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
tool: 'list_invoices',
arguments: { limit: 10 }
})
});
```
### MCP Session ID
For tracking dependencies across tool calls, include a session ID:
```bash
curl -X POST https://api.example.com/mcp/tools/call \
-H "Authorization: Bearer sk_live_xxxxx" \
-H "X-MCP-Session-ID: session_abc123" \
-H "Content-Type: application/json" \
-d '{"tool": "update_task", "arguments": {...}}'
```
---
## Tool Categories
### Query Tools
- `query_database` - Execute SQL queries
- `list_tables` - List database tables
### Commerce Tools
- `get_billing_status` - Get subscription status
- `list_invoices` - List workspace invoices
- `upgrade_plan` - Change subscription plan
- `create_coupon` - Create discount codes
### System Tools
- `list_sites` - List managed sites
- `list_routes` - List application routes
- `get_stats` - Get system statistics
---
## Response Format
All tools return JSON responses. Success responses vary by tool, but error responses follow a consistent format:
```json
{
"error": "Human-readable error message",
"code": "ERROR_CODE",
"details": {} // Optional additional information
}
```
**Common Error Codes:**
| Code | Description |
|------|-------------|
| `VALIDATION_ERROR` | Invalid parameters |
| `FORBIDDEN_QUERY` | SQL query blocked by security |
| `MISSING_WORKSPACE_CONTEXT` | Workspace authentication required |
| `QUOTA_EXCEEDED` | Usage limit reached |
| `NOT_FOUND` | Resource not found |
| `DEPENDENCY_NOT_MET` | Tool prerequisites not satisfied |
| `INTERNAL_ERROR` | Unexpected server error |
---
## Learn More
- [Creating MCP Tools](/packages/mcp/creating-mcp-tools) - Build custom tools
- [SQL Security](/packages/mcp/sql-security) - Query security rules
- [Workspace Context](/packages/mcp/workspace) - Multi-tenant isolation
- [Quotas](/packages/mcp/quotas) - Usage limits
- [Analytics](/packages/mcp/analytics) - Usage tracking

569
src/php/docs/tools.md Normal file
View file

@ -0,0 +1,569 @@
# Creating MCP Tools
Learn how to create custom MCP tools for AI agents with parameter validation, dependency management, and workspace context.
## Tool Structure
Every MCP tool extends `BaseTool`:
```php
<?php
namespace Mod\Blog\Tools;
use Core\Mcp\Tools\BaseTool;
class ListPostsTool extends BaseTool
{
public function getName(): string
{
return 'blog:list-posts';
}
public function getDescription(): string
{
return 'List all blog posts with optional filters';
}
public function getParameters(): array
{
return [
'status' => [
'type' => 'string',
'description' => 'Filter by status',
'enum' => ['published', 'draft', 'archived'],
'required' => false,
],
'limit' => [
'type' => 'integer',
'description' => 'Number of posts to return',
'default' => 10,
'min' => 1,
'max' => 100,
'required' => false,
],
];
}
public function execute(array $params): array
{
$query = Post::query();
if (isset($params['status'])) {
$query->where('status', $params['status']);
}
$posts = $query->limit($params['limit'] ?? 10)->get();
return [
'success' => true,
'posts' => $posts->map(fn ($post) => [
'id' => $post->id,
'title' => $post->title,
'slug' => $post->slug,
'status' => $post->status,
'created_at' => $post->created_at->toIso8601String(),
])->toArray(),
'count' => $posts->count(),
];
}
}
```
## Registering Tools
Register tools in your module's `Boot.php`:
```php
<?php
namespace Mod\Blog;
use Core\Events\McpToolsRegistering;
use Mod\Blog\Tools\ListPostsTool;
use Mod\Blog\Tools\CreatePostTool;
class Boot
{
public static array $listens = [
McpToolsRegistering::class => 'onMcpTools',
];
public function onMcpTools(McpToolsRegistering $event): void
{
$event->tool('blog:list-posts', ListPostsTool::class);
$event->tool('blog:create-post', CreatePostTool::class);
$event->tool('blog:get-post', GetPostTool::class);
}
}
```
## Parameter Validation
### Parameter Types
```php
public function getParameters(): array
{
return [
// String
'title' => [
'type' => 'string',
'description' => 'Post title',
'minLength' => 1,
'maxLength' => 255,
'required' => true,
],
// Integer
'views' => [
'type' => 'integer',
'description' => 'Number of views',
'min' => 0,
'max' => 1000000,
'required' => false,
],
// Boolean
'published' => [
'type' => 'boolean',
'description' => 'Is published',
'required' => false,
],
// Enum
'status' => [
'type' => 'string',
'enum' => ['draft', 'published', 'archived'],
'description' => 'Post status',
'required' => true,
],
// Array
'tags' => [
'type' => 'array',
'description' => 'Post tags',
'items' => ['type' => 'string'],
'required' => false,
],
// Object
'metadata' => [
'type' => 'object',
'description' => 'Additional metadata',
'properties' => [
'featured' => ['type' => 'boolean'],
'views' => ['type' => 'integer'],
],
'required' => false,
],
];
}
```
### Default Values
```php
'limit' => [
'type' => 'integer',
'default' => 10, // Used if not provided
'required' => false,
]
```
### Custom Validation
```php
public function execute(array $params): array
{
// Additional validation
if (isset($params['email']) && !filter_var($params['email'], FILTER_VALIDATE_EMAIL)) {
return [
'success' => false,
'error' => 'Invalid email address',
'code' => 'INVALID_EMAIL',
];
}
// Tool logic...
}
```
## Workspace Context
### Requiring Workspace
Use the `RequiresWorkspaceContext` trait:
```php
<?php
namespace Mod\Blog\Tools;
use Core\Mcp\Tools\BaseTool;
use Core\Mcp\Tools\Concerns\RequiresWorkspaceContext;
class CreatePostTool extends BaseTool
{
use RequiresWorkspaceContext;
public function execute(array $params): array
{
// Workspace automatically validated and available
$workspace = $this->getWorkspaceContext();
$post = Post::create([
'title' => $params['title'],
'content' => $params['content'],
'workspace_id' => $workspace->id,
]);
return [
'success' => true,
'post_id' => $post->id,
];
}
}
```
### Optional Workspace
```php
public function execute(array $params): array
{
$workspace = $this->getWorkspaceContext(); // May be null
$query = Post::query();
if ($workspace) {
$query->where('workspace_id', $workspace->id);
}
return ['posts' => $query->get()];
}
```
## Tool Dependencies
### Declaring Dependencies
```php
<?php
namespace Mod\Blog\Tools;
use Core\Mcp\Tools\BaseTool;
use Core\Mcp\Dependencies\HasDependencies;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mcp\Dependencies\DependencyType;
class ImportPostsTool extends BaseTool
{
use HasDependencies;
public function getDependencies(): array
{
return [
// Required dependency
new ToolDependency(
'blog:list-posts',
DependencyType::REQUIRED
),
// Optional dependency
new ToolDependency(
'media:upload',
DependencyType::OPTIONAL
),
];
}
public function execute(array $params): array
{
// Dependencies automatically validated before execution
// ...
}
}
```
### Dependency Types
- `DependencyType::REQUIRED` - Tool cannot execute without this
- `DependencyType::OPTIONAL` - Tool works better with this but not required
## Error Handling
### Standard Error Format
```php
public function execute(array $params): array
{
try {
// Tool logic...
return [
'success' => true,
'data' => $result,
];
} catch (\Exception $e) {
return [
'success' => false,
'error' => $e->getMessage(),
'code' => 'TOOL_EXECUTION_FAILED',
];
}
}
```
### Specific Error Codes
```php
// Validation error
return [
'success' => false,
'error' => 'Title is required',
'code' => 'VALIDATION_ERROR',
'field' => 'title',
];
// Not found
return [
'success' => false,
'error' => 'Post not found',
'code' => 'NOT_FOUND',
'resource_id' => $params['id'],
];
// Forbidden
return [
'success' => false,
'error' => 'Insufficient permissions',
'code' => 'FORBIDDEN',
'required_permission' => 'posts.create',
];
```
## Advanced Patterns
### Tool with File Processing
```php
public function execute(array $params): array
{
$csvPath = $params['csv_path'];
if (!file_exists($csvPath)) {
return [
'success' => false,
'error' => 'CSV file not found',
'code' => 'FILE_NOT_FOUND',
];
}
$imported = 0;
$errors = [];
if (($handle = fopen($csvPath, 'r')) !== false) {
while (($data = fgetcsv($handle)) !== false) {
try {
Post::create([
'title' => $data[0],
'content' => $data[1],
]);
$imported++;
} catch (\Exception $e) {
$errors[] = "Row {$imported}: {$e->getMessage()}";
}
}
fclose($handle);
}
return [
'success' => true,
'imported' => $imported,
'errors' => $errors,
];
}
```
### Tool with Pagination
```php
public function execute(array $params): array
{
$page = $params['page'] ?? 1;
$perPage = $params['per_page'] ?? 15;
$posts = Post::paginate($perPage, ['*'], 'page', $page);
return [
'success' => true,
'posts' => $posts->items(),
'pagination' => [
'current_page' => $posts->currentPage(),
'last_page' => $posts->lastPage(),
'per_page' => $posts->perPage(),
'total' => $posts->total(),
],
];
}
```
### Tool with Progress Tracking
```php
public function execute(array $params): array
{
$postIds = $params['post_ids'];
$total = count($postIds);
$processed = 0;
foreach ($postIds as $postId) {
$post = Post::find($postId);
if ($post) {
$post->publish();
$processed++;
// Emit progress event
event(new ToolProgress(
tool: $this->getName(),
progress: ($processed / $total) * 100,
message: "Published post {$postId}"
));
}
}
return [
'success' => true,
'processed' => $processed,
'total' => $total,
];
}
```
## Testing Tools
```php
<?php
namespace Tests\Feature\Mcp;
use Tests\TestCase;
use Mod\Blog\Tools\ListPostsTool;
use Mod\Blog\Models\Post;
class ListPostsToolTest extends TestCase
{
public function test_lists_all_posts(): void
{
Post::factory()->count(5)->create();
$tool = new ListPostsTool();
$result = $tool->execute([]);
$this->assertTrue($result['success']);
$this->assertCount(5, $result['posts']);
}
public function test_filters_by_status(): void
{
Post::factory()->count(3)->create(['status' => 'published']);
Post::factory()->count(2)->create(['status' => 'draft']);
$tool = new ListPostsTool();
$result = $tool->execute([
'status' => 'published',
]);
$this->assertCount(3, $result['posts']);
}
public function test_respects_limit(): void
{
Post::factory()->count(20)->create();
$tool = new ListPostsTool();
$result = $tool->execute([
'limit' => 5,
]);
$this->assertCount(5, $result['posts']);
}
}
```
## Best Practices
### 1. Clear Naming
```php
// ✅ Good - descriptive name
'blog:create-post'
'blog:list-published-posts'
'blog:delete-post'
// ❌ Bad - vague name
'blog:action'
'do-thing'
```
### 2. Detailed Descriptions
```php
// ✅ Good - explains what and why
public function getDescription(): string
{
return 'Create a new blog post with title, content, and optional metadata. '
. 'Requires workspace context. Validates entitlements before creation.';
}
// ❌ Bad - too brief
public function getDescription(): string
{
return 'Creates post';
}
```
### 3. Validate Parameters
```php
// ✅ Good - strict validation
public function getParameters(): array
{
return [
'title' => [
'type' => 'string',
'required' => true,
'minLength' => 1,
'maxLength' => 255,
],
];
}
```
### 4. Return Consistent Format
```php
// ✅ Good - always includes success
return [
'success' => true,
'data' => $result,
];
return [
'success' => false,
'error' => $message,
'code' => $code,
];
```
## Learn More
- [Query Database →](/packages/mcp/query-database)
- [Workspace Context →](/packages/mcp/workspace)
- [Tool Analytics →](/packages/mcp/analytics)

368
src/php/docs/workspace.md Normal file
View file

@ -0,0 +1,368 @@
# Workspace Context
Workspace isolation and context resolution for MCP tools.
## Overview
Workspace context ensures that MCP tools operate within the correct workspace boundary, preventing data leaks and unauthorized access.
## Context Resolution
### From Request Headers
```php
use Core\Mcp\Context\WorkspaceContext;
// Resolve from X-Workspace-ID header
$context = WorkspaceContext::fromRequest($request);
// Returns WorkspaceContext with:
// - workspace: Workspace model
// - user: Current user
// - namespace: Current namespace (if applicable)
```
**Request Example:**
```bash
curl -H "Authorization: Bearer sk_live_..." \
-H "X-Workspace-ID: ws_abc123" \
https://api.example.com/mcp/query
```
### From API Key
```php
use Mod\Api\Models\ApiKey;
$apiKey = ApiKey::findByKey($providedKey);
// API key is scoped to workspace
$context = WorkspaceContext::fromApiKey($apiKey);
```
### Manual Creation
```php
use Mod\Tenant\Models\Workspace;
$workspace = Workspace::find($id);
$context = new WorkspaceContext(
workspace: $workspace,
user: $user,
namespace: $namespace
);
```
## Requiring Context
### Tool Implementation
```php
<?php
namespace Mod\Blog\Mcp\Tools;
use Core\Mcp\Tools\BaseTool;
use Core\Mcp\Tools\Concerns\RequiresWorkspaceContext;
class ListPosts extends BaseTool
{
use RequiresWorkspaceContext;
public function execute(array $params): array
{
// Validates workspace context exists
$this->validateWorkspaceContext();
// Access workspace
$workspace = $this->workspaceContext->workspace;
// Query scoped to workspace
return Post::where('workspace_id', $workspace->id)
->where('status', $params['status'] ?? 'published')
->get()
->toArray();
}
}
```
### Middleware
```php
use Core\Mcp\Middleware\ValidateWorkspaceContext;
Route::middleware([ValidateWorkspaceContext::class])
->post('/mcp/tools/{tool}', [McpController::class, 'execute']);
```
**Validation:**
- Header `X-Workspace-ID` is present
- Workspace exists
- User has access to workspace
- API key is scoped to workspace
## Automatic Query Scoping
### SELECT Queries
```php
// Query without workspace filter
$result = $tool->execute([
'query' => 'SELECT * FROM posts WHERE status = ?',
'bindings' => ['published'],
]);
// Automatically becomes:
// SELECT * FROM posts
// WHERE status = ?
// AND workspace_id = ?
// With bindings: ['published', $workspaceId]
```
### BelongsToWorkspace Models
```php
use Core\Mod\Tenant\Concerns\BelongsToWorkspace;
class Post extends Model
{
use BelongsToWorkspace;
// Automatically scoped to workspace
}
// All queries automatically filtered:
Post::all(); // Only current workspace's posts
Post::where('status', 'published')->get(); // Scoped
Post::find($id); // Returns null if wrong workspace
```
## Context Properties
### Workspace
```php
$workspace = $context->workspace;
$workspace->id; // Workspace ID
$workspace->name; // Workspace name
$workspace->slug; // URL slug
$workspace->settings; // Workspace settings
$workspace->subscription; // Subscription plan
```
### User
```php
$user = $context->user;
$user->id; // User ID
$user->name; // User name
$user->email; // User email
$user->workspace_id; // Primary workspace
$user->permissions; // User permissions
```
### Namespace
```php
$namespace = $context->namespace;
if ($namespace) {
$namespace->id; // Namespace ID
$namespace->name; // Namespace name
$namespace->entitlements; // Feature access
}
```
## Multi-Workspace Access
### Switching Context
```php
// User with access to multiple workspaces
$workspaces = $user->workspaces;
foreach ($workspaces as $workspace) {
$context = new WorkspaceContext($workspace, $user);
// Execute in workspace context
$result = $tool->execute($params, $context);
}
```
### Cross-Workspace Queries (Admin)
```php
// Requires admin permission
$result = $tool->execute([
'query' => 'SELECT * FROM posts',
'bypass_workspace_scope' => true,
], $context);
// Returns posts from all workspaces
```
## Error Handling
### Missing Context
```php
use Core\Mcp\Exceptions\MissingWorkspaceContextException;
try {
$tool->execute($params); // No context provided
} catch (MissingWorkspaceContextException $e) {
return response()->json([
'error' => 'Workspace context required',
'message' => 'Please provide X-Workspace-ID header',
], 400);
}
```
### Invalid Workspace
```php
use Core\Mod\Tenant\Exceptions\WorkspaceNotFoundException;
try {
$context = WorkspaceContext::fromRequest($request);
} catch (WorkspaceNotFoundException $e) {
return response()->json([
'error' => 'Invalid workspace',
'message' => 'Workspace not found',
], 404);
}
```
### Unauthorized Access
```php
use Illuminate\Auth\Access\AuthorizationException;
try {
$context = WorkspaceContext::fromRequest($request);
} catch (AuthorizationException $e) {
return response()->json([
'error' => 'Unauthorized',
'message' => 'You do not have access to this workspace',
], 403);
}
```
## Testing
```php
use Tests\TestCase;
use Core\Mcp\Context\WorkspaceContext;
class WorkspaceContextTest extends TestCase
{
public function test_resolves_from_header(): void
{
$workspace = Workspace::factory()->create();
$response = $this->withHeaders([
'X-Workspace-ID' => $workspace->id,
])->postJson('/mcp/query', [...]);
$response->assertStatus(200);
}
public function test_scopes_queries_to_workspace(): void
{
$workspace1 = Workspace::factory()->create();
$workspace2 = Workspace::factory()->create();
Post::factory()->create(['workspace_id' => $workspace1->id]);
Post::factory()->create(['workspace_id' => $workspace2->id]);
$context = new WorkspaceContext($workspace1);
$result = $tool->execute([
'query' => 'SELECT * FROM posts',
], $context);
$this->assertCount(1, $result['rows']);
$this->assertEquals($workspace1->id, $result['rows'][0]['workspace_id']);
}
public function test_throws_when_context_missing(): void
{
$this->expectException(MissingWorkspaceContextException::class);
$tool->execute(['query' => 'SELECT * FROM posts']);
}
}
```
## Best Practices
### 1. Always Validate Context
```php
// ✅ Good - validate context
public function execute(array $params)
{
$this->validateWorkspaceContext();
// ...
}
// ❌ Bad - no validation
public function execute(array $params)
{
// Potential workspace bypass
}
```
### 2. Use BelongsToWorkspace Trait
```php
// ✅ Good - automatic scoping
class Post extends Model
{
use BelongsToWorkspace;
}
// ❌ Bad - manual filtering
Post::where('workspace_id', $workspace->id)->get();
```
### 3. Provide Clear Errors
```php
// ✅ Good - helpful error
throw new MissingWorkspaceContextException(
'Please provide X-Workspace-ID header'
);
// ❌ Bad - generic error
throw new Exception('Error');
```
### 4. Test Context Isolation
```php
// ✅ Good - test workspace boundaries
public function test_cannot_access_other_workspace(): void
{
$workspace1 = Workspace::factory()->create();
$workspace2 = Workspace::factory()->create();
$context = new WorkspaceContext($workspace1);
$post = Post::factory()->create(['workspace_id' => $workspace2->id]);
$result = Post::find($post->id); // Should be null
$this->assertNull($result);
}
```
## Learn More
- [Multi-Tenancy →](/packages/core/tenancy)
- [Security →](/packages/mcp/security)
- [Creating Tools →](/packages/mcp/tools)

16
src/php/package.json Normal file
View file

@ -0,0 +1,16 @@
{
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"devDependencies": {
"autoprefixer": "^10.4.20",
"axios": "^1.7.4",
"laravel-vite-plugin": "^2.1.0",
"postcss": "^8.4.47",
"tailwindcss": "^4.1.18",
"vite": "^7.3.1"
}
}

33
src/php/phpunit.xml Normal file
View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/>
</php>
</phpunit>

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

21
src/php/public/.htaccess Normal file
View file

@ -0,0 +1,21 @@
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
RewriteEngine On
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>

17
src/php/public/index.php Normal file
View file

@ -0,0 +1,17 @@
<?php
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
// Determine if the application is in maintenance mode...
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
// Register the Composer autoloader...
require __DIR__.'/../vendor/autoload.php';
// Bootstrap Laravel and handle the request...
(require_once __DIR__.'/../bootstrap/app.php')
->handleRequest(Request::capture());

View file

@ -0,0 +1,2 @@
User-agent: *
Disallow:

View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -0,0 +1 @@
import './bootstrap';

3
src/php/resources/js/bootstrap.js vendored Normal file
View file

@ -0,0 +1,3 @@
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';

View file

@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Core PHP Framework</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.container {
text-align: center;
padding: 2rem;
}
h1 {
font-size: 3rem;
margin-bottom: 0.5rem;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.version {
color: #888;
font-size: 0.9rem;
margin-bottom: 2rem;
}
.links {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
a {
color: #667eea;
text-decoration: none;
padding: 0.75rem 1.5rem;
border: 1px solid #667eea;
border-radius: 0.5rem;
transition: all 0.2s;
}
a:hover {
background: #667eea;
color: #fff;
}
</style>
</head>
<body>
<div class="container">
<h1>Core PHP Framework</h1>
<p class="version">Laravel {{ Illuminate\Foundation\Application::VERSION }} | PHP {{ PHP_VERSION }}</p>
<div class="links">
<a href="https://github.com/host-uk/core-php">Documentation</a>
<a href="/admin">Admin Panel</a>
<a href="/api/docs">API Docs</a>
</div>
</div>
</body>
</html>

5
src/php/routes/api.php Normal file
View file

@ -0,0 +1,5 @@
<?php
use Illuminate\Support\Facades\Route;
// API routes are registered via Core modules

Some files were not shown because too many files have changed in this diff Show more