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:
parent
7760b4089f
commit
9e2d77ed2a
250 changed files with 48744 additions and 0 deletions
3
.core/build.yaml
Normal file
3
.core/build.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
project:
|
||||
name: core-mcp
|
||||
binary: core-mcp
|
||||
7
.gitattributes
vendored
Normal file
7
.gitattributes
vendored
Normal 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
502
cmd/brain-seed/main.go
Normal 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
12
cmd/core-mcp/main.go
Normal 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
92
cmd/mcpcmd/cmd_mcp.go
Normal 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
38
composer.json
Normal 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
162
go.mod
Normal 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
423
go.sum
Normal 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
42
pkg/mcp/brain/brain.go
Normal 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
229
pkg/mcp/brain/brain_test.go
Normal 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
220
pkg/mcp/brain/tools.go
Normal 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
64
pkg/mcp/bridge.go
Normal 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
250
pkg/mcp/bridge_test.go
Normal 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
191
pkg/mcp/ide/bridge.go
Normal 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
442
pkg/mcp/ide/bridge_test.go
Normal 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
57
pkg/mcp/ide/config.go
Normal 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
62
pkg/mcp/ide/ide.go
Normal 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
114
pkg/mcp/ide/tools_build.go
Normal 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
201
pkg/mcp/ide/tools_chat.go
Normal 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
|
||||
}
|
||||
132
pkg/mcp/ide/tools_dashboard.go
Normal file
132
pkg/mcp/ide/tools_dashboard.go
Normal 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
781
pkg/mcp/ide/tools_test.go
Normal 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
121
pkg/mcp/integration_test.go
Normal 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
40
pkg/mcp/iter_test.go
Normal 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
580
pkg/mcp/mcp.go
Normal 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
180
pkg/mcp/mcp_test.go
Normal 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
149
pkg/mcp/registry.go
Normal 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
150
pkg/mcp/registry_test.go
Normal 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
32
pkg/mcp/subsystem.go
Normal 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
114
pkg/mcp/subsystem_test.go
Normal 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
213
pkg/mcp/tools_metrics.go
Normal 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))
|
||||
}
|
||||
}
|
||||
207
pkg/mcp/tools_metrics_test.go
Normal file
207
pkg/mcp/tools_metrics_test.go
Normal 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
290
pkg/mcp/tools_ml.go
Normal 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
479
pkg/mcp/tools_ml_test.go
Normal 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
305
pkg/mcp/tools_process.go
Normal 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
|
||||
}
|
||||
515
pkg/mcp/tools_process_ci_test.go
Normal file
515
pkg/mcp/tools_process_ci_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
290
pkg/mcp/tools_process_test.go
Normal file
290
pkg/mcp/tools_process_test.go
Normal 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
233
pkg/mcp/tools_rag.go
Normal 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
|
||||
}
|
||||
181
pkg/mcp/tools_rag_ci_test.go
Normal file
181
pkg/mcp/tools_rag_ci_test.go
Normal 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
173
pkg/mcp/tools_rag_test.go
Normal 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
497
pkg/mcp/tools_webview.go
Normal 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
|
||||
}
|
||||
452
pkg/mcp/tools_webview_test.go
Normal file
452
pkg/mcp/tools_webview_test.go
Normal 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
142
pkg/mcp/tools_ws.go
Normal 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
174
pkg/mcp/tools_ws_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
742
pkg/mcp/transport_e2e_test.go
Normal file
742
pkg/mcp/transport_e2e_test.go
Normal 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
|
||||
15
pkg/mcp/transport_stdio.go
Normal file
15
pkg/mcp/transport_stdio.go
Normal 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
177
pkg/mcp/transport_tcp.go
Normal 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
|
||||
}
|
||||
184
pkg/mcp/transport_tcp_test.go
Normal file
184
pkg/mcp/transport_tcp_test.go
Normal 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
52
pkg/mcp/transport_unix.go
Normal 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
24
src/php/.editorconfig
Normal 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
76
src/php/.env.example
Normal 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=
|
||||
57
src/php/.forgejo/workflows/ci.yml
Normal file
57
src/php/.forgejo/workflows/ci.yml
Normal 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
|
||||
38
src/php/.forgejo/workflows/release.yml
Normal file
38
src/php/.forgejo/workflows/release.yml
Normal 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}"
|
||||
12
src/php/.gemini/settings.json
Normal file
12
src/php/.gemini/settings.json
Normal 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
4
src/php/.github/FUNDING.yml
vendored
Normal 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
83
src/php/.github/copilot-instructions.md
vendored
Normal 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
29
src/php/.github/dependabot.yml
vendored
Normal 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
|
||||
62
src/php/.github/package-workflows/README.md
vendored
Normal file
62
src/php/.github/package-workflows/README.md
vendored
Normal 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
|
||||
[](https://github.com/host-uk/{package}/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/host-uk/{package})
|
||||
[](https://packagist.org/packages/host-uk/{package})
|
||||
[](https://packagist.org/packages/host-uk/{package})
|
||||
[](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"
|
||||
}
|
||||
}
|
||||
```
|
||||
55
src/php/.github/package-workflows/ci.yml
vendored
Normal file
55
src/php/.github/package-workflows/ci.yml
vendored
Normal 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 }}
|
||||
40
src/php/.github/package-workflows/release.yml
vendored
Normal file
40
src/php/.github/package-workflows/release.yml
vendored
Normal 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
66
src/php/.github/workflows/ci.yml
vendored
Normal 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
37
src/php/.github/workflows/release.yml
vendored
Normal 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
24
src/php/.gitignore
vendored
Normal 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
124
src/php/AGENTS.md
Normal 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
236
src/php/FINDINGS.md
Normal 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
122
src/php/GEMINI.md
Normal 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
373
src/php/TODO.md
Normal 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.*
|
||||
0
src/php/app/Http/Controllers/.gitkeep
Normal file
0
src/php/app/Http/Controllers/.gitkeep
Normal file
0
src/php/app/Mod/.gitkeep
Normal file
0
src/php/app/Mod/.gitkeep
Normal file
0
src/php/app/Models/.gitkeep
Normal file
0
src/php/app/Models/.gitkeep
Normal file
24
src/php/app/Providers/AppServiceProvider.php
Normal file
24
src/php/app/Providers/AppServiceProvider.php
Normal 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
15
src/php/artisan
Executable 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
26
src/php/bootstrap/app.php
Normal 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
2
src/php/bootstrap/cache/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
5
src/php/bootstrap/providers.php
Normal file
5
src/php/bootstrap/providers.php
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
];
|
||||
24
src/php/config/core.php
Normal file
24
src/php/config/core.php
Normal 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'),
|
||||
],
|
||||
];
|
||||
0
src/php/database/factories/.gitkeep
Normal file
0
src/php/database/factories/.gitkeep
Normal file
0
src/php/database/migrations/.gitkeep
Normal file
0
src/php/database/migrations/.gitkeep
Normal file
16
src/php/database/seeders/DatabaseSeeder.php
Normal file
16
src/php/database/seeders/DatabaseSeeder.php
Normal 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
436
src/php/docs/analytics.md
Normal 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)
|
||||
787
src/php/docs/creating-mcp-tools.md
Normal file
787
src/php/docs/creating-mcp-tools.md
Normal 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
|
||||
52
src/php/docs/discovery-2026-02-21.md
Normal file
52
src/php/docs/discovery-2026-02-21.md
Normal 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
436
src/php/docs/index.md
Normal 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)
|
||||
452
src/php/docs/query-database.md
Normal file
452
src/php/docs/query-database.md
Normal 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
405
src/php/docs/quotas.md
Normal 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
363
src/php/docs/security.md
Normal 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)
|
||||
605
src/php/docs/sql-security.md
Normal file
605
src/php/docs/sql-security.md
Normal 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
|
||||
739
src/php/docs/tools-reference.md
Normal file
739
src/php/docs/tools-reference.md
Normal 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
569
src/php/docs/tools.md
Normal 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
368
src/php/docs/workspace.md
Normal 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
16
src/php/package.json
Normal 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
33
src/php/phpunit.xml
Normal 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>
|
||||
6
src/php/postcss.config.js
Normal file
6
src/php/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
21
src/php/public/.htaccess
Normal file
21
src/php/public/.htaccess
Normal 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
17
src/php/public/index.php
Normal 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());
|
||||
2
src/php/public/robots.txt
Normal file
2
src/php/public/robots.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
User-agent: *
|
||||
Disallow:
|
||||
3
src/php/resources/css/app.css
Normal file
3
src/php/resources/css/app.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
1
src/php/resources/js/app.js
Normal file
1
src/php/resources/js/app.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
import './bootstrap';
|
||||
3
src/php/resources/js/bootstrap.js
vendored
Normal file
3
src/php/resources/js/bootstrap.js
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import axios from 'axios';
|
||||
window.axios = axios;
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
65
src/php/resources/views/welcome.blade.php
Normal file
65
src/php/resources/views/welcome.blade.php
Normal 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
5
src/php/routes/api.php
Normal 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
Loading…
Add table
Reference in a new issue