diff --git a/.core/build.yaml b/.core/build.yaml new file mode 100644 index 0000000..043ddd7 --- /dev/null +++ b/.core/build.yaml @@ -0,0 +1,3 @@ +project: + name: core-mcp + binary: core-mcp diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..cbab090 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/cmd/brain-seed/main.go b/cmd/brain-seed/main.go new file mode 100644 index 0000000..692e1ae --- /dev/null +++ b/cmd/brain-seed/main.go @@ -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 + } +} diff --git a/cmd/core-mcp/main.go b/cmd/core-mcp/main.go new file mode 100644 index 0000000..44aeaf4 --- /dev/null +++ b/cmd/core-mcp/main.go @@ -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), + ) +} diff --git a/cmd/mcpcmd/cmd_mcp.go b/cmd/mcpcmd/cmd_mcp.go new file mode 100644 index 0000000..6f2a02f --- /dev/null +++ b/cmd/mcpcmd/cmd_mcp.go @@ -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) +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..7f9c2f0 --- /dev/null +++ b/composer.json @@ -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" + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..91ecf37 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6c27b2b --- /dev/null +++ b/go.sum @@ -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= diff --git a/pkg/mcp/brain/brain.go b/pkg/mcp/brain/brain.go new file mode 100644 index 0000000..2b2fd3e --- /dev/null +++ b/pkg/mcp/brain/brain.go @@ -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 +} diff --git a/pkg/mcp/brain/brain_test.go b/pkg/mcp/brain/brain_test.go new file mode 100644 index 0000000..bf71cc5 --- /dev/null +++ b/pkg/mcp/brain/brain_test.go @@ -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) + } +} diff --git a/pkg/mcp/brain/tools.go b/pkg/mcp/brain/tools.go new file mode 100644 index 0000000..9a8f12b --- /dev/null +++ b/pkg/mcp/brain/tools.go @@ -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 +} diff --git a/pkg/mcp/bridge.go b/pkg/mcp/bridge.go new file mode 100644 index 0000000..eb5689f --- /dev/null +++ b/pkg/mcp/bridge.go @@ -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)) + }) + } +} diff --git a/pkg/mcp/bridge_test.go b/pkg/mcp/bridge_test.go new file mode 100644 index 0000000..bc8df91 --- /dev/null +++ b/pkg/mcp/bridge_test.go @@ -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") + } +} diff --git a/pkg/mcp/ide/bridge.go b/pkg/mcp/ide/bridge.go new file mode 100644 index 0000000..56ce884 --- /dev/null +++ b/pkg/mcp/ide/bridge.go @@ -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) + } +} diff --git a/pkg/mcp/ide/bridge_test.go b/pkg/mcp/ide/bridge_test.go new file mode 100644 index 0000000..f1e3881 --- /dev/null +++ b/pkg/mcp/ide/bridge_test.go @@ -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) + } +} diff --git a/pkg/mcp/ide/config.go b/pkg/mcp/ide/config.go new file mode 100644 index 0000000..ff64419 --- /dev/null +++ b/pkg/mcp/ide/config.go @@ -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 } +} diff --git a/pkg/mcp/ide/ide.go b/pkg/mcp/ide/ide.go new file mode 100644 index 0000000..ba3a833 --- /dev/null +++ b/pkg/mcp/ide/ide.go @@ -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) + } +} diff --git a/pkg/mcp/ide/tools_build.go b/pkg/mcp/ide/tools_build.go new file mode 100644 index 0000000..57a6a86 --- /dev/null +++ b/pkg/mcp/ide/tools_build.go @@ -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 +} diff --git a/pkg/mcp/ide/tools_chat.go b/pkg/mcp/ide/tools_chat.go new file mode 100644 index 0000000..bbdc6b0 --- /dev/null +++ b/pkg/mcp/ide/tools_chat.go @@ -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 +} diff --git a/pkg/mcp/ide/tools_dashboard.go b/pkg/mcp/ide/tools_dashboard.go new file mode 100644 index 0000000..6b660bf --- /dev/null +++ b/pkg/mcp/ide/tools_dashboard.go @@ -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 +} diff --git a/pkg/mcp/ide/tools_test.go b/pkg/mcp/ide/tools_test.go new file mode 100644 index 0000000..21a01fa --- /dev/null +++ b/pkg/mcp/ide/tools_test.go @@ -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") + } +} diff --git a/pkg/mcp/integration_test.go b/pkg/mcp/integration_test.go new file mode 100644 index 0000000..de35e66 --- /dev/null +++ b/pkg/mcp/integration_test.go @@ -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) +} diff --git a/pkg/mcp/iter_test.go b/pkg/mcp/iter_test.go new file mode 100644 index 0000000..5c9b274 --- /dev/null +++ b/pkg/mcp/iter_test.go @@ -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) + } +} diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go new file mode 100644 index 0000000..7854cf3 --- /dev/null +++ b/pkg/mcp/mcp.go @@ -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 +} diff --git a/pkg/mcp/mcp_test.go b/pkg/mcp/mcp_test.go new file mode 100644 index 0000000..a1701de --- /dev/null +++ b/pkg/mcp/mcp_test.go @@ -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") + } +} diff --git a/pkg/mcp/registry.go b/pkg/mcp/registry.go new file mode 100644 index 0000000..21ae123 --- /dev/null +++ b/pkg/mcp/registry.go @@ -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" + } +} diff --git a/pkg/mcp/registry_test.go b/pkg/mcp/registry_test.go new file mode 100644 index 0000000..15cdc14 --- /dev/null +++ b/pkg/mcp/registry_test.go @@ -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") + } +} diff --git a/pkg/mcp/subsystem.go b/pkg/mcp/subsystem.go new file mode 100644 index 0000000..56bd6f7 --- /dev/null +++ b/pkg/mcp/subsystem.go @@ -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 + } +} diff --git a/pkg/mcp/subsystem_test.go b/pkg/mcp/subsystem_test.go new file mode 100644 index 0000000..5e823f7 --- /dev/null +++ b/pkg/mcp/subsystem_test.go @@ -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) + } +} diff --git a/pkg/mcp/tools_metrics.go b/pkg/mcp/tools_metrics.go new file mode 100644 index 0000000..d0e3811 --- /dev/null +++ b/pkg/mcp/tools_metrics.go @@ -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)) + } +} diff --git a/pkg/mcp/tools_metrics_test.go b/pkg/mcp/tools_metrics_test.go new file mode 100644 index 0000000..c34ee6c --- /dev/null +++ b/pkg/mcp/tools_metrics_test.go @@ -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) + } + }) + } +} diff --git a/pkg/mcp/tools_ml.go b/pkg/mcp/tools_ml.go new file mode 100644 index 0000000..55a0f08 --- /dev/null +++ b/pkg/mcp/tools_ml.go @@ -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 +} diff --git a/pkg/mcp/tools_ml_test.go b/pkg/mcp/tools_ml_test.go new file mode 100644 index 0000000..902405f --- /dev/null +++ b/pkg/mcp/tools_ml_test.go @@ -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") + } +} diff --git a/pkg/mcp/tools_process.go b/pkg/mcp/tools_process.go new file mode 100644 index 0000000..9fab75b --- /dev/null +++ b/pkg/mcp/tools_process.go @@ -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 +} diff --git a/pkg/mcp/tools_process_ci_test.go b/pkg/mcp/tools_process_ci_test.go new file mode 100644 index 0000000..d8ea037 --- /dev/null +++ b/pkg/mcp/tools_process_ci_test.go @@ -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) + } + } +} diff --git a/pkg/mcp/tools_process_test.go b/pkg/mcp/tools_process_test.go new file mode 100644 index 0000000..724e2e4 --- /dev/null +++ b/pkg/mcp/tools_process_test.go @@ -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") + } +} diff --git a/pkg/mcp/tools_rag.go b/pkg/mcp/tools_rag.go new file mode 100644 index 0000000..89499f1 --- /dev/null +++ b/pkg/mcp/tools_rag.go @@ -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 +} diff --git a/pkg/mcp/tools_rag_ci_test.go b/pkg/mcp/tools_rag_ci_test.go new file mode 100644 index 0000000..fb7d853 --- /dev/null +++ b/pkg/mcp/tools_rag_ci_test.go @@ -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) + } +} diff --git a/pkg/mcp/tools_rag_test.go b/pkg/mcp/tools_rag_test.go new file mode 100644 index 0000000..1c344f3 --- /dev/null +++ b/pkg/mcp/tools_rag_test.go @@ -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: "...", + } + + 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") + } +} diff --git a/pkg/mcp/tools_webview.go b/pkg/mcp/tools_webview.go new file mode 100644 index 0000000..8fbf941 --- /dev/null +++ b/pkg/mcp/tools_webview.go @@ -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 +} diff --git a/pkg/mcp/tools_webview_test.go b/pkg/mcp/tools_webview_test.go new file mode 100644 index 0000000..abb00fa --- /dev/null +++ b/pkg/mcp/tools_webview_test.go @@ -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") + } +} diff --git a/pkg/mcp/tools_ws.go b/pkg/mcp/tools_ws.go new file mode 100644 index 0000000..ccae53c --- /dev/null +++ b/pkg/mcp/tools_ws.go @@ -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) + } +} diff --git a/pkg/mcp/tools_ws_test.go b/pkg/mcp/tools_ws_test.go new file mode 100644 index 0000000..2ffaa51 --- /dev/null +++ b/pkg/mcp/tools_ws_test.go @@ -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") + } +} diff --git a/pkg/mcp/transport_e2e_test.go b/pkg/mcp/transport_e2e_test.go new file mode 100644 index 0000000..1a9a8d0 --- /dev/null +++ b/pkg/mcp/transport_e2e_test.go @@ -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 diff --git a/pkg/mcp/transport_stdio.go b/pkg/mcp/transport_stdio.go new file mode 100644 index 0000000..10ea27c --- /dev/null +++ b/pkg/mcp/transport_stdio.go @@ -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{}) +} diff --git a/pkg/mcp/transport_tcp.go b/pkg/mcp/transport_tcp.go new file mode 100644 index 0000000..eb7ec91 --- /dev/null +++ b/pkg/mcp/transport_tcp.go @@ -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 +} diff --git a/pkg/mcp/transport_tcp_test.go b/pkg/mcp/transport_tcp_test.go new file mode 100644 index 0000000..ba9a229 --- /dev/null +++ b/pkg/mcp/transport_tcp_test.go @@ -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) + } +} diff --git a/pkg/mcp/transport_unix.go b/pkg/mcp/transport_unix.go new file mode 100644 index 0000000..c70d5d9 --- /dev/null +++ b/pkg/mcp/transport_unix.go @@ -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) + } +} diff --git a/src/php/.editorconfig b/src/php/.editorconfig new file mode 100644 index 0000000..8e11abd --- /dev/null +++ b/src/php/.editorconfig @@ -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 diff --git a/src/php/.env.example b/src/php/.env.example new file mode 100644 index 0000000..01b4da4 --- /dev/null +++ b/src/php/.env.example @@ -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= diff --git a/src/php/.forgejo/workflows/ci.yml b/src/php/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..2c549e3 --- /dev/null +++ b/src/php/.forgejo/workflows/ci.yml @@ -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 diff --git a/src/php/.forgejo/workflows/release.yml b/src/php/.forgejo/workflows/release.yml new file mode 100644 index 0000000..844f7a2 --- /dev/null +++ b/src/php/.forgejo/workflows/release.yml @@ -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}" diff --git a/src/php/.gemini/settings.json b/src/php/.gemini/settings.json new file mode 100644 index 0000000..4e613e9 --- /dev/null +++ b/src/php/.gemini/settings.json @@ -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" + ] + } +} diff --git a/src/php/.github/FUNDING.yml b/src/php/.github/FUNDING.yml new file mode 100644 index 0000000..fdd4114 --- /dev/null +++ b/src/php/.github/FUNDING.yml @@ -0,0 +1,4 @@ +# GitHub Sponsors configuration +# Support Trees for the Future through our fundraiser + +custom: ["https://donate.trees.org/-/NPMMSVUP?member=SWZTDDWH"] diff --git a/src/php/.github/copilot-instructions.md b/src/php/.github/copilot-instructions.md new file mode 100644 index 0000000..69bfe03 --- /dev/null +++ b/src/php/.github/copilot-instructions.md @@ -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 + '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 diff --git a/src/php/.github/dependabot.yml b/src/php/.github/dependabot.yml new file mode 100644 index 0000000..6867d41 --- /dev/null +++ b/src/php/.github/dependabot.yml @@ -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 diff --git a/src/php/.github/package-workflows/README.md b/src/php/.github/package-workflows/README.md new file mode 100644 index 0000000..999966f --- /dev/null +++ b/src/php/.github/package-workflows/README.md @@ -0,0 +1,62 @@ +# Package Workflows + +These workflow templates are for **library packages** (host-uk/core, host-uk/core-api, etc.), not application projects. + +## README Badges + +Add these badges to your package README (replace `{package}` with your package name): + +```markdown +[![CI](https://github.com/host-uk/{package}/actions/workflows/ci.yml/badge.svg)](https://github.com/host-uk/{package}/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/host-uk/{package}/graph/badge.svg)](https://codecov.io/gh/host-uk/{package}) +[![Latest Version](https://img.shields.io/packagist/v/host-uk/{package})](https://packagist.org/packages/host-uk/{package}) +[![PHP Version](https://img.shields.io/packagist/php-v/host-uk/{package})](https://packagist.org/packages/host-uk/{package}) +[![License](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](LICENSE) +``` + +## Usage + +Copy the relevant workflows to your library's `.github/workflows/` directory: + +```bash +# In your library repo +mkdir -p .github/workflows +cp path/to/core-template/.github/package-workflows/ci.yml .github/workflows/ +cp path/to/core-template/.github/package-workflows/release.yml .github/workflows/ +``` + +## Workflows + +### ci.yml +- Runs on push/PR to main +- Tests against PHP 8.2, 8.3, 8.4 +- Tests against Laravel 11 and 12 +- Runs Pint linting +- Runs Pest tests + +### release.yml +- Triggers on version tags (v*) +- Generates changelog using git-cliff +- Creates GitHub release + +## Requirements + +For these workflows to work, your package needs: + +1. **cliff.toml** - Copy from core-template root +2. **Pest configured** - `composer require pestphp/pest --dev` +3. **Pint configured** - `composer require laravel/pint --dev` +4. **CODECOV_TOKEN** - Add to repo secrets for coverage uploads +5. **FUNDING.yml** - Copy `.github/FUNDING.yml` for sponsor button + +## Recommended composer.json scripts + +```json +{ + "scripts": { + "lint": "pint", + "test": "pest", + "test:coverage": "pest --coverage" + } +} +``` diff --git a/src/php/.github/package-workflows/ci.yml b/src/php/.github/package-workflows/ci.yml new file mode 100644 index 0000000..7c5f722 --- /dev/null +++ b/src/php/.github/package-workflows/ci.yml @@ -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 }} diff --git a/src/php/.github/package-workflows/release.yml b/src/php/.github/package-workflows/release.yml new file mode 100644 index 0000000..035294e --- /dev/null +++ b/src/php/.github/package-workflows/release.yml @@ -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 }} diff --git a/src/php/.github/workflows/ci.yml b/src/php/.github/workflows/ci.yml new file mode 100644 index 0000000..6b3e7c2 --- /dev/null +++ b/src/php/.github/workflows/ci.yml @@ -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 diff --git a/src/php/.github/workflows/release.yml b/src/php/.github/workflows/release.yml new file mode 100644 index 0000000..3d8f8d0 --- /dev/null +++ b/src/php/.github/workflows/release.yml @@ -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 }} diff --git a/src/php/.gitignore b/src/php/.gitignore new file mode 100644 index 0000000..899ea82 --- /dev/null +++ b/src/php/.gitignore @@ -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 diff --git a/src/php/AGENTS.md b/src/php/AGENTS.md new file mode 100644 index 0000000..79b4594 --- /dev/null +++ b/src/php/AGENTS.md @@ -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 + '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 + Post::latest()->paginate(10), + ]); + } +} +``` + +## Testing Example + +```php +count(3)->create(); + + $this->get('/blog') + ->assertOk() + ->assertSee($posts->first()->title); +}); +``` diff --git a/src/php/FINDINGS.md b/src/php/FINDINGS.md new file mode 100644 index 0000000..906c85c --- /dev/null +++ b/src/php/FINDINGS.md @@ -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` for PHPStan level 5+ +- `Boot::$listens` uses `array` — 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` — likely needs type narrowing +4. `QueryDatabase::handleExplain()` passes `$explainResults` (array of stdClass) to `interpretExplain()` typed as `array` — needs `array` +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. diff --git a/src/php/GEMINI.md b/src/php/GEMINI.md new file mode 100644 index 0000000..103179c --- /dev/null +++ b/src/php/GEMINI.md @@ -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 + '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) diff --git a/src/php/TODO.md b/src/php/TODO.md new file mode 100644 index 0000000..9aabf08 --- /dev/null +++ b/src/php/TODO.md @@ -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.* diff --git a/src/php/app/Http/Controllers/.gitkeep b/src/php/app/Http/Controllers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/php/app/Mod/.gitkeep b/src/php/app/Mod/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/php/app/Models/.gitkeep b/src/php/app/Models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/php/app/Providers/AppServiceProvider.php b/src/php/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..452e6b6 --- /dev/null +++ b/src/php/app/Providers/AppServiceProvider.php @@ -0,0 +1,24 @@ +handleCommand(new ArgvInput); + +exit($status); diff --git a/src/php/bootstrap/app.php b/src/php/bootstrap/app.php new file mode 100644 index 0000000..4687853 --- /dev/null +++ b/src/php/bootstrap/app.php @@ -0,0 +1,26 @@ +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(); diff --git a/src/php/bootstrap/cache/.gitignore b/src/php/bootstrap/cache/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/src/php/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/php/bootstrap/providers.php b/src/php/bootstrap/providers.php new file mode 100644 index 0000000..38b258d --- /dev/null +++ b/src/php/bootstrap/providers.php @@ -0,0 +1,5 @@ + [ + 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'), + ], +]; diff --git a/src/php/database/factories/.gitkeep b/src/php/database/factories/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/php/database/migrations/.gitkeep b/src/php/database/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/php/database/seeders/DatabaseSeeder.php b/src/php/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..df6818f --- /dev/null +++ b/src/php/database/seeders/DatabaseSeeder.php @@ -0,0 +1,16 @@ +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 + $analytics->totalExecutions(), + 'topTools' => $analytics->mostUsedTools(['limit' => 10]), + 'errorRate' => $analytics->errorRate(), + 'avgExecutionTime' => $analytics->averageExecutionTime(), + ]); + } +} +``` + +**View:** + +```blade + + +

MCP Tool Analytics

+
+ +
+ + + + + + + +
+ +
+

Most Used Tools

+ + + Tool + Executions + + + @foreach($topTools as $tool) + + {{ $tool['tool_name'] }} + {{ number_format($tool['count']) }} + + @endforeach + +
+
+``` + +## Tool Detail View + +Detailed analytics for specific tool: + +```blade + + +

{{ $toolName }} Analytics

+
+ +
+ + + + + +
+ +
+

Performance Trend

+ +
+ +
+

Recent Errors

+ @foreach($recentErrors as $error) + + {{ $error->created_at->diffForHumans() }} + {{ $error->error_message }} + + @endforeach +
+
+``` + +## 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) diff --git a/src/php/docs/creating-mcp-tools.md b/src/php/docs/creating-mcp-tools.md new file mode 100644 index 0000000..f309801 --- /dev/null +++ b/src/php/docs/creating-mcp-tools.md @@ -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 +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 +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 + '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 + '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 +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 +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 diff --git a/src/php/docs/discovery-2026-02-21.md b/src/php/docs/discovery-2026-02-21.md new file mode 100644 index 0000000..9925914 --- /dev/null +++ b/src/php/docs/discovery-2026-02-21.md @@ -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** diff --git a/src/php/docs/index.md b/src/php/docs/index.md new file mode 100644 index 0000000..1424588 --- /dev/null +++ b/src/php/docs/index.md @@ -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 + '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 + [ + '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 +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 +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 +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) diff --git a/src/php/docs/query-database.md b/src/php/docs/query-database.md new file mode 100644 index 0000000..b6438b5 --- /dev/null +++ b/src/php/docs/query-database.md @@ -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 +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) diff --git a/src/php/docs/quotas.md b/src/php/docs/quotas.md new file mode 100644 index 0000000..4556a1d --- /dev/null +++ b/src/php/docs/quotas.md @@ -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 + + + Workspace + Tier + Hourly Usage + Daily Usage + + + @foreach($workspaces as $workspace) + + {{ $workspace['name'] }} + + + {{ ucfirst($workspace['tier']) }} + + + + {{ $workspace['hourly_usage'] }} / {{ $workspace['hourly_limit'] ?? '∞' }} + + + + {{ $workspace['daily_usage'] }} / {{ $workspace['daily_limit'] ?? '∞' }} + + + @endforeach + +``` + +### 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) diff --git a/src/php/docs/security.md b/src/php/docs/security.md new file mode 100644 index 0000000..61ecd3e --- /dev/null +++ b/src/php/docs/security.md @@ -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) diff --git a/src/php/docs/sql-security.md b/src/php/docs/sql-security.md new file mode 100644 index 0000000..fead675 --- /dev/null +++ b/src/php/docs/sql-security.md @@ -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 diff --git a/src/php/docs/tools-reference.md b/src/php/docs/tools-reference.md new file mode 100644 index 0000000..1dd1d52 --- /dev/null +++ b/src/php/docs/tools-reference.md @@ -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 diff --git a/src/php/docs/tools.md b/src/php/docs/tools.md new file mode 100644 index 0000000..d9cd02b --- /dev/null +++ b/src/php/docs/tools.md @@ -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 + [ + '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 + '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 +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 + 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 +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) diff --git a/src/php/docs/workspace.md b/src/php/docs/workspace.md new file mode 100644 index 0000000..0736654 --- /dev/null +++ b/src/php/docs/workspace.md @@ -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 +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) diff --git a/src/php/package.json b/src/php/package.json new file mode 100644 index 0000000..46afe94 --- /dev/null +++ b/src/php/package.json @@ -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" + } +} diff --git a/src/php/phpunit.xml b/src/php/phpunit.xml new file mode 100644 index 0000000..61c031c --- /dev/null +++ b/src/php/phpunit.xml @@ -0,0 +1,33 @@ + + + + + tests/Unit + + + tests/Feature + + + + + app + + + + + + + + + + + + + + + + diff --git a/src/php/postcss.config.js b/src/php/postcss.config.js new file mode 100644 index 0000000..49c0612 --- /dev/null +++ b/src/php/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/src/php/public/.htaccess b/src/php/public/.htaccess new file mode 100644 index 0000000..3aec5e2 --- /dev/null +++ b/src/php/public/.htaccess @@ -0,0 +1,21 @@ + + + Options -MultiViews -Indexes + + + 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] + diff --git a/src/php/public/index.php b/src/php/public/index.php new file mode 100644 index 0000000..947d989 --- /dev/null +++ b/src/php/public/index.php @@ -0,0 +1,17 @@ +handleRequest(Request::capture()); diff --git a/src/php/public/robots.txt b/src/php/public/robots.txt new file mode 100644 index 0000000..eb05362 --- /dev/null +++ b/src/php/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/src/php/resources/css/app.css b/src/php/resources/css/app.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/src/php/resources/css/app.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/src/php/resources/js/app.js b/src/php/resources/js/app.js new file mode 100644 index 0000000..e59d6a0 --- /dev/null +++ b/src/php/resources/js/app.js @@ -0,0 +1 @@ +import './bootstrap'; diff --git a/src/php/resources/js/bootstrap.js b/src/php/resources/js/bootstrap.js new file mode 100644 index 0000000..953d266 --- /dev/null +++ b/src/php/resources/js/bootstrap.js @@ -0,0 +1,3 @@ +import axios from 'axios'; +window.axios = axios; +window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; diff --git a/src/php/resources/views/welcome.blade.php b/src/php/resources/views/welcome.blade.php new file mode 100644 index 0000000..88808ac --- /dev/null +++ b/src/php/resources/views/welcome.blade.php @@ -0,0 +1,65 @@ + + + + + + Core PHP Framework + + + +
+

Core PHP Framework

+

Laravel {{ Illuminate\Foundation\Application::VERSION }} | PHP {{ PHP_VERSION }}

+ +
+ + diff --git a/src/php/routes/api.php b/src/php/routes/api.php new file mode 100644 index 0000000..15fbf70 --- /dev/null +++ b/src/php/routes/api.php @@ -0,0 +1,5 @@ +group('mcp', [ + \Illuminate\Routing\Middleware\ThrottleRequests::class.':api', + \Illuminate\Routing\Middleware\SubstituteBindings::class, + ]); + } + + public function register(): void + { + // + } + + public function boot(): void + { + // Fire McpRoutesRegistering event for lazy-loaded modules + LifecycleEventProvider::fireMcpRoutes(); + + // Fire McpToolsRegistering so modules can register tool handlers + LifecycleEventProvider::fireMcpTools(); + } +} diff --git a/src/php/src/Front/Mcp/Contracts/McpToolHandler.php b/src/php/src/Front/Mcp/Contracts/McpToolHandler.php new file mode 100644 index 0000000..e223f4b --- /dev/null +++ b/src/php/src/Front/Mcp/Contracts/McpToolHandler.php @@ -0,0 +1,48 @@ +sessionId; + } + + /** + * Set the current session ID. + */ + public function setSessionId(?string $sessionId): void + { + $this->sessionId = $sessionId; + } + + /** + * Get the current plan if one is active. + * + * @return object|null AgentPlan model instance when Agentic module installed + */ + public function getCurrentPlan(): ?object + { + return $this->currentPlan; + } + + /** + * Set the current plan. + * + * @param object|null $plan AgentPlan model instance + */ + public function setCurrentPlan(?object $plan): void + { + $this->currentPlan = $plan; + } + + /** + * Send an MCP notification to the client. + * + * Notifications are one-way messages that don't expect a response. + * Common notifications include progress updates, log messages, etc. + */ + public function sendNotification(string $method, array $params = []): void + { + if ($this->notificationCallback) { + ($this->notificationCallback)($method, $params); + } + } + + /** + * Log a message to the current session. + * + * Messages are recorded in the session log for handoff context + * and audit trail purposes. + */ + public function logToSession(string $message, string $type = 'info', array $data = []): void + { + if ($this->logCallback) { + ($this->logCallback)($message, $type, $data); + } + } + + /** + * Set the notification callback. + */ + public function setNotificationCallback(?Closure $callback): void + { + $this->notificationCallback = $callback; + } + + /** + * Set the log callback. + */ + public function setLogCallback(?Closure $callback): void + { + $this->logCallback = $callback; + } + + /** + * Check if a session is currently active. + */ + public function hasSession(): bool + { + return $this->sessionId !== null; + } + + /** + * Check if a plan is currently active. + */ + public function hasPlan(): bool + { + return $this->currentPlan !== null; + } +} diff --git a/src/php/src/Front/View/Blade/layouts/mcp.blade.php b/src/php/src/Front/View/Blade/layouts/mcp.blade.php new file mode 100644 index 0000000..04f0e9a --- /dev/null +++ b/src/php/src/Front/View/Blade/layouts/mcp.blade.php @@ -0,0 +1,87 @@ +@php + $appName = config('core.app.name', 'Core PHP'); + $appUrl = config('app.url', 'https://core.test'); + $privacyUrl = config('core.urls.privacy', '/privacy'); + $termsUrl = config('core.urls.terms', '/terms'); +@endphp + + + + + + + + {{ $title ?? 'MCP Portal' }} - {{ $appName }} + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + @fluxAppearance + + + +
+
+
+ +
+ @php + $workspace = request()->attributes->get('mcp_workspace'); + @endphp + @if($workspace) + + Dashboard + + + API Keys + + + {{ $workspace->name }} + + @else + + Sign in + + @endif + + ← {{ $appName }} + +
+
+
+
+ + +
+ {{ $slot }} +
+ + +
+
+
+

+ © {{ date('Y') }} {{ $appName }}. All rights reserved. +

+
+ Privacy + Terms +
+
+
+
+ + @fluxScripts + + diff --git a/src/php/src/Mcp/Boot.php b/src/php/src/Mcp/Boot.php new file mode 100644 index 0000000..68631a5 --- /dev/null +++ b/src/php/src/Mcp/Boot.php @@ -0,0 +1,139 @@ + + */ + public static array $listens = [ + AdminPanelBooting::class => 'onAdminPanel', + ConsoleBooting::class => 'onConsole', + McpRoutesRegistering::class => 'onMcpRoutes', + McpToolsRegistering::class => 'onMcpTools', + ]; + + /** + * Register any application services. + */ + public function register(): void + { + $this->app->singleton(ToolRegistry::class); + $this->app->singleton(ToolAnalyticsService::class); + $this->app->singleton(McpQuotaService::class); + $this->app->singleton(ToolDependencyService::class); + $this->app->singleton(AuditLogService::class); + $this->app->singleton(ToolVersionService::class); + $this->app->singleton(QueryAuditService::class); + $this->app->singleton(QueryExecutionService::class); + } + + /** + * Bootstrap any application services. + */ + public function boot(): void + { + $this->loadMigrationsFrom(__DIR__.'/Migrations'); + + // Register event listener for tool execution analytics + Event::listen(ToolExecuted::class, RecordToolExecution::class); + } + + // ------------------------------------------------------------------------- + // Event-driven handlers + // ------------------------------------------------------------------------- + + public function onAdminPanel(AdminPanelBooting $event): void + { + $event->views($this->moduleName, __DIR__.'/View/Blade'); + + if (file_exists(__DIR__.'/Routes/admin.php')) { + $event->routes(fn () => require __DIR__.'/Routes/admin.php'); + } + + $event->livewire('mcp.admin.api-key-manager', View\Modal\Admin\ApiKeyManager::class); + $event->livewire('mcp.admin.playground', View\Modal\Admin\Playground::class); + $event->livewire('mcp.admin.mcp-playground', View\Modal\Admin\McpPlayground::class); + $event->livewire('mcp.admin.request-log', View\Modal\Admin\RequestLog::class); + $event->livewire('mcp.admin.tool-analytics-dashboard', View\Modal\Admin\ToolAnalyticsDashboard::class); + $event->livewire('mcp.admin.tool-analytics-detail', View\Modal\Admin\ToolAnalyticsDetail::class); + $event->livewire('mcp.admin.quota-usage', View\Modal\Admin\QuotaUsage::class); + $event->livewire('mcp.admin.audit-log-viewer', View\Modal\Admin\AuditLogViewer::class); + $event->livewire('mcp.admin.tool-version-manager', View\Modal\Admin\ToolVersionManager::class); + } + + public function onMcpRoutes(McpRoutesRegistering $event): void + { + // Register middleware aliases + $event->middleware('mcp.auth', Middleware\McpApiKeyAuth::class); + $event->middleware('mcp.workspace', Middleware\ValidateWorkspaceContext::class); + $event->middleware('mcp.authenticate', Middleware\McpAuthenticate::class); + $event->middleware('mcp.quota', Middleware\CheckMcpQuota::class); + $event->middleware('mcp.dependencies', Middleware\ValidateToolDependencies::class); + + $domain = config('mcp.domain'); + + $event->routes(fn () => Route::domain($domain) + ->middleware('mcp.auth') + ->name('mcp.') + ->group(function () { + Route::post('tools/call', [Controllers\McpApiController::class, 'callTool'])->name('tools.call'); + Route::get('resources/{uri}', [Controllers\McpApiController::class, 'resource'])->name('resources.read') + ->where('uri', '.+'); + Route::get('servers.json', [Controllers\McpApiController::class, 'servers'])->name('servers.json'); + Route::get('servers/{id}.json', [Controllers\McpApiController::class, 'server'])->name('servers.json.show') + ->where('id', '[a-z0-9-]+'); + Route::get('servers/{id}/tools', [Controllers\McpApiController::class, 'tools'])->name('servers.tools') + ->where('id', '[a-z0-9-]+'); + }) + ); + } + + public function onConsole(ConsoleBooting $event): void + { + // Middleware aliases for CLI context (artisan route:list etc.) + $event->middleware('mcp.auth', Middleware\McpApiKeyAuth::class); + $event->middleware('mcp.workspace', Middleware\ValidateWorkspaceContext::class); + $event->middleware('mcp.authenticate', Middleware\McpAuthenticate::class); + $event->middleware('mcp.quota', Middleware\CheckMcpQuota::class); + $event->middleware('mcp.dependencies', Middleware\ValidateToolDependencies::class); + + $event->command(Console\Commands\McpAgentServerCommand::class); + $event->command(Console\Commands\PruneMetricsCommand::class); + $event->command(Console\Commands\VerifyAuditLogCommand::class); + } + + public function onMcpTools(McpToolsRegistering $event): void + { + // MCP tool handlers will be registered here once extracted + // from the monolithic McpAgentServerCommand + } +} diff --git a/src/php/src/Mcp/Console/Commands/CleanupToolCallLogsCommand.php b/src/php/src/Mcp/Console/Commands/CleanupToolCallLogsCommand.php new file mode 100644 index 0000000..d56a8a6 --- /dev/null +++ b/src/php/src/Mcp/Console/Commands/CleanupToolCallLogsCommand.php @@ -0,0 +1,111 @@ +option('dry-run'); + $logRetentionDays = (int) ($this->option('days') ?? config('mcp.log_retention.days', 90)); + $statsRetentionDays = (int) ($this->option('stats-days') ?? config('mcp.log_retention.stats_days', 365)); + + $this->info('MCP Log Cleanup'.($dryRun ? ' (DRY RUN)' : '')); + $this->line(''); + $this->line("Detailed logs retention: {$logRetentionDays} days"); + $this->line("Statistics retention: {$statsRetentionDays} days"); + $this->line(''); + + $logsCutoff = now()->subDays($logRetentionDays); + $statsCutoff = now()->subDays($statsRetentionDays); + + // Clean up tool call logs + $toolCallsCount = McpToolCall::where('created_at', '<', $logsCutoff)->count(); + if ($toolCallsCount > 0) { + if ($dryRun) { + $this->line("Would delete {$toolCallsCount} tool call log(s) older than {$logsCutoff->toDateString()}"); + } else { + // Delete in chunks to avoid memory issues and lock contention + $deleted = $this->deleteInChunks(McpToolCall::class, 'created_at', $logsCutoff); + $this->info("Deleted {$deleted} tool call log(s)"); + } + } else { + $this->line('No tool call logs to clean up'); + } + + // Clean up API request logs + $apiRequestsCount = McpApiRequest::where('created_at', '<', $logsCutoff)->count(); + if ($apiRequestsCount > 0) { + if ($dryRun) { + $this->line("Would delete {$apiRequestsCount} API request log(s) older than {$logsCutoff->toDateString()}"); + } else { + $deleted = $this->deleteInChunks(McpApiRequest::class, 'created_at', $logsCutoff); + $this->info("Deleted {$deleted} API request log(s)"); + } + } else { + $this->line('No API request logs to clean up'); + } + + // Clean up aggregated statistics (longer retention) + $statsCount = McpToolCallStat::where('date', '<', $statsCutoff->toDateString())->count(); + if ($statsCount > 0) { + if ($dryRun) { + $this->line("Would delete {$statsCount} tool call stat(s) older than {$statsCutoff->toDateString()}"); + } else { + $deleted = McpToolCallStat::where('date', '<', $statsCutoff->toDateString())->delete(); + $this->info("Deleted {$deleted} tool call stat(s)"); + } + } else { + $this->line('No tool call stats to clean up'); + } + + $this->line(''); + $this->info('Cleanup complete.'); + + return self::SUCCESS; + } + + /** + * Delete records in chunks to avoid memory issues. + */ + protected function deleteInChunks(string $model, string $column, \DateTimeInterface $cutoff, int $chunkSize = 1000): int + { + $totalDeleted = 0; + + do { + $deleted = $model::where($column, '<', $cutoff) + ->limit($chunkSize) + ->delete(); + + $totalDeleted += $deleted; + + // Small pause to reduce database pressure + if ($deleted > 0) { + usleep(10000); // 10ms + } + } while ($deleted > 0); + + return $totalDeleted; + } +} diff --git a/src/php/src/Mcp/Console/Commands/McpAgentServerCommand.php b/src/php/src/Mcp/Console/Commands/McpAgentServerCommand.php new file mode 100644 index 0000000..fe49ed8 --- /dev/null +++ b/src/php/src/Mcp/Console/Commands/McpAgentServerCommand.php @@ -0,0 +1,2064 @@ +registerTools(); + $this->registerResources(); + + // Run MCP server loop + while (($line = fgets(STDIN)) !== false) { + $line = trim($line); + if (empty($line)) { + continue; + } + + try { + $request = json_decode($line, true, 512, JSON_THROW_ON_ERROR); + $response = $this->handleRequest($request); + + if ($response !== null) { + $this->sendResponse($response); + } + } catch (Throwable $e) { + Log::error('MCP Agent Server error', [ + 'error' => $e->getMessage(), + 'line' => $line, + ]); + + $this->sendResponse([ + 'jsonrpc' => '2.0', + 'id' => null, + 'error' => [ + 'code' => -32700, + 'message' => 'Parse error: '.$e->getMessage(), + ], + ]); + } + } + + return 0; + } + + protected function registerTools(): void + { + // Plan management tools + $this->tools['plan_list'] = [ + 'description' => 'List all work plans with their current status and progress', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'status' => [ + 'type' => 'string', + 'description' => 'Filter by status (draft, active, paused, completed, archived)', + 'enum' => ['draft', 'active', 'paused', 'completed', 'archived'], + ], + 'include_archived' => [ + 'type' => 'boolean', + 'description' => 'Include archived plans (default: false)', + ], + ], + ], + 'handler' => 'toolPlanList', + ]; + + $this->tools['plan_create'] = [ + 'description' => 'Create a new work plan with phases and tasks', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'title' => [ + 'type' => 'string', + 'description' => 'Plan title', + ], + 'slug' => [ + 'type' => 'string', + 'description' => 'URL-friendly identifier (auto-generated if not provided)', + ], + 'description' => [ + 'type' => 'string', + 'description' => 'Plan description', + ], + 'context' => [ + 'type' => 'object', + 'description' => 'Additional context (related files, dependencies, etc.)', + ], + 'phases' => [ + 'type' => 'array', + 'description' => 'Array of phase definitions with name, description, and tasks', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + 'description' => ['type' => 'string'], + 'tasks' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + ], + ], + ], + ], + ], + 'required' => ['title'], + ], + 'handler' => 'toolPlanCreate', + ]; + + $this->tools['plan_get'] = [ + 'description' => 'Get detailed information about a specific plan', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'slug' => [ + 'type' => 'string', + 'description' => 'Plan slug identifier', + ], + 'format' => [ + 'type' => 'string', + 'description' => 'Output format: json or markdown', + 'enum' => ['json', 'markdown'], + ], + ], + 'required' => ['slug'], + ], + 'handler' => 'toolPlanGet', + ]; + + $this->tools['plan_update_status'] = [ + 'description' => 'Update the status of a plan', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'slug' => [ + 'type' => 'string', + 'description' => 'Plan slug identifier', + ], + 'status' => [ + 'type' => 'string', + 'description' => 'New status', + 'enum' => ['draft', 'active', 'paused', 'completed'], + ], + ], + 'required' => ['slug', 'status'], + ], + 'handler' => 'toolPlanUpdateStatus', + ]; + + $this->tools['plan_archive'] = [ + 'description' => 'Archive a completed or abandoned plan', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'slug' => [ + 'type' => 'string', + 'description' => 'Plan slug identifier', + ], + 'reason' => [ + 'type' => 'string', + 'description' => 'Reason for archiving', + ], + ], + 'required' => ['slug'], + ], + 'handler' => 'toolPlanArchive', + ]; + + // Phase tools + $this->tools['phase_get'] = [ + 'description' => 'Get details of a specific phase within a plan', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'plan_slug' => [ + 'type' => 'string', + 'description' => 'Plan slug identifier', + ], + 'phase' => [ + 'type' => 'string', + 'description' => 'Phase identifier (number or name)', + ], + ], + 'required' => ['plan_slug', 'phase'], + ], + 'handler' => 'toolPhaseGet', + ]; + + $this->tools['phase_update_status'] = [ + 'description' => 'Update the status of a phase', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'plan_slug' => [ + 'type' => 'string', + 'description' => 'Plan slug identifier', + ], + 'phase' => [ + 'type' => 'string', + 'description' => 'Phase identifier (number or name)', + ], + 'status' => [ + 'type' => 'string', + 'description' => 'New status', + 'enum' => ['pending', 'in_progress', 'completed', 'blocked', 'skipped'], + ], + 'notes' => [ + 'type' => 'string', + 'description' => 'Optional notes about the status change', + ], + ], + 'required' => ['plan_slug', 'phase', 'status'], + ], + 'handler' => 'toolPhaseUpdateStatus', + ]; + + $this->tools['phase_add_checkpoint'] = [ + 'description' => 'Add a checkpoint note to a phase', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'plan_slug' => [ + 'type' => 'string', + 'description' => 'Plan slug identifier', + ], + 'phase' => [ + 'type' => 'string', + 'description' => 'Phase identifier (number or name)', + ], + 'note' => [ + 'type' => 'string', + 'description' => 'Checkpoint note', + ], + 'context' => [ + 'type' => 'object', + 'description' => 'Additional context data', + ], + ], + 'required' => ['plan_slug', 'phase', 'note'], + ], + 'handler' => 'toolPhaseAddCheckpoint', + ]; + + // Task tools + $this->tools['task_toggle'] = [ + 'description' => 'Toggle a task completion status', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'plan_slug' => [ + 'type' => 'string', + 'description' => 'Plan slug identifier', + ], + 'phase' => [ + 'type' => 'string', + 'description' => 'Phase identifier (number or name)', + ], + 'task_index' => [ + 'type' => 'integer', + 'description' => 'Task index (0-based)', + ], + ], + 'required' => ['plan_slug', 'phase', 'task_index'], + ], + 'handler' => 'toolTaskToggle', + ]; + + $this->tools['task_update'] = [ + 'description' => 'Update task details (status, notes)', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'plan_slug' => [ + 'type' => 'string', + 'description' => 'Plan slug identifier', + ], + 'phase' => [ + 'type' => 'string', + 'description' => 'Phase identifier (number or name)', + ], + 'task_index' => [ + 'type' => 'integer', + 'description' => 'Task index (0-based)', + ], + 'status' => [ + 'type' => 'string', + 'description' => 'New status', + 'enum' => ['pending', 'in_progress', 'completed', 'blocked', 'skipped'], + ], + 'notes' => [ + 'type' => 'string', + 'description' => 'Task notes', + ], + ], + 'required' => ['plan_slug', 'phase', 'task_index'], + ], + 'handler' => 'toolTaskUpdate', + ]; + + // Session tools (for multi-agent handoff) + $this->tools['session_start'] = [ + 'description' => 'Start a new agent session for a plan', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'plan_slug' => [ + 'type' => 'string', + 'description' => 'Plan slug identifier', + ], + 'agent_type' => [ + 'type' => 'string', + 'description' => 'Type of agent (e.g., opus, sonnet, haiku)', + ], + 'context' => [ + 'type' => 'object', + 'description' => 'Initial session context', + ], + ], + 'required' => ['agent_type'], + ], + 'handler' => 'toolSessionStart', + ]; + + $this->tools['session_log'] = [ + 'description' => 'Log an entry in the current session', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'message' => [ + 'type' => 'string', + 'description' => 'Log message', + ], + 'type' => [ + 'type' => 'string', + 'description' => 'Log type', + 'enum' => ['info', 'progress', 'decision', 'error', 'checkpoint'], + ], + 'data' => [ + 'type' => 'object', + 'description' => 'Additional data to log', + ], + ], + 'required' => ['message'], + ], + 'handler' => 'toolSessionLog', + ]; + + $this->tools['session_artifact'] = [ + 'description' => 'Record an artifact created/modified during the session', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'path' => [ + 'type' => 'string', + 'description' => 'File or resource path', + ], + 'action' => [ + 'type' => 'string', + 'description' => 'Action performed', + 'enum' => ['created', 'modified', 'deleted', 'reviewed'], + ], + 'description' => [ + 'type' => 'string', + 'description' => 'Description of changes', + ], + ], + 'required' => ['path', 'action'], + ], + 'handler' => 'toolSessionArtifact', + ]; + + $this->tools['session_handoff'] = [ + 'description' => 'Prepare session for handoff to another agent', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'summary' => [ + 'type' => 'string', + 'description' => 'Summary of work done', + ], + 'next_steps' => [ + 'type' => 'array', + 'description' => 'Recommended next steps', + 'items' => ['type' => 'string'], + ], + 'blockers' => [ + 'type' => 'array', + 'description' => 'Any blockers encountered', + 'items' => ['type' => 'string'], + ], + 'context_for_next' => [ + 'type' => 'object', + 'description' => 'Context to pass to next agent', + ], + ], + 'required' => ['summary'], + ], + 'handler' => 'toolSessionHandoff', + ]; + + $this->tools['session_end'] = [ + 'description' => 'End the current session', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'status' => [ + 'type' => 'string', + 'description' => 'Final session status', + 'enum' => ['completed', 'handed_off', 'paused', 'failed'], + ], + 'summary' => [ + 'type' => 'string', + 'description' => 'Final summary', + ], + ], + 'required' => ['status'], + ], + 'handler' => 'toolSessionEnd', + ]; + + // State tools (persistent workspace state) + $this->tools['state_get'] = [ + 'description' => 'Get a workspace state value', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'plan_slug' => [ + 'type' => 'string', + 'description' => 'Plan slug identifier', + ], + 'key' => [ + 'type' => 'string', + 'description' => 'State key', + ], + ], + 'required' => ['plan_slug', 'key'], + ], + 'handler' => 'toolStateGet', + ]; + + $this->tools['state_set'] = [ + 'description' => 'Set a workspace state value', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'plan_slug' => [ + 'type' => 'string', + 'description' => 'Plan slug identifier', + ], + 'key' => [ + 'type' => 'string', + 'description' => 'State key', + ], + 'value' => [ + 'type' => ['string', 'number', 'boolean', 'object', 'array'], + 'description' => 'State value', + ], + 'category' => [ + 'type' => 'string', + 'description' => 'State category for organisation', + ], + ], + 'required' => ['plan_slug', 'key', 'value'], + ], + 'handler' => 'toolStateSet', + ]; + + $this->tools['state_list'] = [ + 'description' => 'List all state values for a plan', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'plan_slug' => [ + 'type' => 'string', + 'description' => 'Plan slug identifier', + ], + 'category' => [ + 'type' => 'string', + 'description' => 'Filter by category', + ], + ], + 'required' => ['plan_slug'], + ], + 'handler' => 'toolStateList', + ]; + + // Template tools + $this->tools['template_list'] = [ + 'description' => 'List available plan templates', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'category' => [ + 'type' => 'string', + 'description' => 'Filter by category', + ], + ], + ], + 'handler' => 'toolTemplateList', + ]; + + $this->tools['template_preview'] = [ + 'description' => 'Preview a template with variables', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'template' => [ + 'type' => 'string', + 'description' => 'Template name/slug', + ], + 'variables' => [ + 'type' => 'object', + 'description' => 'Variable values for the template', + ], + ], + 'required' => ['template'], + ], + 'handler' => 'toolTemplatePreview', + ]; + + $this->tools['template_create_plan'] = [ + 'description' => 'Create a new plan from a template', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'template' => [ + 'type' => 'string', + 'description' => 'Template name/slug', + ], + 'variables' => [ + 'type' => 'object', + 'description' => 'Variable values for the template', + ], + 'slug' => [ + 'type' => 'string', + 'description' => 'Custom slug for the plan', + ], + ], + 'required' => ['template', 'variables'], + ], + 'handler' => 'toolTemplateCreatePlan', + ]; + + // Content generation tools + $this->tools['content_status'] = [ + 'description' => 'Get content generation pipeline status (AI provider availability, brief counts)', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [], + ], + 'handler' => 'toolContentStatus', + ]; + + $this->tools['content_brief_create'] = [ + 'description' => 'Create a content brief for AI generation', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'title' => [ + 'type' => 'string', + 'description' => 'Content title', + ], + 'content_type' => [ + 'type' => 'string', + 'description' => 'Type of content', + 'enum' => ['help_article', 'blog_post', 'landing_page', 'social_post'], + ], + 'service' => [ + 'type' => 'string', + 'description' => 'Service context (e.g., BioHost, QRHost)', + ], + 'keywords' => [ + 'type' => 'array', + 'description' => 'SEO keywords to include', + 'items' => ['type' => 'string'], + ], + 'target_word_count' => [ + 'type' => 'integer', + 'description' => 'Target word count (default: 800)', + ], + 'description' => [ + 'type' => 'string', + 'description' => 'Brief description of what to write about', + ], + 'difficulty' => [ + 'type' => 'string', + 'description' => 'Target audience level', + 'enum' => ['beginner', 'intermediate', 'advanced'], + ], + 'plan_slug' => [ + 'type' => 'string', + 'description' => 'Link to an existing plan', + ], + ], + 'required' => ['title', 'content_type'], + ], + 'handler' => 'toolContentBriefCreate', + ]; + + $this->tools['content_brief_list'] = [ + 'description' => 'List content briefs with optional status filter', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'status' => [ + 'type' => 'string', + 'description' => 'Filter by status', + 'enum' => ['pending', 'queued', 'generating', 'review', 'published', 'failed'], + ], + 'limit' => [ + 'type' => 'integer', + 'description' => 'Maximum results (default: 20)', + ], + ], + ], + 'handler' => 'toolContentBriefList', + ]; + + $this->tools['content_brief_get'] = [ + 'description' => 'Get details of a specific content brief including generated content', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'integer', + 'description' => 'Brief ID', + ], + ], + 'required' => ['id'], + ], + 'handler' => 'toolContentBriefGet', + ]; + + $this->tools['content_generate'] = [ + 'description' => 'Generate content for a brief using AI pipeline (Gemini draft → Claude refine)', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'brief_id' => [ + 'type' => 'integer', + 'description' => 'Brief ID to generate content for', + ], + 'mode' => [ + 'type' => 'string', + 'description' => 'Generation mode', + 'enum' => ['draft', 'refine', 'full'], + ], + 'sync' => [ + 'type' => 'boolean', + 'description' => 'Run synchronously (wait for result) vs queue for async processing', + ], + ], + 'required' => ['brief_id'], + ], + 'handler' => 'toolContentGenerate', + ]; + + $this->tools['content_batch_generate'] = [ + 'description' => 'Queue multiple briefs for batch content generation', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'limit' => [ + 'type' => 'integer', + 'description' => 'Maximum briefs to process (default: 5)', + ], + 'mode' => [ + 'type' => 'string', + 'description' => 'Generation mode', + 'enum' => ['draft', 'refine', 'full'], + ], + ], + ], + 'handler' => 'toolContentBatchGenerate', + ]; + + $this->tools['content_from_plan'] = [ + 'description' => 'Create content briefs from plan tasks and queue for generation', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'plan_slug' => [ + 'type' => 'string', + 'description' => 'Plan slug to generate content from', + ], + 'content_type' => [ + 'type' => 'string', + 'description' => 'Type of content to generate', + 'enum' => ['help_article', 'blog_post', 'landing_page', 'social_post'], + ], + 'service' => [ + 'type' => 'string', + 'description' => 'Service context', + ], + 'limit' => [ + 'type' => 'integer', + 'description' => 'Maximum briefs to create (default: 5)', + ], + 'target_word_count' => [ + 'type' => 'integer', + 'description' => 'Target word count per article', + ], + ], + 'required' => ['plan_slug'], + ], + 'handler' => 'toolContentFromPlan', + ]; + + $this->tools['content_usage_stats'] = [ + 'description' => 'Get AI usage statistics (tokens, costs) for content generation', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'period' => [ + 'type' => 'string', + 'description' => 'Time period for stats', + 'enum' => ['day', 'week', 'month', 'year'], + ], + ], + ], + 'handler' => 'toolContentUsageStats', + ]; + } + + protected function registerResources(): void + { + $this->resources['plans://all'] = [ + 'name' => 'All Plans Overview', + 'description' => 'Overview of all work plans and their status', + 'mimeType' => 'text/markdown', + 'handler' => 'resourceAllPlans', + ]; + + // Dynamic plan resources are handled in getResourcesList + } + + protected function handleRequest(array $request): ?array + { + $method = $request['method'] ?? ''; + $id = $request['id'] ?? null; + $params = $request['params'] ?? []; + + return match ($method) { + 'initialize' => $this->handleInitialize($id, $params), + 'tools/list' => $this->handleToolsList($id), + 'tools/call' => $this->handleToolsCall($id, $params), + 'resources/list' => $this->handleResourcesList($id), + 'resources/read' => $this->handleResourcesRead($id, $params), + 'notifications/initialized' => null, + default => $this->errorResponse($id, -32601, "Method not found: {$method}"), + }; + } + + protected function handleInitialize(mixed $id, array $params): array + { + return [ + 'jsonrpc' => '2.0', + 'id' => $id, + 'result' => [ + 'protocolVersion' => '2024-11-05', + 'capabilities' => [ + 'tools' => ['listChanged' => true], + 'resources' => ['subscribe' => false, 'listChanged' => true], + ], + 'serverInfo' => [ + 'name' => 'hosthub-agent', + 'version' => '1.0.0', + ], + ], + ]; + } + + protected function handleToolsList(mixed $id): array + { + $tools = []; + + foreach ($this->tools as $name => $tool) { + $tools[] = [ + 'name' => $name, + 'description' => $tool['description'], + 'inputSchema' => $tool['inputSchema'], + ]; + } + + return [ + 'jsonrpc' => '2.0', + 'id' => $id, + 'result' => ['tools' => $tools], + ]; + } + + protected function handleToolsCall(mixed $id, array $params): array + { + $toolName = $params['name'] ?? ''; + $args = $params['arguments'] ?? []; + $startTime = microtime(true); + + if (! isset($this->tools[$toolName])) { + return $this->errorResponse($id, -32602, "Unknown tool: {$toolName}"); + } + + try { + $handler = $this->tools[$toolName]['handler']; + $result = $this->$handler($args); + + // Log tool call + $this->logToolCall($toolName, $args, $result, $startTime, true); + + return [ + 'jsonrpc' => '2.0', + 'id' => $id, + 'result' => [ + 'content' => [ + [ + 'type' => 'text', + 'text' => json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), + ], + ], + ], + ]; + } catch (Throwable $e) { + $this->logToolCall($toolName, $args, ['error' => $e->getMessage()], $startTime, false); + + Log::error('MCP tool error', [ + 'tool' => $toolName, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return $this->errorResponse($id, -32603, $e->getMessage()); + } + } + + protected function handleResourcesList(mixed $id): array + { + $resources = []; + + // Static resources + foreach ($this->resources as $uri => $resource) { + $resources[] = [ + 'uri' => $uri, + 'name' => $resource['name'], + 'description' => $resource['description'], + 'mimeType' => $resource['mimeType'], + ]; + } + + // Dynamic plan resources + $plans = AgentPlan::notArchived()->get(); + foreach ($plans as $plan) { + $resources[] = [ + 'uri' => "plans://{$plan->slug}", + 'name' => $plan->title, + 'description' => "Work plan: {$plan->title}", + 'mimeType' => 'text/markdown', + ]; + } + + return [ + 'jsonrpc' => '2.0', + 'id' => $id, + 'result' => ['resources' => $resources], + ]; + } + + protected function handleResourcesRead(mixed $id, array $params): array + { + $uri = $params['uri'] ?? ''; + + // Handle static resources + if (isset($this->resources[$uri])) { + $handler = $this->resources[$uri]['handler']; + $content = $this->$handler(); + + return [ + 'jsonrpc' => '2.0', + 'id' => $id, + 'result' => [ + 'contents' => [ + [ + 'uri' => $uri, + 'mimeType' => $this->resources[$uri]['mimeType'], + 'text' => $content, + ], + ], + ], + ]; + } + + // Handle dynamic plan resources + if (str_starts_with($uri, 'plans://')) { + $path = substr($uri, 9); // Remove 'plans://' + $parts = explode('/', $path); + $slug = $parts[0]; + + // plans://{slug}/phases/{order} + if (count($parts) === 3 && $parts[1] === 'phases') { + $content = $this->resourcePhaseChecklist($slug, (int) $parts[2]); + } + // plans://{slug}/state/{key} + elseif (count($parts) === 3 && $parts[1] === 'state') { + $content = $this->resourceStateValue($slug, $parts[2]); + } + // plans://{slug} + else { + $content = $this->resourcePlanDocument($slug); + } + + return [ + 'jsonrpc' => '2.0', + 'id' => $id, + 'result' => [ + 'contents' => [ + [ + 'uri' => $uri, + 'mimeType' => 'text/markdown', + 'text' => $content, + ], + ], + ], + ]; + } + + // Handle session resources + if (str_starts_with($uri, 'sessions://')) { + $path = substr($uri, 11); + $parts = explode('/', $path); + + if (count($parts) === 2 && $parts[1] === 'context') { + $content = $this->resourceSessionContext($parts[0]); + + return [ + 'jsonrpc' => '2.0', + 'id' => $id, + 'result' => [ + 'contents' => [ + [ + 'uri' => $uri, + 'mimeType' => 'text/markdown', + 'text' => $content, + ], + ], + ], + ]; + } + } + + return $this->errorResponse($id, -32602, "Resource not found: {$uri}"); + } + + protected function sendResponse(array $response): void + { + echo json_encode($response, JSON_UNESCAPED_SLASHES)."\n"; + flush(); + } + + protected function logToolCall(string $tool, array $args, array $result, float $startTime, bool $success): void + { + $duration = (int) ((microtime(true) - $startTime) * 1000); + + // Use the log() method which updates daily stats automatically + McpToolCall::log( + serverId: 'hosthub-agent', + toolName: $tool, + params: $args, + success: $success, + durationMs: $duration, + errorMessage: $success ? null : ($result['error'] ?? null), + errorCode: $success ? null : ($result['code'] ?? null), + resultSummary: $success ? $result : null, + sessionId: $this->currentSessionId, + ); + } + + // ===== TOOL IMPLEMENTATIONS ===== + + protected function toolPlanList(array $args): array + { + $query = AgentPlan::with('agentPhases') + ->orderBy('updated_at', 'desc'); + + if (! ($args['include_archived'] ?? false)) { + $query->notArchived(); + } + + if (! empty($args['status'])) { + $query->where('status', $args['status']); + } + + $plans = $query->get(); + + return [ + 'plans' => $plans->map(fn ($plan) => [ + 'slug' => $plan->slug, + 'title' => $plan->title, + 'status' => $plan->status, + 'progress' => $plan->getProgress(), + 'updated_at' => $plan->updated_at->toIso8601String(), + ])->all(), + 'total' => $plans->count(), + ]; + } + + protected function toolPlanCreate(array $args): array + { + $slug = $args['slug'] ?? Str::slug($args['title']).'-'.Str::random(6); + + if (AgentPlan::where('slug', $slug)->exists()) { + return ['error' => "Plan with slug '{$slug}' already exists"]; + } + + $plan = AgentPlan::create([ + 'slug' => $slug, + 'title' => $args['title'], + 'description' => $args['description'] ?? null, + 'status' => 'draft', + 'context' => $args['context'] ?? [], + ]); + + // Create phases if provided + if (! empty($args['phases'])) { + foreach ($args['phases'] as $order => $phaseData) { + $tasks = collect($phaseData['tasks'] ?? [])->map(fn ($task) => [ + 'name' => $task, + 'status' => 'pending', + ])->all(); + + AgentPhase::create([ + 'agent_plan_id' => $plan->id, + 'name' => $phaseData['name'], + 'description' => $phaseData['description'] ?? null, + 'order' => $order + 1, + 'status' => 'pending', + 'tasks' => $tasks, + ]); + } + } + + $plan->load('agentPhases'); + + return [ + 'success' => true, + 'plan' => [ + 'slug' => $plan->slug, + 'title' => $plan->title, + 'status' => $plan->status, + 'phases' => $plan->agentPhases->count(), + ], + ]; + } + + protected function toolPlanGet(array $args): array + { + $plan = AgentPlan::with('agentPhases') + ->where('slug', $args['slug']) + ->first(); + + if (! $plan) { + return ['error' => "Plan not found: {$args['slug']}"]; + } + + $format = $args['format'] ?? 'json'; + + if ($format === 'markdown') { + return ['markdown' => $plan->toMarkdown()]; + } + + return [ + 'plan' => [ + 'slug' => $plan->slug, + 'title' => $plan->title, + 'description' => $plan->description, + 'status' => $plan->status, + 'context' => $plan->context, + 'progress' => $plan->getProgress(), + 'phases' => $plan->agentPhases->map(fn ($phase) => [ + 'order' => $phase->order, + 'name' => $phase->name, + 'description' => $phase->description, + 'status' => $phase->status, + 'tasks' => $phase->tasks, + 'checkpoints' => $phase->checkpoints, + ])->all(), + 'created_at' => $plan->created_at->toIso8601String(), + 'updated_at' => $plan->updated_at->toIso8601String(), + ], + ]; + } + + protected function toolPlanUpdateStatus(array $args): array + { + $plan = AgentPlan::where('slug', $args['slug'])->first(); + + if (! $plan) { + return ['error' => "Plan not found: {$args['slug']}"]; + } + + $plan->update(['status' => $args['status']]); + + return [ + 'success' => true, + 'plan' => [ + 'slug' => $plan->slug, + 'status' => $plan->fresh()->status, + ], + ]; + } + + protected function toolPlanArchive(array $args): array + { + $plan = AgentPlan::where('slug', $args['slug'])->first(); + + if (! $plan) { + return ['error' => "Plan not found: {$args['slug']}"]; + } + + $plan->archive($args['reason'] ?? null); + + return [ + 'success' => true, + 'plan' => [ + 'slug' => $plan->slug, + 'status' => 'archived', + 'archived_at' => $plan->archived_at->toIso8601String(), + ], + ]; + } + + protected function toolPhaseGet(array $args): array + { + $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); + + if (! $plan) { + return ['error' => "Plan not found: {$args['plan_slug']}"]; + } + + $phase = $this->findPhase($plan, $args['phase']); + + if (! $phase) { + return ['error' => "Phase not found: {$args['phase']}"]; + } + + return [ + 'phase' => [ + 'order' => $phase->order, + 'name' => $phase->name, + 'description' => $phase->description, + 'status' => $phase->status, + 'tasks' => $phase->tasks, + 'checkpoints' => $phase->checkpoints, + 'dependencies' => $phase->dependencies, + ], + ]; + } + + protected function toolPhaseUpdateStatus(array $args): array + { + $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); + + if (! $plan) { + return ['error' => "Plan not found: {$args['plan_slug']}"]; + } + + $phase = $this->findPhase($plan, $args['phase']); + + if (! $phase) { + return ['error' => "Phase not found: {$args['phase']}"]; + } + + $updateData = ['status' => $args['status']]; + + if (! empty($args['notes'])) { + $phase->addCheckpoint($args['notes'], ['status_change' => $args['status']]); + } + + $phase->update($updateData); + + return [ + 'success' => true, + 'phase' => [ + 'order' => $phase->order, + 'name' => $phase->name, + 'status' => $phase->fresh()->status, + ], + ]; + } + + protected function toolPhaseAddCheckpoint(array $args): array + { + $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); + + if (! $plan) { + return ['error' => "Plan not found: {$args['plan_slug']}"]; + } + + $phase = $this->findPhase($plan, $args['phase']); + + if (! $phase) { + return ['error' => "Phase not found: {$args['phase']}"]; + } + + $phase->addCheckpoint($args['note'], $args['context'] ?? []); + + return [ + 'success' => true, + 'checkpoints' => $phase->fresh()->checkpoints, + ]; + } + + protected function toolTaskToggle(array $args): array + { + $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); + + if (! $plan) { + return ['error' => "Plan not found: {$args['plan_slug']}"]; + } + + $phase = $this->findPhase($plan, $args['phase']); + + if (! $phase) { + return ['error' => "Phase not found: {$args['phase']}"]; + } + + $tasks = $phase->tasks ?? []; + $index = $args['task_index']; + + if (! isset($tasks[$index])) { + return ['error' => "Task not found at index: {$index}"]; + } + + $currentStatus = is_string($tasks[$index]) + ? 'pending' + : ($tasks[$index]['status'] ?? 'pending'); + + $newStatus = $currentStatus === 'completed' ? 'pending' : 'completed'; + + if (is_string($tasks[$index])) { + $tasks[$index] = [ + 'name' => $tasks[$index], + 'status' => $newStatus, + ]; + } else { + $tasks[$index]['status'] = $newStatus; + } + + $phase->update(['tasks' => $tasks]); + + return [ + 'success' => true, + 'task' => $tasks[$index], + 'plan_progress' => $plan->fresh()->getProgress(), + ]; + } + + protected function toolTaskUpdate(array $args): array + { + $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); + + if (! $plan) { + return ['error' => "Plan not found: {$args['plan_slug']}"]; + } + + $phase = $this->findPhase($plan, $args['phase']); + + if (! $phase) { + return ['error' => "Phase not found: {$args['phase']}"]; + } + + $tasks = $phase->tasks ?? []; + $index = $args['task_index']; + + if (! isset($tasks[$index])) { + return ['error' => "Task not found at index: {$index}"]; + } + + if (is_string($tasks[$index])) { + $tasks[$index] = ['name' => $tasks[$index], 'status' => 'pending']; + } + + if (isset($args['status'])) { + $tasks[$index]['status'] = $args['status']; + } + + if (isset($args['notes'])) { + $tasks[$index]['notes'] = $args['notes']; + } + + $phase->update(['tasks' => $tasks]); + + return [ + 'success' => true, + 'task' => $tasks[$index], + ]; + } + + protected function toolSessionStart(array $args): array + { + $plan = null; + if (! empty($args['plan_slug'])) { + $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); + } + + $sessionId = 'ses_'.Str::random(12); + $this->currentSessionId = $sessionId; + + $session = AgentSession::create([ + 'session_id' => $sessionId, + 'agent_plan_id' => $plan?->id, + 'agent_type' => $args['agent_type'], + 'status' => 'active', + 'started_at' => now(), + 'context_summary' => $args['context'] ?? [], + ]); + + return [ + 'success' => true, + 'session' => [ + 'session_id' => $session->session_id, + 'agent_type' => $session->agent_type, + 'plan' => $plan?->slug, + 'status' => $session->status, + ], + ]; + } + + protected function toolSessionLog(array $args): array + { + if (! $this->currentSessionId) { + return ['error' => 'No active session. Call session_start first.']; + } + + $session = AgentSession::where('session_id', $this->currentSessionId)->first(); + + if (! $session) { + return ['error' => 'Session not found']; + } + + $session->addWorkLogEntry( + $args['message'], + $args['type'] ?? 'info', + $args['data'] ?? [] + ); + + return ['success' => true, 'logged' => $args['message']]; + } + + protected function toolSessionArtifact(array $args): array + { + if (! $this->currentSessionId) { + return ['error' => 'No active session. Call session_start first.']; + } + + $session = AgentSession::where('session_id', $this->currentSessionId)->first(); + + if (! $session) { + return ['error' => 'Session not found']; + } + + $session->addArtifact( + $args['path'], + $args['action'], + $args['description'] ?? null + ); + + return ['success' => true, 'artifact' => $args['path']]; + } + + protected function toolSessionHandoff(array $args): array + { + if (! $this->currentSessionId) { + return ['error' => 'No active session. Call session_start first.']; + } + + $session = AgentSession::where('session_id', $this->currentSessionId)->first(); + + if (! $session) { + return ['error' => 'Session not found']; + } + + $session->prepareHandoff( + $args['summary'], + $args['next_steps'] ?? [], + $args['blockers'] ?? [], + $args['context_for_next'] ?? [] + ); + + return [ + 'success' => true, + 'handoff_context' => $session->getHandoffContext(), + ]; + } + + protected function toolSessionEnd(array $args): array + { + if (! $this->currentSessionId) { + return ['error' => 'No active session']; + } + + $session = AgentSession::where('session_id', $this->currentSessionId)->first(); + + if (! $session) { + return ['error' => 'Session not found']; + } + + $session->end($args['status'], $args['summary'] ?? null); + $this->currentSessionId = null; + + return [ + 'success' => true, + 'session' => [ + 'session_id' => $session->session_id, + 'status' => $session->status, + 'duration' => $session->getDurationFormatted(), + ], + ]; + } + + protected function toolStateGet(array $args): array + { + $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); + + if (! $plan) { + return ['error' => "Plan not found: {$args['plan_slug']}"]; + } + + $state = $plan->states()->where('key', $args['key'])->first(); + + if (! $state) { + return ['error' => "State not found: {$args['key']}"]; + } + + return [ + 'key' => $state->key, + 'value' => $state->value, + 'category' => $state->category, + 'updated_at' => $state->updated_at->toIso8601String(), + ]; + } + + protected function toolStateSet(array $args): array + { + $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); + + if (! $plan) { + return ['error' => "Plan not found: {$args['plan_slug']}"]; + } + + $state = AgentWorkspaceState::updateOrCreate( + [ + 'agent_plan_id' => $plan->id, + 'key' => $args['key'], + ], + [ + 'value' => $args['value'], + 'category' => $args['category'] ?? 'general', + ] + ); + + return [ + 'success' => true, + 'state' => [ + 'key' => $state->key, + 'value' => $state->value, + 'category' => $state->category, + ], + ]; + } + + protected function toolStateList(array $args): array + { + $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); + + if (! $plan) { + return ['error' => "Plan not found: {$args['plan_slug']}"]; + } + + $query = $plan->states(); + + if (! empty($args['category'])) { + $query->where('category', $args['category']); + } + + $states = $query->get(); + + return [ + 'states' => $states->map(fn ($state) => [ + 'key' => $state->key, + 'value' => $state->value, + 'category' => $state->category, + ])->all(), + 'total' => $states->count(), + ]; + } + + protected function toolTemplateList(array $args): array + { + $templateService = app(PlanTemplateService::class); + $templates = $templateService->listTemplates(); + + if (! empty($args['category'])) { + $templates = array_filter($templates, fn ($t) => ($t['category'] ?? '') === $args['category']); + } + + return [ + 'templates' => array_values($templates), + 'total' => count($templates), + ]; + } + + protected function toolTemplatePreview(array $args): array + { + $templateService = app(PlanTemplateService::class); + $templateSlug = $args['template']; + $variables = $args['variables'] ?? []; + + $preview = $templateService->previewTemplate($templateSlug, $variables); + + if (! $preview) { + return ['error' => "Template not found: {$templateSlug}"]; + } + + return [ + 'template' => $templateSlug, + 'preview' => $preview, + ]; + } + + protected function toolTemplateCreatePlan(array $args): array + { + $templateService = app(PlanTemplateService::class); + $templateSlug = $args['template']; + $variables = $args['variables'] ?? []; + + $options = []; + + if (! empty($args['slug'])) { + $options['slug'] = $args['slug']; + } + + $plan = $templateService->createPlan($templateSlug, $variables, $options); + + if (! $plan) { + return ['error' => 'Failed to create plan from template']; + } + + return [ + 'success' => true, + 'plan' => [ + 'slug' => $plan->slug, + 'title' => $plan->title, + 'status' => $plan->status, + 'phases' => $plan->agentPhases->count(), + 'total_tasks' => $plan->getProgress()['total'], + ], + 'commands' => [ + 'view' => "php artisan plan:show {$plan->slug}", + 'activate' => "php artisan plan:status {$plan->slug} --set=active", + ], + ]; + } + + // ===== CONTENT GENERATION TOOL IMPLEMENTATIONS ===== + + protected function toolContentStatus(array $args): array + { + $gateway = app(AIGatewayService::class); + + return [ + 'providers' => [ + 'gemini' => $gateway->isGeminiAvailable(), + 'claude' => $gateway->isClaudeAvailable(), + ], + 'pipeline_available' => $gateway->isAvailable(), + 'briefs' => [ + 'pending' => ContentBrief::pending()->count(), + 'queued' => ContentBrief::where('status', ContentBrief::STATUS_QUEUED)->count(), + 'generating' => ContentBrief::where('status', ContentBrief::STATUS_GENERATING)->count(), + 'review' => ContentBrief::needsReview()->count(), + 'published' => ContentBrief::where('status', ContentBrief::STATUS_PUBLISHED)->count(), + 'failed' => ContentBrief::where('status', ContentBrief::STATUS_FAILED)->count(), + ], + ]; + } + + protected function toolContentBriefCreate(array $args): array + { + $plan = null; + if (! empty($args['plan_slug'])) { + $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); + } + + $brief = ContentBrief::create([ + 'title' => $args['title'], + 'slug' => Str::slug($args['title']).'-'.Str::random(6), + 'content_type' => $args['content_type'], + 'service' => $args['service'] ?? null, + 'description' => $args['description'] ?? null, + 'keywords' => $args['keywords'] ?? null, + 'target_word_count' => $args['target_word_count'] ?? 800, + 'difficulty' => $args['difficulty'] ?? null, + 'status' => ContentBrief::STATUS_PENDING, + 'metadata' => $plan ? [ + 'plan_id' => $plan->id, + 'plan_slug' => $plan->slug, + ] : null, + ]); + + return [ + 'success' => true, + 'brief' => [ + 'id' => $brief->id, + 'title' => $brief->title, + 'slug' => $brief->slug, + 'status' => $brief->status, + 'content_type' => $brief->content_type, + ], + ]; + } + + protected function toolContentBriefList(array $args): array + { + $query = ContentBrief::query()->orderBy('created_at', 'desc'); + + if (! empty($args['status'])) { + $query->where('status', $args['status']); + } + + $limit = $args['limit'] ?? 20; + $briefs = $query->limit($limit)->get(); + + return [ + 'briefs' => $briefs->map(fn ($brief) => [ + 'id' => $brief->id, + 'title' => $brief->title, + 'status' => $brief->status, + 'content_type' => $brief->content_type, + 'service' => $brief->service, + 'created_at' => $brief->created_at->toIso8601String(), + ])->all(), + 'total' => $briefs->count(), + ]; + } + + protected function toolContentBriefGet(array $args): array + { + $brief = ContentBrief::find($args['id']); + + if (! $brief) { + return ['error' => "Brief not found: {$args['id']}"]; + } + + return [ + 'brief' => [ + 'id' => $brief->id, + 'title' => $brief->title, + 'slug' => $brief->slug, + 'status' => $brief->status, + 'content_type' => $brief->content_type, + 'service' => $brief->service, + 'description' => $brief->description, + 'keywords' => $brief->keywords, + 'target_word_count' => $brief->target_word_count, + 'difficulty' => $brief->difficulty, + 'draft_output' => $brief->draft_output, + 'refined_output' => $brief->refined_output, + 'final_content' => $brief->final_content, + 'best_content' => $brief->best_content, + 'error_message' => $brief->error_message, + 'generation_log' => $brief->generation_log, + 'total_cost' => $brief->total_cost, + 'created_at' => $brief->created_at->toIso8601String(), + 'generated_at' => $brief->generated_at?->toIso8601String(), + 'refined_at' => $brief->refined_at?->toIso8601String(), + ], + ]; + } + + protected function toolContentGenerate(array $args): array + { + $brief = ContentBrief::find($args['brief_id']); + + if (! $brief) { + return ['error' => "Brief not found: {$args['brief_id']}"]; + } + + $gateway = app(AIGatewayService::class); + + if (! $gateway->isAvailable()) { + return ['error' => 'AI providers not configured. Set GOOGLE_AI_API_KEY and ANTHROPIC_API_KEY.']; + } + + $mode = $args['mode'] ?? 'full'; + $sync = $args['sync'] ?? false; + + if ($sync) { + try { + if ($mode === 'full') { + $result = $gateway->generateAndRefine($brief); + + return [ + 'success' => true, + 'brief_id' => $brief->id, + 'status' => $brief->fresh()->status, + 'draft' => [ + 'model' => $result['draft']->model, + 'tokens' => $result['draft']->totalTokens(), + 'cost' => $result['draft']->estimateCost(), + ], + 'refined' => [ + 'model' => $result['refined']->model, + 'tokens' => $result['refined']->totalTokens(), + 'cost' => $result['refined']->estimateCost(), + ], + ]; + } elseif ($mode === 'draft') { + $response = $gateway->generateDraft($brief); + $brief->markDraftComplete($response->content); + + return [ + 'success' => true, + 'brief_id' => $brief->id, + 'status' => $brief->fresh()->status, + 'draft' => [ + 'model' => $response->model, + 'tokens' => $response->totalTokens(), + 'cost' => $response->estimateCost(), + ], + ]; + } elseif ($mode === 'refine') { + if (! $brief->isGenerated()) { + return ['error' => 'No draft to refine. Generate draft first.']; + } + $response = $gateway->refineDraft($brief, $brief->draft_output); + $brief->markRefined($response->content); + + return [ + 'success' => true, + 'brief_id' => $brief->id, + 'status' => $brief->fresh()->status, + 'refined' => [ + 'model' => $response->model, + 'tokens' => $response->totalTokens(), + 'cost' => $response->estimateCost(), + ], + ]; + } + } catch (\Exception $e) { + $brief->markFailed($e->getMessage()); + + return ['error' => $e->getMessage()]; + } + } + + // Async - queue for processing + $brief->markQueued(); + GenerateContentJob::dispatch($brief, $mode); + + return [ + 'success' => true, + 'queued' => true, + 'brief_id' => $brief->id, + 'mode' => $mode, + 'message' => 'Brief queued for generation', + ]; + } + + protected function toolContentBatchGenerate(array $args): array + { + $limit = $args['limit'] ?? 5; + $mode = $args['mode'] ?? 'full'; + + $briefs = ContentBrief::readyToProcess()->limit($limit)->get(); + + if ($briefs->isEmpty()) { + return ['message' => 'No briefs ready for processing', 'queued' => 0]; + } + + foreach ($briefs as $brief) { + GenerateContentJob::dispatch($brief, $mode); + } + + return [ + 'success' => true, + 'queued' => $briefs->count(), + 'mode' => $mode, + 'brief_ids' => $briefs->pluck('id')->all(), + ]; + } + + protected function toolContentFromPlan(array $args): array + { + $plan = AgentPlan::with('agentPhases') + ->where('slug', $args['plan_slug']) + ->first(); + + if (! $plan) { + return ['error' => "Plan not found: {$args['plan_slug']}"]; + } + + $limit = $args['limit'] ?? 5; + $contentType = $args['content_type'] ?? 'help_article'; + $service = $args['service'] ?? ($plan->metadata['service'] ?? null); + $wordCount = $args['target_word_count'] ?? 800; + + $phases = $plan->agentPhases() + ->whereIn('status', ['pending', 'in_progress']) + ->get(); + + if ($phases->isEmpty()) { + return ['message' => 'No pending phases in plan', 'created' => 0]; + } + + $briefsCreated = []; + + foreach ($phases as $phase) { + $tasks = $phase->getTasks(); + + foreach ($tasks as $index => $task) { + if (count($briefsCreated) >= $limit) { + break 2; + } + + $taskName = is_string($task) ? $task : ($task['name'] ?? ''); + $taskStatus = is_array($task) ? ($task['status'] ?? 'pending') : 'pending'; + + if ($taskStatus === 'completed') { + continue; + } + + $brief = ContentBrief::create([ + 'title' => $taskName, + 'slug' => Str::slug($taskName).'-'.time(), + 'content_type' => $contentType, + 'service' => $service, + 'target_word_count' => $wordCount, + 'status' => ContentBrief::STATUS_QUEUED, + 'metadata' => [ + 'plan_id' => $plan->id, + 'plan_slug' => $plan->slug, + 'phase_id' => $phase->id, + 'phase_order' => $phase->order, + 'task_index' => $index, + ], + ]); + + GenerateContentJob::dispatch($brief, 'full'); + $briefsCreated[] = [ + 'id' => $brief->id, + 'title' => $brief->title, + ]; + } + } + + return [ + 'success' => true, + 'plan' => $plan->slug, + 'created' => count($briefsCreated), + 'briefs' => $briefsCreated, + ]; + } + + protected function toolContentUsageStats(array $args): array + { + $period = $args['period'] ?? 'month'; + $stats = AIUsage::statsForWorkspace(null, $period); + + return [ + 'period' => $period, + 'total_requests' => $stats['total_requests'], + 'total_input_tokens' => $stats['total_input_tokens'], + 'total_output_tokens' => $stats['total_output_tokens'], + 'total_cost' => number_format($stats['total_cost'], 4), + 'by_provider' => $stats['by_provider'], + 'by_purpose' => $stats['by_purpose'], + ]; + } + + // ===== RESOURCE IMPLEMENTATIONS ===== + + protected function resourceAllPlans(): string + { + $plans = AgentPlan::with('agentPhases')->notArchived()->orderBy('updated_at', 'desc')->get(); + + $md = "# Work Plans\n\n"; + $md .= '**Total:** '.$plans->count()." plan(s)\n\n"; + + foreach ($plans->groupBy('status') as $status => $group) { + $md .= '## '.ucfirst($status).' ('.$group->count().")\n\n"; + + foreach ($group as $plan) { + $progress = $plan->getProgress(); + $md .= "- **[{$plan->slug}]** {$plan->title} - {$progress['percentage']}%\n"; + } + $md .= "\n"; + } + + return $md; + } + + protected function resourcePlanDocument(string $slug): string + { + $plan = AgentPlan::with('agentPhases')->where('slug', $slug)->first(); + + if (! $plan) { + return "Plan not found: {$slug}"; + } + + return $plan->toMarkdown(); + } + + protected function resourcePhaseChecklist(string $slug, int $phaseOrder): string + { + $plan = AgentPlan::where('slug', $slug)->first(); + + if (! $plan) { + return "Plan not found: {$slug}"; + } + + $phase = $plan->agentPhases()->where('order', $phaseOrder)->first(); + + if (! $phase) { + return "Phase not found: {$phaseOrder}"; + } + + $md = "# Phase {$phase->order}: {$phase->name}\n\n"; + $md .= "**Status:** {$phase->getStatusIcon()} {$phase->status}\n\n"; + + if ($phase->description) { + $md .= "{$phase->description}\n\n"; + } + + $md .= "## Tasks\n\n"; + + foreach ($phase->tasks ?? [] as $task) { + $status = is_string($task) ? 'pending' : ($task['status'] ?? 'pending'); + $name = is_string($task) ? $task : ($task['name'] ?? 'Unknown'); + $icon = $status === 'completed' ? '✅' : '⬜'; + $md .= "- {$icon} {$name}\n"; + } + + return $md; + } + + protected function resourceStateValue(string $slug, string $key): string + { + $plan = AgentPlan::where('slug', $slug)->first(); + + if (! $plan) { + return "Plan not found: {$slug}"; + } + + $state = $plan->states()->where('key', $key)->first(); + + if (! $state) { + return "State key not found: {$key}"; + } + + return $state->getFormattedValue(); + } + + protected function resourceSessionContext(string $sessionId): string + { + $session = AgentSession::where('session_id', $sessionId)->first(); + + if (! $session) { + return "Session not found: {$sessionId}"; + } + + $context = $session->getHandoffContext(); + + $md = "# Session: {$session->session_id}\n\n"; + $md .= "**Agent:** {$session->agent_type}\n"; + $md .= "**Status:** {$session->status}\n"; + $md .= "**Duration:** {$session->getDurationFormatted()}\n\n"; + + if ($session->plan) { + $md .= "## Plan\n\n"; + $md .= "**{$session->plan->title}** ({$session->plan->slug})\n\n"; + } + + if (! empty($context['context_summary'])) { + $md .= "## Context Summary\n\n"; + $md .= json_encode($context['context_summary'], JSON_PRETTY_PRINT)."\n\n"; + } + + if (! empty($context['handoff_notes'])) { + $md .= "## Handoff Notes\n\n"; + $md .= json_encode($context['handoff_notes'], JSON_PRETTY_PRINT)."\n\n"; + } + + if (! empty($context['artifacts'])) { + $md .= "## Artifacts\n\n"; + foreach ($context['artifacts'] as $artifact) { + $md .= "- {$artifact['action']}: {$artifact['path']}\n"; + } + $md .= "\n"; + } + + return $md; + } + + // ===== HELPERS ===== + + protected function findPhase(AgentPlan $plan, string|int $identifier): ?AgentPhase + { + if (is_numeric($identifier)) { + return $plan->agentPhases()->where('order', (int) $identifier)->first(); + } + + return $plan->agentPhases() + ->where(function ($query) use ($identifier) { + $query->where('name', $identifier) + ->orWhere('order', $identifier); + }) + ->first(); + } + + protected function errorResponse(mixed $id, int $code, string $message): array + { + return [ + 'jsonrpc' => '2.0', + 'id' => $id, + 'error' => [ + 'code' => $code, + 'message' => $message, + ], + ]; + } +} diff --git a/src/php/src/Mcp/Console/Commands/McpMonitorCommand.php b/src/php/src/Mcp/Console/Commands/McpMonitorCommand.php new file mode 100644 index 0000000..52415e7 --- /dev/null +++ b/src/php/src/Mcp/Console/Commands/McpMonitorCommand.php @@ -0,0 +1,199 @@ +argument('action'); + + return match ($action) { + 'status' => $this->showStatus($monitoring), + 'alerts' => $this->checkAlerts($monitoring), + 'export' => $this->exportMetrics($monitoring), + 'report' => $this->showReport($monitoring), + 'prometheus' => $this->showPrometheus($monitoring), + default => $this->showHelp(), + }; + } + + protected function showStatus(McpMonitoringService $monitoring): int + { + $health = $monitoring->getHealthStatus(); + + if ($this->option('json')) { + $this->line(json_encode($health, JSON_PRETTY_PRINT)); + + return 0; + } + + $statusColor = match ($health['status']) { + 'healthy' => 'green', + 'degraded' => 'yellow', + 'critical' => 'red', + default => 'white', + }; + + $this->newLine(); + $this->line("MCP Health Status: ".strtoupper($health['status']).''); + $this->newLine(); + + $this->table( + ['Metric', 'Value'], + [ + ['Total Calls (24h)', number_format($health['metrics']['total_calls'])], + ['Success Rate', $health['metrics']['success_rate'].'%'], + ['Error Rate', $health['metrics']['error_rate'].'%'], + ['Avg Duration', $health['metrics']['avg_duration_ms'].'ms'], + ] + ); + + if (count($health['issues']) > 0) { + $this->newLine(); + $this->warn('Issues Detected:'); + + foreach ($health['issues'] as $issue) { + $icon = $issue['severity'] === 'critical' ? '!!' : '!'; + $this->line(" [{$icon}] {$issue['message']}"); + } + } + + $this->newLine(); + $this->line('Checked at: '.$health['checked_at'].''); + + return $health['status'] === 'critical' ? 1 : 0; + } + + protected function checkAlerts(McpMonitoringService $monitoring): int + { + $alerts = $monitoring->checkAlerts(); + + if ($this->option('json')) { + $this->line(json_encode($alerts, JSON_PRETTY_PRINT)); + + return count($alerts) > 0 ? 1 : 0; + } + + if (count($alerts) === 0) { + $this->info('No alerts detected.'); + + return 0; + } + + $this->warn(count($alerts).' alert(s) detected:'); + $this->newLine(); + + foreach ($alerts as $alert) { + $severityColor = $alert['severity'] === 'critical' ? 'red' : 'yellow'; + $this->line("[{$alert['severity']}] {$alert['message']}"); + } + + return 1; + } + + protected function exportMetrics(McpMonitoringService $monitoring): int + { + $monitoring->exportMetrics(); + $this->info('Metrics exported to monitoring channel.'); + + return 0; + } + + protected function showReport(McpMonitoringService $monitoring): int + { + $days = (int) $this->option('days'); + $report = $monitoring->getSummaryReport($days); + + if ($this->option('json')) { + $this->line(json_encode($report, JSON_PRETTY_PRINT)); + + return 0; + } + + $this->newLine(); + $this->line("MCP Summary Report ({$days} days)"); + $this->line("Period: {$report['period']['from']} to {$report['period']['to']}"); + $this->newLine(); + + // Overview + $this->line('Overview:'); + $this->table( + ['Metric', 'Value'], + [ + ['Total Calls', number_format($report['overview']['total_calls'])], + ['Success Rate', $report['overview']['success_rate'].'%'], + ['Avg Duration', $report['overview']['avg_duration_ms'].'ms'], + ['Unique Tools', $report['overview']['unique_tools']], + ['Unique Servers', $report['overview']['unique_servers']], + ] + ); + + // Top tools + if (count($report['top_tools']) > 0) { + $this->newLine(); + $this->line('Top Tools:'); + + $toolRows = []; + foreach ($report['top_tools'] as $tool) { + $toolRows[] = [ + $tool->tool_name, + number_format($tool->total_calls), + $tool->success_rate.'%', + round($tool->avg_duration ?? 0).'ms', + ]; + } + + $this->table(['Tool', 'Calls', 'Success Rate', 'Avg Duration'], $toolRows); + } + + // Anomalies + if (count($report['anomalies']) > 0) { + $this->newLine(); + $this->warn('Anomalies Detected:'); + + foreach ($report['anomalies'] as $anomaly) { + $this->line(" - [{$anomaly['tool']}] {$anomaly['message']}"); + } + } + + $this->newLine(); + $this->line('Generated: '.$report['generated_at'].''); + + return 0; + } + + protected function showPrometheus(McpMonitoringService $monitoring): int + { + $metrics = $monitoring->getPrometheusMetrics(); + $this->line($metrics); + + return 0; + } + + protected function showHelp(): int + { + $this->error('Unknown action. Available actions: status, alerts, export, report, prometheus'); + + return 1; + } +} diff --git a/src/php/src/Mcp/Console/Commands/PruneMetricsCommand.php b/src/php/src/Mcp/Console/Commands/PruneMetricsCommand.php new file mode 100644 index 0000000..812779a --- /dev/null +++ b/src/php/src/Mcp/Console/Commands/PruneMetricsCommand.php @@ -0,0 +1,97 @@ +option('dry-run'); + $retentionDays = (int) ($this->option('days') ?? config('mcp.analytics.retention_days', 90)); + + $this->info('MCP Metrics Pruning'.($dryRun ? ' (DRY RUN)' : '')); + $this->line(''); + $this->line("Retention period: {$retentionDays} days"); + $this->line(''); + + $cutoffDate = now()->subDays($retentionDays)->toDateString(); + + // Prune tool metrics + $metricsCount = ToolMetric::where('date', '<', $cutoffDate)->count(); + + if ($metricsCount > 0) { + if ($dryRun) { + $this->line("Would delete {$metricsCount} tool metric record(s) older than {$cutoffDate}"); + } else { + $deleted = $this->deleteInChunks(ToolMetric::class, 'date', $cutoffDate); + $this->info("Deleted {$deleted} tool metric record(s)"); + } + } else { + $this->line('No tool metrics to prune'); + } + + // Prune tool combinations + $combinationsCount = DB::table('mcp_tool_combinations') + ->where('date', '<', $cutoffDate) + ->count(); + + if ($combinationsCount > 0) { + if ($dryRun) { + $this->line("Would delete {$combinationsCount} tool combination record(s) older than {$cutoffDate}"); + } else { + $deleted = DB::table('mcp_tool_combinations') + ->where('date', '<', $cutoffDate) + ->delete(); + $this->info("Deleted {$deleted} tool combination record(s)"); + } + } else { + $this->line('No tool combinations to prune'); + } + + $this->line(''); + $this->info('Pruning complete.'); + + return self::SUCCESS; + } + + /** + * Delete records in chunks to avoid memory issues. + */ + protected function deleteInChunks(string $model, string $column, string $cutoff, int $chunkSize = 1000): int + { + $totalDeleted = 0; + + do { + $deleted = $model::where($column, '<', $cutoff) + ->limit($chunkSize) + ->delete(); + + $totalDeleted += $deleted; + + // Small pause to reduce database pressure + if ($deleted > 0) { + usleep(10000); // 10ms + } + } while ($deleted > 0); + + return $totalDeleted; + } +} diff --git a/src/php/src/Mcp/Console/Commands/VerifyAuditLogCommand.php b/src/php/src/Mcp/Console/Commands/VerifyAuditLogCommand.php new file mode 100644 index 0000000..e9808c2 --- /dev/null +++ b/src/php/src/Mcp/Console/Commands/VerifyAuditLogCommand.php @@ -0,0 +1,104 @@ +option('from') ? (int) $this->option('from') : null; + $toId = $this->option('to') ? (int) $this->option('to') : null; + $jsonOutput = $this->option('json'); + + if (! $jsonOutput) { + $this->info('Verifying MCP audit log integrity...'); + $this->newLine(); + } + + $result = $auditLogService->verifyChain($fromId, $toId); + + if ($jsonOutput) { + $this->line(json_encode($result, JSON_PRETTY_PRINT)); + + return $result['valid'] ? self::SUCCESS : self::FAILURE; + } + + // Display results + $this->displayResults($result); + + return $result['valid'] ? self::SUCCESS : self::FAILURE; + } + + /** + * Display verification results. + */ + protected function displayResults(array $result): void + { + // Summary table + $this->table( + ['Metric', 'Value'], + [ + ['Total Entries', number_format($result['total'])], + ['Verified', number_format($result['verified'])], + ['Status', $result['valid'] ? 'VALID' : 'INVALID'], + ['Issues Found', count($result['issues'])], + ] + ); + + if ($result['valid']) { + $this->newLine(); + $this->info('Audit log integrity verified successfully.'); + $this->info('The hash chain is intact and no tampering has been detected.'); + + return; + } + + // Display issues + $this->newLine(); + $this->error('Integrity issues detected!'); + $this->newLine(); + + foreach ($result['issues'] as $issue) { + $this->warn("Entry #{$issue['id']}: {$issue['type']}"); + $this->line(" {$issue['message']}"); + + if (isset($issue['expected'])) { + $this->line(" Expected: {$issue['expected']}"); + } + + if (isset($issue['actual'])) { + $this->line(" Actual: {$issue['actual']}"); + } + + $this->newLine(); + } + + $this->error('The audit log may have been tampered with. Please investigate immediately.'); + } +} diff --git a/src/php/src/Mcp/Context/WorkspaceContext.php b/src/php/src/Mcp/Context/WorkspaceContext.php new file mode 100644 index 0000000..9c34d5e --- /dev/null +++ b/src/php/src/Mcp/Context/WorkspaceContext.php @@ -0,0 +1,112 @@ +id, + workspace: $workspace, + ); + } + + /** + * Create context from a workspace ID (lazy loads workspace when needed). + */ + public static function fromId(int $workspaceId): self + { + return new self(workspaceId: $workspaceId); + } + + /** + * Create context from request attributes. + * + * @throws MissingWorkspaceContextException If no workspace context is available + */ + public static function fromRequest(mixed $request, string $toolName = 'unknown'): self + { + // Try to get workspace from request attributes (set by middleware) + $workspace = $request->attributes->get('mcp_workspace') + ?? $request->attributes->get('workspace'); + + if ($workspace instanceof Workspace) { + return self::fromWorkspace($workspace); + } + + // Try to get API key's workspace + $apiKey = $request->attributes->get('api_key'); + if ($apiKey?->workspace_id) { + return new self( + workspaceId: $apiKey->workspace_id, + workspace: $apiKey->workspace, + ); + } + + // Try authenticated user's default workspace + $user = $request->user(); + if ($user && method_exists($user, 'defaultHostWorkspace')) { + $workspace = $user->defaultHostWorkspace(); + if ($workspace) { + return self::fromWorkspace($workspace); + } + } + + throw new MissingWorkspaceContextException($toolName); + } + + /** + * Get the workspace model, loading it if necessary. + */ + public function getWorkspace(): Workspace + { + if ($this->workspace) { + return $this->workspace; + } + + return Workspace::findOrFail($this->workspaceId); + } + + /** + * Check if this context has a specific workspace ID. + */ + public function hasWorkspaceId(int $workspaceId): bool + { + return $this->workspaceId === $workspaceId; + } + + /** + * Validate that a resource belongs to this workspace. + * + * @throws \RuntimeException If the resource doesn't belong to this workspace + */ + public function validateOwnership(int $resourceWorkspaceId, string $resourceType = 'resource'): void + { + if ($resourceWorkspaceId !== $this->workspaceId) { + throw new \RuntimeException( + "Access denied: {$resourceType} does not belong to the authenticated workspace." + ); + } + } +} diff --git a/src/php/src/Mcp/Controllers/McpApiController.php b/src/php/src/Mcp/Controllers/McpApiController.php new file mode 100644 index 0000000..87b416e --- /dev/null +++ b/src/php/src/Mcp/Controllers/McpApiController.php @@ -0,0 +1,477 @@ +loadRegistry(); + + $servers = collect($registry['servers'] ?? []) + ->map(fn ($ref) => $this->loadServerSummary($ref['id'])) + ->filter() + ->values(); + + return response()->json([ + 'servers' => $servers, + 'count' => $servers->count(), + ]); + } + + /** + * Get server details with tools and resources. + * + * GET /api/v1/mcp/servers/{id} + */ + public function server(Request $request, string $id): JsonResponse + { + $server = $this->loadServerFull($id); + + if (! $server) { + return response()->json(['error' => 'Server not found'], 404); + } + + return response()->json($server); + } + + /** + * List tools for a specific server. + * + * GET /api/v1/mcp/servers/{id}/tools + */ + public function tools(Request $request, string $id): JsonResponse + { + $server = $this->loadServerFull($id); + + if (! $server) { + return response()->json(['error' => 'Server not found'], 404); + } + + return response()->json([ + 'server' => $id, + 'tools' => $server['tools'] ?? [], + 'count' => count($server['tools'] ?? []), + ]); + } + + /** + * Execute a tool on an MCP server. + * + * POST /api/v1/mcp/tools/call + */ + public function callTool(Request $request): JsonResponse + { + $validated = $request->validate([ + 'server' => 'required|string|max:64', + 'tool' => 'required|string|max:128', + 'arguments' => 'nullable|array', + ]); + + $server = $this->loadServerFull($validated['server']); + if (! $server) { + return response()->json(['error' => 'Server not found'], 404); + } + + // Verify tool exists + $toolDef = collect($server['tools'] ?? [])->firstWhere('name', $validated['tool']); + if (! $toolDef) { + return response()->json(['error' => 'Tool not found'], 404); + } + + // Validate arguments against tool's input schema + $validationErrors = $this->validateToolArguments($toolDef, $validated['arguments'] ?? []); + if (! empty($validationErrors)) { + return response()->json([ + 'error' => 'validation_failed', + 'message' => 'Tool arguments do not match input schema', + 'validation_errors' => $validationErrors, + ], 422); + } + + // Get API key for logging + $apiKey = $request->attributes->get('api_key'); + $workspace = $apiKey?->workspace; + + $startTime = microtime(true); + + try { + // Execute the tool via in-process registry or artisan fallback + $result = $this->executeTool( + $validated['tool'], + $validated['arguments'] ?? [], + $apiKey + ); + + $durationMs = (int) ((microtime(true) - $startTime) * 1000); + + // Log the call + $this->logToolCall($apiKey, $validated, $result, $durationMs, true); + + // Record quota usage + $this->recordQuotaUsage($workspace); + + // Dispatch webhooks + $this->dispatchWebhook($apiKey, $validated, true, $durationMs); + + $response = [ + 'success' => true, + 'server' => $validated['server'], + 'tool' => $validated['tool'], + 'result' => $result, + 'duration_ms' => $durationMs, + ]; + + // Log full request for debugging/replay + $this->logApiRequest($request, $validated, 200, $response, $durationMs, $apiKey); + + return response()->json($response); + } catch (\Throwable $e) { + $durationMs = (int) ((microtime(true) - $startTime) * 1000); + + $this->logToolCall($apiKey, $validated, null, $durationMs, false, $e->getMessage()); + + // Dispatch webhooks (even on failure) + $this->dispatchWebhook($apiKey, $validated, false, $durationMs, $e->getMessage()); + + $response = [ + 'success' => false, + 'error' => $e->getMessage(), + 'server' => $validated['server'], + 'tool' => $validated['tool'], + ]; + + // Log full request for debugging/replay + $this->logApiRequest($request, $validated, 500, $response, $durationMs, $apiKey, $e->getMessage()); + + return response()->json($response, 500); + } + } + + /** + * Read a resource from an MCP server. + * + * GET /api/v1/mcp/resources/{uri} + * + * NOTE: Resource reading is not yet implemented. Returns 501 Not Implemented. + */ + public function resource(Request $request, string $uri): JsonResponse + { + // Parse URI format: server://resource/path + if (! preg_match('/^([a-z0-9-]+):\/\/(.+)$/', $uri, $matches)) { + return response()->json(['error' => 'Invalid resource URI format'], 400); + } + + $serverId = $matches[1]; + + $server = $this->loadServerFull($serverId); + if (! $server) { + return response()->json(['error' => 'Server not found'], 404); + } + + // Resource reading not yet implemented + return response()->json([ + 'error' => 'not_implemented', + 'message' => 'MCP resource reading is not yet implemented. Use tool calls instead.', + 'uri' => $uri, + ], 501); + } + + /** + * Execute a tool via the in-process AgentToolRegistry. + * + * Tools are registered at boot via the McpToolsRegistering lifecycle event. + * This avoids the overhead of spawning artisan sub-processes for each call. + * + * @throws \RuntimeException If tool not found in registry + */ + protected function executeTool(string $tool, array $arguments, ?ApiKey $apiKey): mixed + { + $registryClass = \Core\Mod\Agentic\Services\AgentToolRegistry::class; + + if (! app()->bound($registryClass)) { + throw new \RuntimeException("AgentToolRegistry not available — is the agentic module installed?"); + } + + $registry = app($registryClass); + + if (! $registry->has($tool)) { + throw new \RuntimeException("Tool not found: {$tool}"); + } + + $context = []; + + if ($apiKey?->workspace_id) { + $context['workspace_id'] = $apiKey->workspace_id; + } + + return $registry->execute( + name: $tool, + args: $arguments, + context: $context, + apiKey: $apiKey, + validateDependencies: false + ); + } + + /** + * Log full API request for debugging and replay. + */ + protected function logApiRequest( + Request $request, + array $validated, + int $status, + array $response, + int $durationMs, + ?ApiKey $apiKey, + ?string $error = null + ): void { + try { + McpApiRequest::log( + method: $request->method(), + path: '/tools/call', + requestBody: $validated, + responseStatus: $status, + responseBody: $response, + durationMs: $durationMs, + workspaceId: $apiKey?->workspace_id, + apiKeyId: $apiKey?->id, + serverId: $validated['server'], + toolName: $validated['tool'], + errorMessage: $error, + ipAddress: $request->ip(), + headers: $request->headers->all() + ); + } catch (\Throwable $e) { + // Don't let logging failures affect API response + report($e); + } + } + + /** + * Dispatch webhook for tool execution. + */ + protected function dispatchWebhook( + ?ApiKey $apiKey, + array $request, + bool $success, + int $durationMs, + ?string $error = null + ): void { + if (! $apiKey?->workspace_id) { + return; + } + + try { + $dispatcher = new McpWebhookDispatcher; + $dispatcher->dispatchToolExecuted( + workspaceId: $apiKey->workspace_id, + serverId: $request['server'], + toolName: $request['tool'], + arguments: $request['arguments'] ?? [], + success: $success, + durationMs: $durationMs, + errorMessage: $error + ); + } catch (\Throwable $e) { + // Don't let webhook failures affect API response + report($e); + } + } + + /** + * Log tool call for analytics. + */ + protected function logToolCall( + ?ApiKey $apiKey, + array $request, + mixed $result, + int $durationMs, + bool $success, + ?string $error = null + ): void { + try { + McpToolCall::log( + serverId: $request['server'], + toolName: $request['tool'], + params: $request['arguments'] ?? [], + success: $success, + durationMs: $durationMs, + errorMessage: $error, + workspaceId: $apiKey?->workspace_id + ); + } catch (\Throwable $e) { + // Don't let logging failures affect API response + report($e); + } + } + + /** + * Validate tool arguments against the tool's input schema. + * + * @return array Validation errors (empty if valid) + */ + protected function validateToolArguments(array $toolDef, array $arguments): array + { + $inputSchema = $toolDef['inputSchema'] ?? null; + + // No schema = no validation + if (! $inputSchema || ! is_array($inputSchema)) { + return []; + } + + $errors = []; + $properties = $inputSchema['properties'] ?? []; + $required = $inputSchema['required'] ?? []; + + // Check required properties + foreach ($required as $requiredProp) { + if (! array_key_exists($requiredProp, $arguments)) { + $errors[] = "Missing required argument: {$requiredProp}"; + } + } + + // Type validation for provided arguments + foreach ($arguments as $key => $value) { + // Check if argument is defined in schema + if (! isset($properties[$key])) { + // Allow extra properties unless additionalProperties is false + if (isset($inputSchema['additionalProperties']) && $inputSchema['additionalProperties'] === false) { + $errors[] = "Unknown argument: {$key}"; + } + + continue; + } + + $propSchema = $properties[$key]; + $expectedType = $propSchema['type'] ?? null; + + if ($expectedType && ! $this->validateType($value, $expectedType)) { + $errors[] = "Argument '{$key}' must be of type {$expectedType}"; + } + + // Validate enum values + if (isset($propSchema['enum']) && ! in_array($value, $propSchema['enum'], true)) { + $allowedValues = implode(', ', $propSchema['enum']); + $errors[] = "Argument '{$key}' must be one of: {$allowedValues}"; + } + + // Validate string constraints + if ($expectedType === 'string' && is_string($value)) { + if (isset($propSchema['minLength']) && strlen($value) < $propSchema['minLength']) { + $errors[] = "Argument '{$key}' must be at least {$propSchema['minLength']} characters"; + } + if (isset($propSchema['maxLength']) && strlen($value) > $propSchema['maxLength']) { + $errors[] = "Argument '{$key}' must be at most {$propSchema['maxLength']} characters"; + } + } + + // Validate numeric constraints + if (in_array($expectedType, ['integer', 'number']) && is_numeric($value)) { + if (isset($propSchema['minimum']) && $value < $propSchema['minimum']) { + $errors[] = "Argument '{$key}' must be at least {$propSchema['minimum']}"; + } + if (isset($propSchema['maximum']) && $value > $propSchema['maximum']) { + $errors[] = "Argument '{$key}' must be at most {$propSchema['maximum']}"; + } + } + } + + return $errors; + } + + /** + * Validate a value against a JSON Schema type. + */ + protected function validateType(mixed $value, string $type): bool + { + return match ($type) { + 'string' => is_string($value), + 'integer' => is_int($value) || (is_numeric($value) && floor((float) $value) == $value), + 'number' => is_numeric($value), + 'boolean' => is_bool($value), + 'array' => is_array($value) && array_is_list($value), + 'object' => is_array($value) && ! array_is_list($value), + 'null' => is_null($value), + default => true, // Unknown types pass validation + }; + } + + // Registry loading methods (shared with McpRegistryController) + + protected function loadRegistry(): array + { + return Cache::remember('mcp:registry', 600, function () { + $path = resource_path('mcp/registry.yaml'); + + return file_exists($path) ? Yaml::parseFile($path) : ['servers' => []]; + }); + } + + protected function loadServerFull(string $id): ?array + { + return Cache::remember("mcp:server:{$id}", 600, function () use ($id) { + $path = resource_path("mcp/servers/{$id}.yaml"); + + return file_exists($path) ? Yaml::parseFile($path) : null; + }); + } + + protected function loadServerSummary(string $id): ?array + { + $server = $this->loadServerFull($id); + if (! $server) { + return null; + } + + return [ + 'id' => $server['id'], + 'name' => $server['name'], + 'tagline' => $server['tagline'] ?? '', + 'status' => $server['status'] ?? 'available', + 'tool_count' => count($server['tools'] ?? []), + 'resource_count' => count($server['resources'] ?? []), + ]; + } + + /** + * Record quota usage for successful tool calls. + */ + protected function recordQuotaUsage($workspace): void + { + if (! $workspace) { + return; + } + + try { + $quotaService = app(McpQuotaService::class); + $quotaService->recordUsage($workspace, toolCalls: 1); + } catch (\Throwable $e) { + // Don't let quota recording failures affect API response + report($e); + } + } +} diff --git a/src/php/src/Mcp/DTO/ToolStats.php b/src/php/src/Mcp/DTO/ToolStats.php new file mode 100644 index 0000000..f64253b --- /dev/null +++ b/src/php/src/Mcp/DTO/ToolStats.php @@ -0,0 +1,95 @@ + $this->toolName, + 'total_calls' => $this->totalCalls, + 'error_count' => $this->errorCount, + 'error_rate' => $this->errorRate, + 'avg_duration_ms' => $this->avgDurationMs, + 'min_duration_ms' => $this->minDurationMs, + 'max_duration_ms' => $this->maxDurationMs, + ]; + } + + /** + * Get success rate as percentage. + */ + public function getSuccessRate(): float + { + return 100.0 - $this->errorRate; + } + + /** + * Get average duration formatted for display. + */ + public function getAvgDurationForHumans(): string + { + if ($this->avgDurationMs === 0.0) { + return '-'; + } + + if ($this->avgDurationMs < 1000) { + return round($this->avgDurationMs).'ms'; + } + + return round($this->avgDurationMs / 1000, 2).'s'; + } + + /** + * Check if the tool has a high error rate (above threshold). + */ + public function hasHighErrorRate(float $threshold = 10.0): bool + { + return $this->errorRate > $threshold; + } + + /** + * Check if the tool has slow response times (above threshold in ms). + */ + public function isSlowResponding(int $thresholdMs = 5000): bool + { + return $this->avgDurationMs > $thresholdMs; + } +} diff --git a/src/php/src/Mcp/Database/Seeders/SensitiveToolSeeder.php b/src/php/src/Mcp/Database/Seeders/SensitiveToolSeeder.php new file mode 100644 index 0000000..525af11 --- /dev/null +++ b/src/php/src/Mcp/Database/Seeders/SensitiveToolSeeder.php @@ -0,0 +1,130 @@ + 'query_database', + 'reason' => 'Direct database access - may expose sensitive data', + 'redact_fields' => ['password', 'email', 'phone', 'address', 'ssn'], + 'require_explicit_consent' => false, + ], + + // User management + [ + 'tool_name' => 'create_user', + 'reason' => 'User account creation - security sensitive', + 'redact_fields' => ['password', 'secret'], + 'require_explicit_consent' => true, + ], + [ + 'tool_name' => 'update_user', + 'reason' => 'User account modification - security sensitive', + 'redact_fields' => ['password', 'secret', 'email'], + 'require_explicit_consent' => true, + ], + [ + 'tool_name' => 'delete_user', + 'reason' => 'User account deletion - irreversible operation', + 'redact_fields' => [], + 'require_explicit_consent' => true, + ], + + // API key management + [ + 'tool_name' => 'create_api_key', + 'reason' => 'API key creation - security credential', + 'redact_fields' => ['key', 'secret', 'token'], + 'require_explicit_consent' => true, + ], + [ + 'tool_name' => 'revoke_api_key', + 'reason' => 'API key revocation - access control', + 'redact_fields' => [], + 'require_explicit_consent' => true, + ], + + // Billing and financial + [ + 'tool_name' => 'upgrade_plan', + 'reason' => 'Plan upgrade - financial impact', + 'redact_fields' => ['card_number', 'cvv', 'payment_method'], + 'require_explicit_consent' => true, + ], + [ + 'tool_name' => 'create_coupon', + 'reason' => 'Coupon creation - financial impact', + 'redact_fields' => [], + 'require_explicit_consent' => false, + ], + [ + 'tool_name' => 'process_refund', + 'reason' => 'Refund processing - financial transaction', + 'redact_fields' => ['card_number', 'bank_account'], + 'require_explicit_consent' => true, + ], + + // Content operations + [ + 'tool_name' => 'delete_content', + 'reason' => 'Content deletion - irreversible data loss', + 'redact_fields' => [], + 'require_explicit_consent' => true, + ], + [ + 'tool_name' => 'publish_content', + 'reason' => 'Public content publishing - visibility impact', + 'redact_fields' => [], + 'require_explicit_consent' => false, + ], + + // System configuration + [ + 'tool_name' => 'update_config', + 'reason' => 'System configuration change - affects application behaviour', + 'redact_fields' => ['api_key', 'secret', 'password'], + 'require_explicit_consent' => true, + ], + + // Webhook management + [ + 'tool_name' => 'create_webhook', + 'reason' => 'External webhook creation - data exfiltration risk', + 'redact_fields' => ['secret', 'token'], + 'require_explicit_consent' => true, + ], + ]; + + foreach ($sensitiveTools as $tool) { + McpSensitiveTool::updateOrCreate( + ['tool_name' => $tool['tool_name']], + [ + 'reason' => $tool['reason'], + 'redact_fields' => $tool['redact_fields'], + 'require_explicit_consent' => $tool['require_explicit_consent'], + ] + ); + } + + $this->command->info('Registered '.count($sensitiveTools).' sensitive tool definitions.'); + } +} diff --git a/src/php/src/Mcp/Dependencies/DependencyType.php b/src/php/src/Mcp/Dependencies/DependencyType.php new file mode 100644 index 0000000..8a7040a --- /dev/null +++ b/src/php/src/Mcp/Dependencies/DependencyType.php @@ -0,0 +1,57 @@ + 'Tool must be called first', + self::SESSION_STATE => 'Session state required', + self::CONTEXT_EXISTS => 'Context value required', + self::ENTITY_EXISTS => 'Entity must exist', + self::CUSTOM => 'Custom condition', + }; + } +} diff --git a/src/php/src/Mcp/Dependencies/HasDependencies.php b/src/php/src/Mcp/Dependencies/HasDependencies.php new file mode 100644 index 0000000..537b3c6 --- /dev/null +++ b/src/php/src/Mcp/Dependencies/HasDependencies.php @@ -0,0 +1,21 @@ + + */ + public function dependencies(): array; +} diff --git a/src/php/src/Mcp/Dependencies/ToolDependency.php b/src/php/src/Mcp/Dependencies/ToolDependency.php new file mode 100644 index 0000000..bf657e4 --- /dev/null +++ b/src/php/src/Mcp/Dependencies/ToolDependency.php @@ -0,0 +1,134 @@ +type, + key: $this->key, + description: $this->description, + optional: true, + metadata: $this->metadata, + ); + } + + /** + * Convert to array representation. + */ + public function toArray(): array + { + return [ + 'type' => $this->type->value, + 'key' => $this->key, + 'description' => $this->description, + 'optional' => $this->optional, + 'metadata' => $this->metadata, + ]; + } + + /** + * Create from array representation. + */ + public static function fromArray(array $data): self + { + return new self( + type: DependencyType::from($data['type']), + key: $data['key'], + description: $data['description'] ?? null, + optional: $data['optional'] ?? false, + metadata: $data['metadata'] ?? [], + ); + } +} diff --git a/src/php/src/Mcp/Events/ToolExecuted.php b/src/php/src/Mcp/Events/ToolExecuted.php new file mode 100644 index 0000000..6479143 --- /dev/null +++ b/src/php/src/Mcp/Events/ToolExecuted.php @@ -0,0 +1,114 @@ +toolName; + } + + /** + * Get the duration in milliseconds. + */ + public function getDurationMs(): int + { + return $this->durationMs; + } + + /** + * Check if the execution was successful. + */ + public function wasSuccessful(): bool + { + return $this->success; + } + + /** + * Get the workspace ID. + */ + public function getWorkspaceId(): ?string + { + return $this->workspaceId; + } + + /** + * Get the session ID. + */ + public function getSessionId(): ?string + { + return $this->sessionId; + } +} diff --git a/src/php/src/Mcp/Exceptions/CircuitOpenException.php b/src/php/src/Mcp/Exceptions/CircuitOpenException.php new file mode 100644 index 0000000..f092600 --- /dev/null +++ b/src/php/src/Mcp/Exceptions/CircuitOpenException.php @@ -0,0 +1,27 @@ + $missingDependencies List of unmet dependencies + * @param array $suggestedOrder Suggested tools to call first + */ + public function __construct( + public readonly string $toolName, + public readonly array $missingDependencies, + public readonly array $suggestedOrder = [], + ) { + $message = $this->buildMessage(); + parent::__construct($message); + } + + /** + * Build a user-friendly error message. + */ + protected function buildMessage(): string + { + $missing = array_map( + fn (ToolDependency $dep) => "- {$dep->description}", + $this->missingDependencies + ); + + $message = "Cannot execute '{$this->toolName}': prerequisites not met.\n\n"; + $message .= "Missing:\n".implode("\n", $missing); + + if (! empty($this->suggestedOrder)) { + $message .= "\n\nSuggested order:\n"; + foreach ($this->suggestedOrder as $i => $tool) { + $message .= sprintf(" %d. %s\n", $i + 1, $tool); + } + } + + return $message; + } + + /** + * Get a structured error response for API output. + */ + public function toApiResponse(): array + { + return [ + 'error' => 'dependency_not_met', + 'message' => "Cannot execute '{$this->toolName}': prerequisites not met", + 'tool' => $this->toolName, + 'missing_dependencies' => array_map( + fn (ToolDependency $dep) => $dep->toArray(), + $this->missingDependencies + ), + 'suggested_order' => $this->suggestedOrder, + 'help' => $this->getHelpText(), + ]; + } + + /** + * Get help text explaining how to resolve the issue. + */ + protected function getHelpText(): string + { + if (empty($this->suggestedOrder)) { + return 'Ensure all required dependencies are satisfied before calling this tool.'; + } + + return sprintf( + 'Call these tools in order before attempting %s: %s', + $this->toolName, + implode(' -> ', $this->suggestedOrder) + ); + } +} diff --git a/src/php/src/Mcp/Exceptions/MissingWorkspaceContextException.php b/src/php/src/Mcp/Exceptions/MissingWorkspaceContextException.php new file mode 100644 index 0000000..97ce03e --- /dev/null +++ b/src/php/src/Mcp/Exceptions/MissingWorkspaceContextException.php @@ -0,0 +1,45 @@ + [ + 'title' => 'API Keys', + 'description' => 'Create API keys to authenticate HTTP requests to MCP servers.', + 'empty' => [ + 'title' => 'No API Keys Yet', + 'description' => 'Create an API key to start making authenticated requests to MCP servers over HTTP.', + ], + 'actions' => [ + 'create' => 'Create Key', + 'create_first' => 'Create Your First Key', + 'revoke' => 'Revoke', + ], + 'table' => [ + 'name' => 'Name', + 'key' => 'Key', + 'scopes' => 'Scopes', + 'last_used' => 'Last Used', + 'expires' => 'Expires', + 'actions' => 'Actions', + ], + 'status' => [ + 'expired' => 'Expired', + 'never' => 'Never', + ], + 'confirm_revoke' => 'Are you sure you want to revoke this API key? This cannot be undone.', + + // Authentication section + 'auth' => [ + 'title' => 'Authentication', + 'description' => 'Include your API key in HTTP requests using one of these methods:', + 'header_recommended' => 'Authorization Header (recommended)', + 'header_api_key' => 'X-API-Key Header', + ], + + // Example section + 'example' => [ + 'title' => 'Example Request', + 'description' => 'Call an MCP tool via HTTP POST:', + ], + + // Create modal + 'create_modal' => [ + 'title' => 'Create API Key', + 'name_label' => 'Key Name', + 'name_placeholder' => 'e.g., Production Server, Claude Agent', + 'permissions_label' => 'Permissions', + 'permission_read' => 'Read - Query tools and resources', + 'permission_write' => 'Write - Create and update data', + 'permission_delete' => 'Delete - Remove data', + 'expiry_label' => 'Expiration', + 'expiry_never' => 'Never expires', + 'expiry_30' => '30 days', + 'expiry_90' => '90 days', + 'expiry_1year' => '1 year', + 'cancel' => 'Cancel', + 'create' => 'Create Key', + ], + + // New key modal + 'new_key_modal' => [ + 'title' => 'API Key Created', + 'warning' => 'Copy this key now.', + 'warning_detail' => "You won't be able to see it again.", + 'done' => 'Done', + ], + ], + + // Request Log + 'logs' => [ + 'title' => 'Request Log', + 'description' => 'View API requests and generate curl commands to replay them.', + 'filters' => [ + 'server' => 'Server', + 'status' => 'Status', + 'all_servers' => 'All servers', + 'all' => 'All', + 'success' => 'Success', + 'failed' => 'Failed', + ], + 'empty' => 'No requests found.', + 'detail' => [ + 'title' => 'Request Detail', + 'status' => 'Status', + 'request' => 'Request', + 'response' => 'Response', + 'error' => 'Error', + 'replay_command' => 'Replay Command', + 'copy' => 'Copy', + 'copied' => 'Copied', + 'metadata' => [ + 'request_id' => 'Request ID', + 'duration' => 'Duration', + 'ip' => 'IP', + 'time' => 'Time', + ], + ], + 'empty_detail' => 'Select a request to view details and generate replay commands.', + 'status_ok' => 'OK', + 'status_error' => 'Error', + ], + + // Playground + 'playground' => [ + 'title' => 'Playground', + 'description' => 'Test MCP tools interactively and execute requests live.', + + // Authentication section + 'auth' => [ + 'title' => 'Authentication', + 'api_key_label' => 'API Key', + 'api_key_placeholder' => 'hk_xxxxxxxx_xxxxxxxxxxxx...', + 'api_key_description' => 'Paste your API key to execute requests live', + 'validate' => 'Validate Key', + 'status' => [ + 'valid' => 'Valid', + 'invalid' => 'Invalid key', + 'expired' => 'Expired', + 'empty' => 'Enter a key to validate', + ], + 'key_info' => [ + 'name' => 'Name', + 'workspace' => 'Workspace', + 'scopes' => 'Scopes', + 'last_used' => 'Last used', + ], + 'sign_in_prompt' => 'Sign in', + 'sign_in_description' => 'to create API keys, or paste an existing key above.', + ], + + // Tool selection section + 'tools' => [ + 'title' => 'Select Tool', + 'server_label' => 'Server', + 'server_placeholder' => 'Choose a server...', + 'tool_label' => 'Tool', + 'tool_placeholder' => 'Choose a tool...', + 'arguments' => 'Arguments', + 'no_arguments' => 'This tool has no arguments.', + 'execute' => 'Execute Request', + 'generate' => 'Generate Request', + 'executing' => 'Executing...', + ], + + // Response section + 'response' => [ + 'title' => 'Response', + 'copy' => 'Copy', + 'copied' => 'Copied', + 'empty' => 'Select a server and tool to get started.', + ], + + // API Reference section + 'reference' => [ + 'title' => 'API Reference', + 'endpoint' => 'Endpoint', + 'method' => 'Method', + 'auth' => 'Auth', + 'content_type' => 'Content-Type', + 'manage_keys' => 'Manage API Keys', + ], + ], + + // Common + 'common' => [ + 'na' => 'N/A', + ], +]; diff --git a/src/php/src/Mcp/Listeners/RecordToolExecution.php b/src/php/src/Mcp/Listeners/RecordToolExecution.php new file mode 100644 index 0000000..a7cef21 --- /dev/null +++ b/src/php/src/Mcp/Listeners/RecordToolExecution.php @@ -0,0 +1,164 @@ +getToolName($event); + $durationMs = $this->getDuration($event); + $success = $this->wasSuccessful($event); + $workspaceId = $this->getWorkspaceId($event); + $sessionId = $this->getSessionId($event); + + if ($toolName === null || $durationMs === null) { + return; + } + + $this->analyticsService->recordExecution( + tool: $toolName, + durationMs: $durationMs, + success: $success, + workspaceId: $workspaceId, + sessionId: $sessionId + ); + } + + /** + * Extract tool name from the event. + */ + protected function getToolName(object $event): ?string + { + // Support multiple event structures + if (property_exists($event, 'toolName')) { + return $event->toolName; + } + + if (property_exists($event, 'tool_name')) { + return $event->tool_name; + } + + if (property_exists($event, 'tool')) { + return is_string($event->tool) ? $event->tool : $event->tool->getName(); + } + + if (method_exists($event, 'getToolName')) { + return $event->getToolName(); + } + + return null; + } + + /** + * Extract duration from the event. + */ + protected function getDuration(object $event): ?int + { + if (property_exists($event, 'durationMs')) { + return (int) $event->durationMs; + } + + if (property_exists($event, 'duration_ms')) { + return (int) $event->duration_ms; + } + + if (property_exists($event, 'duration')) { + return (int) $event->duration; + } + + if (method_exists($event, 'getDurationMs')) { + return $event->getDurationMs(); + } + + return null; + } + + /** + * Determine if the execution was successful. + */ + protected function wasSuccessful(object $event): bool + { + if (property_exists($event, 'success')) { + return (bool) $event->success; + } + + if (property_exists($event, 'error')) { + return $event->error === null; + } + + if (property_exists($event, 'exception')) { + return $event->exception === null; + } + + if (method_exists($event, 'wasSuccessful')) { + return $event->wasSuccessful(); + } + + return true; // Assume success if no indicator + } + + /** + * Extract workspace ID from the event. + */ + protected function getWorkspaceId(object $event): ?string + { + if (property_exists($event, 'workspaceId')) { + return $event->workspaceId; + } + + if (property_exists($event, 'workspace_id')) { + return $event->workspace_id; + } + + if (method_exists($event, 'getWorkspaceId')) { + return $event->getWorkspaceId(); + } + + return null; + } + + /** + * Extract session ID from the event. + */ + protected function getSessionId(object $event): ?string + { + if (property_exists($event, 'sessionId')) { + return $event->sessionId; + } + + if (property_exists($event, 'session_id')) { + return $event->session_id; + } + + if (method_exists($event, 'getSessionId')) { + return $event->getSessionId(); + } + + return null; + } +} diff --git a/src/php/src/Mcp/Middleware/CheckMcpQuota.php b/src/php/src/Mcp/Middleware/CheckMcpQuota.php new file mode 100644 index 0000000..b94c73d --- /dev/null +++ b/src/php/src/Mcp/Middleware/CheckMcpQuota.php @@ -0,0 +1,89 @@ +attributes->get('workspace'); + + // No workspace context = skip quota check (other middleware handles auth) + if (! $workspace) { + return $next($request); + } + + // Check quota + $quotaCheck = $this->quotaService->checkQuotaDetailed($workspace); + + if (! $quotaCheck['allowed']) { + return $this->quotaExceededResponse($quotaCheck, $workspace); + } + + // Process request + $response = $next($request); + + // Add quota headers to response + $this->addQuotaHeaders($response, $workspace); + + return $response; + } + + /** + * Build quota exceeded error response. + */ + protected function quotaExceededResponse(array $quotaCheck, $workspace): Response + { + $headers = $this->quotaService->getQuotaHeaders($workspace); + + $errorData = [ + 'error' => 'quota_exceeded', + 'message' => $quotaCheck['reason'] ?? 'Monthly quota exceeded', + 'quota' => [ + 'tool_calls' => [ + 'used' => $quotaCheck['tool_calls']['used'] ?? 0, + 'limit' => $quotaCheck['tool_calls']['limit'], + 'unlimited' => $quotaCheck['tool_calls']['unlimited'] ?? false, + ], + 'tokens' => [ + 'used' => $quotaCheck['tokens']['used'] ?? 0, + 'limit' => $quotaCheck['tokens']['limit'], + 'unlimited' => $quotaCheck['tokens']['unlimited'] ?? false, + ], + 'resets_at' => now()->endOfMonth()->toIso8601String(), + ], + 'upgrade_hint' => 'Upgrade your plan to increase MCP quota limits.', + ]; + + return response()->json($errorData, 429, $headers); + } + + /** + * Add quota headers to response. + */ + protected function addQuotaHeaders(Response $response, $workspace): void + { + $headers = $this->quotaService->getQuotaHeaders($workspace); + + foreach ($headers as $name => $value) { + $response->headers->set($name, $value); + } + } +} diff --git a/src/php/src/Mcp/Middleware/McpApiKeyAuth.php b/src/php/src/Mcp/Middleware/McpApiKeyAuth.php new file mode 100644 index 0000000..18b4945 --- /dev/null +++ b/src/php/src/Mcp/Middleware/McpApiKeyAuth.php @@ -0,0 +1,85 @@ +extractKey($request); + + if (! $key) { + return response()->json([ + 'error' => 'Missing API key', + 'hint' => 'Provide via Authorization: Bearer or X-API-Key header', + ], 401); + } + + $apiKey = ApiKey::findByPlainKey($key); + + if (! $apiKey) { + return response()->json([ + 'error' => 'Invalid API key', + ], 401); + } + + if ($apiKey->isExpired()) { + return response()->json([ + 'error' => 'API key has expired', + ], 401); + } + + // Check server-level access for tool calls + if ($request->is('*/tools/call') && $request->isMethod('POST')) { + $serverId = $request->input('server'); + if ($serverId && ! $apiKey->hasServerAccess($serverId)) { + return response()->json([ + 'error' => 'Access denied to server: '.$serverId, + 'allowed_servers' => $apiKey->getAllowedServers(), + ], 403); + } + } + + // Record usage + $apiKey->recordUsage(); + + // Attach to request for controller access + $request->attributes->set('api_key', $apiKey); + $request->attributes->set('workspace', $apiKey->workspace); + + return $next($request); + } + + protected function extractKey(Request $request): ?string + { + // Try Authorization: Bearer + $authHeader = $request->header('Authorization'); + if ($authHeader && str_starts_with($authHeader, 'Bearer ')) { + return substr($authHeader, 7); + } + + // Try X-API-Key + $apiKeyHeader = $request->header('X-API-Key'); + if ($apiKeyHeader) { + return $apiKeyHeader; + } + + return null; + } +} diff --git a/src/php/src/Mcp/Middleware/McpAuthenticate.php b/src/php/src/Mcp/Middleware/McpAuthenticate.php new file mode 100644 index 0000000..82939af --- /dev/null +++ b/src/php/src/Mcp/Middleware/McpAuthenticate.php @@ -0,0 +1,102 @@ +authenticateByApiKey($request); + + // Fall back to session auth + if (! $workspace && $request->user()) { + $user = $request->user(); + if (method_exists($user, 'defaultHostWorkspace')) { + $workspace = $user->defaultHostWorkspace(); + } + } + + // Store workspace for downstream use + if ($workspace) { + $request->attributes->set('mcp_workspace', $workspace); + + // Check MCP access entitlement + $result = $this->entitlementService->can($workspace, 'mcp.access'); + $request->attributes->set('mcp_entitlement', $result); + } + + // For 'required' level, must have workspace + if ($level === 'required' && ! $workspace) { + return $this->unauthenticatedResponse($request); + } + + return $next($request); + } + + /** + * Authenticate using API key from header or query. + */ + protected function authenticateByApiKey(Request $request): ?Workspace + { + $apiKey = $request->header('X-API-Key') + ?? $request->header('Authorization') + ?? $request->query('api_key'); + + if (! $apiKey) { + return null; + } + + // Strip 'Bearer ' prefix if present + if (str_starts_with($apiKey, 'Bearer ')) { + $apiKey = substr($apiKey, 7); + } + + // Look up workspace by API key + return Workspace::whereHas('apiKeys', function ($query) use ($apiKey) { + $query->where('key', hash('sha256', $apiKey)) + ->where(function ($q) { + $q->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }); + })->first(); + } + + /** + * Return unauthenticated response. + */ + protected function unauthenticatedResponse(Request $request): Response + { + if ($request->expectsJson() || $request->is('api/*')) { + return response()->json([ + 'error' => 'unauthenticated', + 'message' => 'Authentication required. Provide an API key or sign in.', + ], 401); + } + + return redirect()->guest(route('login')); + } +} diff --git a/src/php/src/Mcp/Middleware/ValidateToolDependencies.php b/src/php/src/Mcp/Middleware/ValidateToolDependencies.php new file mode 100644 index 0000000..f4e7405 --- /dev/null +++ b/src/php/src/Mcp/Middleware/ValidateToolDependencies.php @@ -0,0 +1,146 @@ +isToolCallRequest($request)) { + return $next($request); + } + + $toolName = $this->extractToolName($request); + $sessionId = $this->extractSessionId($request); + $context = $this->extractContext($request); + $args = $this->extractArguments($request); + + if (! $toolName || ! $sessionId) { + return $next($request); + } + + try { + $this->dependencyService->validateDependencies($sessionId, $toolName, $context, $args); + } catch (MissingDependencyException $e) { + return $this->buildErrorResponse($e); + } + + // Record the tool call after successful execution + $response = $next($request); + + // Only record on success + if ($response instanceof JsonResponse && $this->isSuccessResponse($response)) { + $this->dependencyService->recordToolCall($sessionId, $toolName, $args); + } + + return $response; + } + + /** + * Check if this is a tool call request. + */ + protected function isToolCallRequest(Request $request): bool + { + return $request->is('*/tools/call') || $request->is('api/*/mcp/tools/call'); + } + + /** + * Extract the tool name from the request. + */ + protected function extractToolName(Request $request): ?string + { + return $request->input('tool') ?? $request->input('name'); + } + + /** + * Extract the session ID from the request. + */ + protected function extractSessionId(Request $request): ?string + { + // Try various locations where session ID might be + return $request->input('session_id') + ?? $request->input('arguments.session_id') + ?? $request->header('X-MCP-Session-ID') + ?? $request->attributes->get('session_id'); + } + + /** + * Extract context from the request. + */ + protected function extractContext(Request $request): array + { + $context = []; + + // Get API key context + $apiKey = $request->attributes->get('api_key'); + if ($apiKey) { + $context['workspace_id'] = $apiKey->workspace_id; + } + + // Get explicit context from request + $requestContext = $request->input('context', []); + if (is_array($requestContext)) { + $context = array_merge($context, $requestContext); + } + + // Get session ID + $sessionId = $this->extractSessionId($request); + if ($sessionId) { + $context['session_id'] = $sessionId; + } + + return $context; + } + + /** + * Extract tool arguments from the request. + */ + protected function extractArguments(Request $request): array + { + return $request->input('arguments', []) ?? []; + } + + /** + * Check if response indicates success. + */ + protected function isSuccessResponse(JsonResponse $response): bool + { + if ($response->getStatusCode() >= 400) { + return false; + } + + $data = $response->getData(true); + + return ($data['success'] ?? true) !== false; + } + + /** + * Build error response for missing dependencies. + */ + protected function buildErrorResponse(MissingDependencyException $e): JsonResponse + { + return response()->json($e->toApiResponse(), 422); + } +} diff --git a/src/php/src/Mcp/Middleware/ValidateWorkspaceContext.php b/src/php/src/Mcp/Middleware/ValidateWorkspaceContext.php new file mode 100644 index 0000000..5354712 --- /dev/null +++ b/src/php/src/Mcp/Middleware/ValidateWorkspaceContext.php @@ -0,0 +1,91 @@ +attributes->get('mcp_workspace'); + + if ($workspace) { + // Create workspace context and store it + $context = WorkspaceContext::fromWorkspace($workspace); + $request->attributes->set('mcp_workspace_context', $context); + + return $next($request); + } + + // Try to get workspace from API key + $apiKey = $request->attributes->get('api_key'); + if ($apiKey?->workspace_id) { + $context = new WorkspaceContext( + workspaceId: $apiKey->workspace_id, + workspace: $apiKey->workspace, + ); + $request->attributes->set('mcp_workspace_context', $context); + + return $next($request); + } + + // Try authenticated user's default workspace + $user = $request->user(); + if ($user && method_exists($user, 'defaultHostWorkspace')) { + $workspace = $user->defaultHostWorkspace(); + if ($workspace) { + $context = WorkspaceContext::fromWorkspace($workspace); + $request->attributes->set('mcp_workspace_context', $context); + + return $next($request); + } + } + + // If mode is 'required', reject the request + if ($mode === 'required') { + return $this->missingContextResponse($request); + } + + // Mode is 'optional', continue without context + return $next($request); + } + + /** + * Return response for missing workspace context. + */ + protected function missingContextResponse(Request $request): Response + { + $exception = new MissingWorkspaceContextException('MCP API'); + + if ($request->expectsJson() || $request->is('api/*')) { + return response()->json([ + 'error' => $exception->getErrorType(), + 'message' => $exception->getMessage(), + ], $exception->getStatusCode()); + } + + return response($exception->getMessage(), $exception->getStatusCode()); + } +} diff --git a/src/php/src/Mcp/Migrations/2026_01_07_004936_create_mcp_api_requests_table.php b/src/php/src/Mcp/Migrations/2026_01_07_004936_create_mcp_api_requests_table.php new file mode 100644 index 0000000..859ab2f --- /dev/null +++ b/src/php/src/Mcp/Migrations/2026_01_07_004936_create_mcp_api_requests_table.php @@ -0,0 +1,42 @@ +id(); + $table->string('request_id', 32)->unique(); + $table->foreignId('workspace_id')->nullable()->constrained('workspaces')->nullOnDelete(); + $table->foreignId('api_key_id')->nullable()->constrained('api_keys')->nullOnDelete(); + $table->string('method', 10); + $table->string('path', 255); + $table->json('headers')->nullable(); + $table->json('request_body')->nullable(); + $table->unsignedSmallInteger('response_status'); + $table->json('response_body')->nullable(); + $table->unsignedInteger('duration_ms')->default(0); + $table->string('server_id', 64)->nullable(); + $table->string('tool_name', 128)->nullable(); + $table->text('error_message')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->timestamps(); + + $table->index(['workspace_id', 'created_at']); + $table->index(['server_id', 'tool_name']); + $table->index('created_at'); + $table->index('response_status'); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('mcp_api_requests'); + } +}; diff --git a/src/php/src/Mcp/Migrations/2026_01_26_000001_create_mcp_tool_metrics_table.php b/src/php/src/Mcp/Migrations/2026_01_26_000001_create_mcp_tool_metrics_table.php new file mode 100644 index 0000000..4d40678 --- /dev/null +++ b/src/php/src/Mcp/Migrations/2026_01_26_000001_create_mcp_tool_metrics_table.php @@ -0,0 +1,52 @@ +id(); + $table->string('tool_name'); + $table->string('workspace_id')->nullable(); + $table->unsignedInteger('call_count')->default(0); + $table->unsignedInteger('error_count')->default(0); + $table->unsignedInteger('total_duration_ms')->default(0); + $table->unsignedInteger('min_duration_ms')->nullable(); + $table->unsignedInteger('max_duration_ms')->nullable(); + $table->date('date'); + $table->timestamps(); + + $table->unique(['tool_name', 'workspace_id', 'date']); + $table->index(['date', 'tool_name']); + $table->index('workspace_id'); + }); + } + + // Table for tracking tool combinations (tools used together in sessions) + if (! Schema::hasTable('mcp_tool_combinations')) { + Schema::create('mcp_tool_combinations', function (Blueprint $table) { + $table->id(); + $table->string('tool_a'); + $table->string('tool_b'); + $table->string('workspace_id')->nullable(); + $table->unsignedInteger('occurrence_count')->default(0); + $table->date('date'); + $table->timestamps(); + + $table->unique(['tool_a', 'tool_b', 'workspace_id', 'date']); + $table->index(['date', 'occurrence_count']); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('mcp_tool_combinations'); + Schema::dropIfExists('mcp_tool_metrics'); + } +}; diff --git a/src/php/src/Mcp/Migrations/2026_01_26_000002_create_mcp_usage_quotas_table.php b/src/php/src/Mcp/Migrations/2026_01_26_000002_create_mcp_usage_quotas_table.php new file mode 100644 index 0000000..bfd03b7 --- /dev/null +++ b/src/php/src/Mcp/Migrations/2026_01_26_000002_create_mcp_usage_quotas_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete(); + $table->string('month', 7); // YYYY-MM format + $table->unsignedBigInteger('tool_calls_count')->default(0); + $table->unsignedBigInteger('input_tokens')->default(0); + $table->unsignedBigInteger('output_tokens')->default(0); + $table->timestamps(); + + $table->unique(['workspace_id', 'month']); + $table->index('month'); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('mcp_usage_quotas'); + } +}; diff --git a/src/php/src/Mcp/Migrations/2026_01_26_000003_create_mcp_audit_logs_table.php b/src/php/src/Mcp/Migrations/2026_01_26_000003_create_mcp_audit_logs_table.php new file mode 100644 index 0000000..88ba5b4 --- /dev/null +++ b/src/php/src/Mcp/Migrations/2026_01_26_000003_create_mcp_audit_logs_table.php @@ -0,0 +1,82 @@ +id(); + + // Tool execution details + $table->string('server_id')->index(); + $table->string('tool_name')->index(); + $table->unsignedBigInteger('workspace_id')->nullable()->index(); + $table->string('session_id')->nullable()->index(); + + // Input/output (stored as JSON, may be redacted) + $table->json('input_params')->nullable(); + $table->json('output_summary')->nullable(); + $table->boolean('success')->default(true); + $table->unsignedInteger('duration_ms')->nullable(); + $table->string('error_code')->nullable(); + $table->text('error_message')->nullable(); + + // Actor information + $table->string('actor_type')->nullable(); // user, api_key, system + $table->unsignedBigInteger('actor_id')->nullable(); + $table->string('actor_ip', 45)->nullable(); // IPv4 or IPv6 + + // Sensitive tool flagging + $table->boolean('is_sensitive')->default(false)->index(); + $table->string('sensitivity_reason')->nullable(); + + // Hash chain for tamper detection + $table->string('previous_hash', 64)->nullable(); // SHA-256 of previous entry + $table->string('entry_hash', 64)->index(); // SHA-256 of this entry + + // Agent context + $table->string('agent_type')->nullable(); + $table->string('plan_slug')->nullable(); + + // Timestamps (immutable - no updated_at updates after creation) + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->nullable(); + + // Foreign key constraint + $table->foreign('workspace_id') + ->references('id') + ->on('workspaces') + ->nullOnDelete(); + + // Composite indexes for common queries + $table->index(['workspace_id', 'created_at']); + $table->index(['tool_name', 'created_at']); + $table->index(['is_sensitive', 'created_at']); + $table->index(['actor_type', 'actor_id']); + }); + } + + // Table for tracking sensitive tool definitions + if (! Schema::hasTable('mcp_sensitive_tools')) { + Schema::create('mcp_sensitive_tools', function (Blueprint $table) { + $table->id(); + $table->string('tool_name')->unique(); + $table->string('reason'); + $table->json('redact_fields')->nullable(); // Fields to redact in audit logs + $table->boolean('require_explicit_consent')->default(false); + $table->timestamps(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('mcp_sensitive_tools'); + Schema::dropIfExists('mcp_audit_logs'); + } +}; diff --git a/src/php/src/Mcp/Migrations/2026_01_26_000004_create_mcp_tool_versions_table.php b/src/php/src/Mcp/Migrations/2026_01_26_000004_create_mcp_tool_versions_table.php new file mode 100644 index 0000000..04efdcf --- /dev/null +++ b/src/php/src/Mcp/Migrations/2026_01_26_000004_create_mcp_tool_versions_table.php @@ -0,0 +1,43 @@ +id(); + $table->string('server_id', 64)->index(); + $table->string('tool_name', 128); + $table->string('version', 32); // semver: 1.0.0, 2.1.0-beta, etc. + $table->json('input_schema')->nullable(); + $table->json('output_schema')->nullable(); + $table->text('description')->nullable(); + $table->text('changelog')->nullable(); + $table->text('migration_notes')->nullable(); // guidance for upgrading from previous version + $table->boolean('is_latest')->default(false); + $table->timestamp('deprecated_at')->nullable(); + $table->timestamp('sunset_at')->nullable(); // after this date, version is blocked + $table->timestamps(); + + // Unique constraint: one version per tool per server + $table->unique(['server_id', 'tool_name', 'version'], 'mcp_tool_versions_unique'); + + // Index for finding latest versions + $table->index(['server_id', 'tool_name', 'is_latest'], 'mcp_tool_versions_latest'); + + // Index for finding deprecated/sunset versions + $table->index(['deprecated_at', 'sunset_at'], 'mcp_tool_versions_lifecycle'); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('mcp_tool_versions'); + } +}; diff --git a/src/php/src/Mcp/Models/McpApiRequest.php b/src/php/src/Mcp/Models/McpApiRequest.php new file mode 100644 index 0000000..b12c8ef --- /dev/null +++ b/src/php/src/Mcp/Models/McpApiRequest.php @@ -0,0 +1,176 @@ + 'array', + 'request_body' => 'array', + 'response_body' => 'array', + 'duration_ms' => 'integer', + 'response_status' => 'integer', + ]; + + /** + * Log an API request. + */ + public static function log( + string $method, + string $path, + array $requestBody, + int $responseStatus, + ?array $responseBody = null, + int $durationMs = 0, + ?int $workspaceId = null, + ?int $apiKeyId = null, + ?string $serverId = null, + ?string $toolName = null, + ?string $errorMessage = null, + ?string $ipAddress = null, + array $headers = [] + ): self { + // Sanitise headers - remove sensitive info + $sanitisedHeaders = collect($headers) + ->except(['authorization', 'x-api-key', 'cookie']) + ->toArray(); + + return static::create([ + 'request_id' => 'req_'.Str::random(20), + 'workspace_id' => $workspaceId, + 'api_key_id' => $apiKeyId, + 'method' => $method, + 'path' => $path, + 'headers' => $sanitisedHeaders, + 'request_body' => $requestBody, + 'response_status' => $responseStatus, + 'response_body' => $responseBody, + 'duration_ms' => $durationMs, + 'server_id' => $serverId, + 'tool_name' => $toolName, + 'error_message' => $errorMessage, + 'ip_address' => $ipAddress, + ]); + } + + /** + * Generate curl command to replay this request. + */ + public function toCurl(string $apiKey = 'YOUR_API_KEY'): string + { + $url = config('app.url').'/api/v1/mcp'.$this->path; + + $curl = "curl -X {$this->method} \"{$url}\""; + $curl .= " \\\n -H \"Authorization: Bearer {$apiKey}\""; + $curl .= " \\\n -H \"Content-Type: application/json\""; + + if (! empty($this->request_body)) { + $curl .= " \\\n -d '".json_encode($this->request_body)."'"; + } + + return $curl; + } + + /** + * Get duration formatted for humans. + */ + public function getDurationForHumansAttribute(): string + { + if ($this->duration_ms < 1000) { + return $this->duration_ms.'ms'; + } + + return round($this->duration_ms / 1000, 2).'s'; + } + + /** + * Check if request was successful. + */ + public function isSuccessful(): bool + { + return $this->response_status >= 200 && $this->response_status < 300; + } + + // Relationships + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + // Scopes + public function scopeForWorkspace(Builder $query, int $workspaceId): Builder + { + return $query->where('workspace_id', $workspaceId); + } + + public function scopeForServer(Builder $query, string $serverId): Builder + { + return $query->where('server_id', $serverId); + } + + public function scopeForTool(Builder $query, string $toolName): Builder + { + return $query->where('tool_name', $toolName); + } + + public function scopeFailed(Builder $query): Builder + { + return $query->where('response_status', '>=', 400); + } + + public function scopeSuccessful(Builder $query): Builder + { + return $query->whereBetween('response_status', [200, 299]); + } + + public function scopeRecent(Builder $query, int $hours = 24): Builder + { + return $query->where('created_at', '>=', now()->subHours($hours)); + } +} diff --git a/src/php/src/Mcp/Models/McpAuditLog.php b/src/php/src/Mcp/Models/McpAuditLog.php new file mode 100644 index 0000000..fe8913b --- /dev/null +++ b/src/php/src/Mcp/Models/McpAuditLog.php @@ -0,0 +1,383 @@ + 'array', + 'output_summary' => 'array', + 'success' => 'boolean', + 'duration_ms' => 'integer', + 'actor_id' => 'integer', + 'is_sensitive' => 'boolean', + 'created_at' => 'datetime', + ]; + + /** + * Boot the model. + */ + protected static function boot(): void + { + parent::boot(); + + // Prevent updates to maintain immutability + static::updating(function (self $model) { + // Allow only specific fields to be updated (for soft operations) + $allowedChanges = ['updated_at']; + $changes = array_keys($model->getDirty()); + + foreach ($changes as $change) { + if (! in_array($change, $allowedChanges)) { + throw new \RuntimeException( + 'Audit log entries are immutable. Cannot modify: '.$change + ); + } + } + }); + + // Prevent deletion + static::deleting(function () { + throw new \RuntimeException( + 'Audit log entries cannot be deleted. They are immutable for compliance purposes.' + ); + }); + } + + // ------------------------------------------------------------------------- + // Relationships + // ------------------------------------------------------------------------- + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + // ------------------------------------------------------------------------- + // Scopes + // ------------------------------------------------------------------------- + + /** + * Filter by server. + */ + public function scopeForServer(Builder $query, string $serverId): Builder + { + return $query->where('server_id', $serverId); + } + + /** + * Filter by tool name. + */ + public function scopeForTool(Builder $query, string $toolName): Builder + { + return $query->where('tool_name', $toolName); + } + + /** + * Filter by session. + */ + public function scopeForSession(Builder $query, string $sessionId): Builder + { + return $query->where('session_id', $sessionId); + } + + /** + * Filter successful calls. + */ + public function scopeSuccessful(Builder $query): Builder + { + return $query->where('success', true); + } + + /** + * Filter failed calls. + */ + public function scopeFailed(Builder $query): Builder + { + return $query->where('success', false); + } + + /** + * Filter sensitive tool calls. + */ + public function scopeSensitive(Builder $query): Builder + { + return $query->where('is_sensitive', true); + } + + /** + * Filter by actor type. + */ + public function scopeByActorType(Builder $query, string $actorType): Builder + { + return $query->where('actor_type', $actorType); + } + + /** + * Filter by actor. + */ + public function scopeByActor(Builder $query, string $actorType, int $actorId): Builder + { + return $query->where('actor_type', $actorType) + ->where('actor_id', $actorId); + } + + /** + * Filter by date range. + */ + public function scopeInDateRange(Builder $query, string|\DateTimeInterface $start, string|\DateTimeInterface $end): Builder + { + return $query->whereBetween('created_at', [$start, $end]); + } + + /** + * Filter for today. + */ + public function scopeToday(Builder $query): Builder + { + return $query->whereDate('created_at', today()); + } + + /** + * Filter for last N days. + */ + public function scopeLastDays(Builder $query, int $days): Builder + { + return $query->where('created_at', '>=', now()->subDays($days)); + } + + // ------------------------------------------------------------------------- + // Hash Chain Methods + // ------------------------------------------------------------------------- + + /** + * Compute the hash for this entry. + * Uses SHA-256 to create a deterministic hash of the entry data. + */ + public function computeHash(): string + { + $data = [ + 'id' => $this->id, + 'server_id' => $this->server_id, + 'tool_name' => $this->tool_name, + 'workspace_id' => $this->workspace_id, + 'session_id' => $this->session_id, + 'input_params' => $this->input_params, + 'output_summary' => $this->output_summary, + 'success' => $this->success, + 'duration_ms' => $this->duration_ms, + 'error_code' => $this->error_code, + 'actor_type' => $this->actor_type, + 'actor_id' => $this->actor_id, + 'actor_ip' => $this->actor_ip, + 'is_sensitive' => $this->is_sensitive, + 'previous_hash' => $this->previous_hash, + 'created_at' => $this->created_at?->toIso8601String(), + ]; + + return hash('sha256', json_encode($data, JSON_THROW_ON_ERROR)); + } + + /** + * Verify this entry's hash is valid. + */ + public function verifyHash(): bool + { + return $this->entry_hash === $this->computeHash(); + } + + /** + * Verify the chain link to the previous entry. + */ + public function verifyChainLink(): bool + { + if ($this->previous_hash === null) { + // First entry in chain - check there's no earlier entry + return ! static::where('id', '<', $this->id)->exists(); + } + + $previous = static::where('id', '<', $this->id) + ->orderByDesc('id') + ->first(); + + if (! $previous) { + return false; // Previous entry missing + } + + return $this->previous_hash === $previous->entry_hash; + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Get duration formatted for humans. + */ + public function getDurationForHumans(): string + { + if (! $this->duration_ms) { + return '-'; + } + + if ($this->duration_ms < 1000) { + return $this->duration_ms.'ms'; + } + + return round($this->duration_ms / 1000, 2).'s'; + } + + /** + * Get actor display name. + */ + public function getActorDisplay(): string + { + return match ($this->actor_type) { + self::ACTOR_USER => "User #{$this->actor_id}", + self::ACTOR_API_KEY => "API Key #{$this->actor_id}", + self::ACTOR_SYSTEM => 'System', + default => 'Unknown', + }; + } + + /** + * Check if this entry has integrity issues. + */ + public function hasIntegrityIssues(): bool + { + return ! $this->verifyHash() || ! $this->verifyChainLink(); + } + + /** + * Get integrity status. + */ + public function getIntegrityStatus(): array + { + $hashValid = $this->verifyHash(); + $chainValid = $this->verifyChainLink(); + + return [ + 'valid' => $hashValid && $chainValid, + 'hash_valid' => $hashValid, + 'chain_valid' => $chainValid, + 'issues' => array_filter([ + ! $hashValid ? 'Entry hash mismatch - data may have been tampered' : null, + ! $chainValid ? 'Chain link broken - previous entry missing or modified' : null, + ]), + ]; + } + + /** + * Convert to array for export. + */ + public function toExportArray(): array + { + return [ + 'id' => $this->id, + 'timestamp' => $this->created_at->toIso8601String(), + 'server_id' => $this->server_id, + 'tool_name' => $this->tool_name, + 'workspace_id' => $this->workspace_id, + 'session_id' => $this->session_id, + 'success' => $this->success, + 'duration_ms' => $this->duration_ms, + 'error_code' => $this->error_code, + 'actor_type' => $this->actor_type, + 'actor_id' => $this->actor_id, + 'actor_ip' => $this->actor_ip, + 'is_sensitive' => $this->is_sensitive, + 'sensitivity_reason' => $this->sensitivity_reason, + 'entry_hash' => $this->entry_hash, + 'previous_hash' => $this->previous_hash, + 'agent_type' => $this->agent_type, + 'plan_slug' => $this->plan_slug, + ]; + } +} diff --git a/src/php/src/Mcp/Models/McpSensitiveTool.php b/src/php/src/Mcp/Models/McpSensitiveTool.php new file mode 100644 index 0000000..7649102 --- /dev/null +++ b/src/php/src/Mcp/Models/McpSensitiveTool.php @@ -0,0 +1,127 @@ + 'array', + 'require_explicit_consent' => 'boolean', + ]; + + // ------------------------------------------------------------------------- + // Scopes + // ------------------------------------------------------------------------- + + /** + * Find by tool name. + */ + public function scopeForTool(Builder $query, string $toolName): Builder + { + return $query->where('tool_name', $toolName); + } + + /** + * Filter tools requiring explicit consent. + */ + public function scopeRequiringConsent(Builder $query): Builder + { + return $query->where('require_explicit_consent', true); + } + + // ------------------------------------------------------------------------- + // Static Methods + // ------------------------------------------------------------------------- + + /** + * Check if a tool is marked as sensitive. + */ + public static function isSensitive(string $toolName): bool + { + return static::where('tool_name', $toolName)->exists(); + } + + /** + * Get sensitivity info for a tool. + */ + public static function getSensitivityInfo(string $toolName): ?array + { + $tool = static::where('tool_name', $toolName)->first(); + + if (! $tool) { + return null; + } + + return [ + 'is_sensitive' => true, + 'reason' => $tool->reason, + 'redact_fields' => $tool->redact_fields ?? [], + 'require_explicit_consent' => $tool->require_explicit_consent, + ]; + } + + /** + * Register a sensitive tool. + */ + public static function register( + string $toolName, + string $reason, + array $redactFields = [], + bool $requireConsent = false + ): self { + return static::updateOrCreate( + ['tool_name' => $toolName], + [ + 'reason' => $reason, + 'redact_fields' => $redactFields, + 'require_explicit_consent' => $requireConsent, + ] + ); + } + + /** + * Unregister a sensitive tool. + */ + public static function unregister(string $toolName): bool + { + return static::where('tool_name', $toolName)->delete() > 0; + } + + /** + * Get all sensitive tool names. + */ + public static function getAllToolNames(): array + { + return static::pluck('tool_name')->toArray(); + } +} diff --git a/src/php/src/Mcp/Models/McpToolCall.php b/src/php/src/Mcp/Models/McpToolCall.php new file mode 100644 index 0000000..643502b --- /dev/null +++ b/src/php/src/Mcp/Models/McpToolCall.php @@ -0,0 +1,161 @@ + 'array', + 'result_summary' => 'array', + 'success' => 'boolean', + 'duration_ms' => 'integer', + ]; + + // Relationships + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + // Scopes + public function scopeForServer(Builder $query, string $serverId): Builder + { + return $query->where('server_id', $serverId); + } + + public function scopeForTool(Builder $query, string $toolName): Builder + { + return $query->where('tool_name', $toolName); + } + + public function scopeSuccessful(Builder $query): Builder + { + return $query->where('success', true); + } + + public function scopeFailed(Builder $query): Builder + { + return $query->where('success', false); + } + + public function scopeRecent(Builder $query, int $hours = 24): Builder + { + return $query->where('created_at', '>=', now()->subHours($hours)); + } + + public function scopeToday(Builder $query): Builder + { + return $query->whereDate('created_at', today()); + } + + public function scopeThisWeek(Builder $query): Builder + { + return $query->where('created_at', '>=', now()->startOfWeek()); + } + + /** + * Log a tool call and update daily stats. + */ + public static function log( + string $serverId, + string $toolName, + array $params = [], + bool $success = true, + ?int $durationMs = null, + ?string $errorMessage = null, + ?string $errorCode = null, + ?array $resultSummary = null, + ?string $sessionId = null, + ?string $agentType = null, + ?string $planSlug = null, + ?int $workspaceId = null + ): self { + $call = static::create([ + 'workspace_id' => $workspaceId, + 'server_id' => $serverId, + 'tool_name' => $toolName, + 'input_params' => $params, + 'success' => $success, + 'duration_ms' => $durationMs, + 'error_message' => $errorMessage, + 'error_code' => $errorCode, + 'result_summary' => $resultSummary, + 'session_id' => $sessionId, + 'agent_type' => $agentType, + 'plan_slug' => $planSlug, + ]); + + // Update daily stats + McpToolCallStat::incrementForCall($call); + + return $call; + } + + // Helpers + public function getDurationForHumans(): string + { + if (! $this->duration_ms) { + return '-'; + } + + if ($this->duration_ms < 1000) { + return $this->duration_ms.'ms'; + } + + return round($this->duration_ms / 1000, 2).'s'; + } + + public function getStatusBadge(): string + { + return $this->success + ? 'Success' + : 'Failed'; + } +} diff --git a/src/php/src/Mcp/Models/McpToolCallStat.php b/src/php/src/Mcp/Models/McpToolCallStat.php new file mode 100644 index 0000000..ee615c8 --- /dev/null +++ b/src/php/src/Mcp/Models/McpToolCallStat.php @@ -0,0 +1,263 @@ + 'date', + 'call_count' => 'integer', + 'success_count' => 'integer', + 'error_count' => 'integer', + 'total_duration_ms' => 'integer', + 'min_duration_ms' => 'integer', + 'max_duration_ms' => 'integer', + ]; + + // Relationships + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + // Scopes + public function scopeForServer(Builder $query, string $serverId): Builder + { + return $query->where('server_id', $serverId); + } + + public function scopeForTool(Builder $query, string $toolName): Builder + { + return $query->where('tool_name', $toolName); + } + + public function scopeForDate(Builder $query, Carbon|string $date): Builder + { + $date = $date instanceof Carbon ? $date->toDateString() : $date; + + return $query->where('date', $date); + } + + public function scopeForDateRange(Builder $query, Carbon|string $start, Carbon|string $end): Builder + { + $start = $start instanceof Carbon ? $start->toDateString() : $start; + $end = $end instanceof Carbon ? $end->toDateString() : $end; + + return $query->whereBetween('date', [$start, $end]); + } + + public function scopeLast7Days(Builder $query): Builder + { + return $query->forDateRange(now()->subDays(6), now()); + } + + public function scopeLast30Days(Builder $query): Builder + { + return $query->forDateRange(now()->subDays(29), now()); + } + + /** + * Increment stats for a tool call. + */ + public static function incrementForCall(McpToolCall $call): void + { + $stat = static::firstOrCreate([ + 'date' => $call->created_at->toDateString(), + 'server_id' => $call->server_id, + 'tool_name' => $call->tool_name, + 'workspace_id' => $call->workspace_id, + ], [ + 'call_count' => 0, + 'success_count' => 0, + 'error_count' => 0, + 'total_duration_ms' => 0, + ]); + + $stat->call_count++; + + if ($call->success) { + $stat->success_count++; + } else { + $stat->error_count++; + } + + if ($call->duration_ms) { + $stat->total_duration_ms += $call->duration_ms; + + if ($stat->min_duration_ms === null || $call->duration_ms < $stat->min_duration_ms) { + $stat->min_duration_ms = $call->duration_ms; + } + + if ($stat->max_duration_ms === null || $call->duration_ms > $stat->max_duration_ms) { + $stat->max_duration_ms = $call->duration_ms; + } + } + + $stat->save(); + } + + // Computed attributes + public function getSuccessRateAttribute(): float + { + if ($this->call_count === 0) { + return 0; + } + + return round(($this->success_count / $this->call_count) * 100, 1); + } + + public function getAvgDurationMsAttribute(): ?float + { + if ($this->call_count === 0 || $this->total_duration_ms === 0) { + return null; + } + + return round($this->total_duration_ms / $this->call_count, 1); + } + + public function getAvgDurationForHumansAttribute(): string + { + $avg = $this->avg_duration_ms; + if ($avg === null) { + return '-'; + } + + if ($avg < 1000) { + return round($avg).'ms'; + } + + return round($avg / 1000, 2).'s'; + } + + /** + * Get top tools by call count. + */ + public static function getTopTools(int $days = 7, int $limit = 10, ?int $workspaceId = null): Collection + { + $query = static::query() + ->select('server_id', 'tool_name') + ->selectRaw('SUM(call_count) as total_calls') + ->selectRaw('SUM(success_count) as total_success') + ->selectRaw('SUM(error_count) as total_errors') + ->selectRaw('AVG(total_duration_ms / NULLIF(call_count, 0)) as avg_duration') + ->forDateRange(now()->subDays($days - 1), now()) + ->groupBy('server_id', 'tool_name') + ->orderByDesc('total_calls') + ->limit($limit); + + if ($workspaceId !== null) { + $query->where('workspace_id', $workspaceId); + } + + return $query->get() + ->map(function ($item) { + $item->success_rate = $item->total_calls > 0 + ? round(($item->total_success / $item->total_calls) * 100, 1) + : 0; + + return $item; + }); + } + + /** + * Get daily trend data. + */ + public static function getDailyTrend(int $days = 7, ?int $workspaceId = null): Collection + { + $query = static::query() + ->select('date') + ->selectRaw('SUM(call_count) as total_calls') + ->selectRaw('SUM(success_count) as total_success') + ->selectRaw('SUM(error_count) as total_errors') + ->forDateRange(now()->subDays($days - 1), now()) + ->groupBy('date') + ->orderBy('date'); + + if ($workspaceId !== null) { + $query->where('workspace_id', $workspaceId); + } + + return $query->get() + ->map(function ($item) { + $item->success_rate = $item->total_calls > 0 + ? round(($item->total_success / $item->total_calls) * 100, 1) + : 0; + + return $item; + }); + } + + /** + * Get server-level statistics. + */ + public static function getServerStats(int $days = 7, ?int $workspaceId = null): Collection + { + $query = static::query() + ->select('server_id') + ->selectRaw('SUM(call_count) as total_calls') + ->selectRaw('SUM(success_count) as total_success') + ->selectRaw('SUM(error_count) as total_errors') + ->selectRaw('COUNT(DISTINCT tool_name) as unique_tools') + ->forDateRange(now()->subDays($days - 1), now()) + ->groupBy('server_id') + ->orderByDesc('total_calls'); + + if ($workspaceId !== null) { + $query->where('workspace_id', $workspaceId); + } + + return $query->get() + ->map(function ($item) { + $item->success_rate = $item->total_calls > 0 + ? round(($item->total_success / $item->total_calls) * 100, 1) + : 0; + + return $item; + }); + } +} diff --git a/src/php/src/Mcp/Models/McpToolVersion.php b/src/php/src/Mcp/Models/McpToolVersion.php new file mode 100644 index 0000000..7bf5bc6 --- /dev/null +++ b/src/php/src/Mcp/Models/McpToolVersion.php @@ -0,0 +1,359 @@ + 'array', + 'output_schema' => 'array', + 'is_latest' => 'boolean', + 'deprecated_at' => 'datetime', + 'sunset_at' => 'datetime', + ]; + + // ------------------------------------------------------------------------- + // Scopes + // ------------------------------------------------------------------------- + + /** + * Filter by server. + */ + public function scopeForServer(Builder $query, string $serverId): Builder + { + return $query->where('server_id', $serverId); + } + + /** + * Filter by tool name. + */ + public function scopeForTool(Builder $query, string $toolName): Builder + { + return $query->where('tool_name', $toolName); + } + + /** + * Filter by specific version. + */ + public function scopeForVersion(Builder $query, string $version): Builder + { + return $query->where('version', $version); + } + + /** + * Get only latest versions. + */ + public function scopeLatest(Builder $query): Builder + { + return $query->where('is_latest', true); + } + + /** + * Get deprecated versions. + */ + public function scopeDeprecated(Builder $query): Builder + { + return $query->whereNotNull('deprecated_at') + ->where('deprecated_at', '<=', now()); + } + + /** + * Get sunset versions (blocked). + */ + public function scopeSunset(Builder $query): Builder + { + return $query->whereNotNull('sunset_at') + ->where('sunset_at', '<=', now()); + } + + /** + * Get active versions (not sunset). + */ + public function scopeActive(Builder $query): Builder + { + return $query->where(function ($q) { + $q->whereNull('sunset_at') + ->orWhere('sunset_at', '>', now()); + }); + } + + /** + * Order by version (newest first using semver sort). + */ + public function scopeOrderByVersion(Builder $query, string $direction = 'desc'): Builder + { + // Basic version ordering - splits on dots and orders numerically + // For production use, consider a more robust semver sorting approach + return $query->orderByRaw( + "CAST(SUBSTRING_INDEX(version, '.', 1) AS UNSIGNED) {$direction}, ". + "CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(version, '.', 2), '.', -1) AS UNSIGNED) {$direction}, ". + "CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(version, '.', 3), '.', -1) AS UNSIGNED) {$direction}" + ); + } + + // ------------------------------------------------------------------------- + // Accessors + // ------------------------------------------------------------------------- + + /** + * Check if this version is deprecated. + */ + public function getIsDeprecatedAttribute(): bool + { + return $this->deprecated_at !== null && $this->deprecated_at->isPast(); + } + + /** + * Check if this version is sunset (blocked). + */ + public function getIsSunsetAttribute(): bool + { + return $this->sunset_at !== null && $this->sunset_at->isPast(); + } + + /** + * Get the lifecycle status of this version. + */ + public function getStatusAttribute(): string + { + if ($this->is_sunset) { + return 'sunset'; + } + + if ($this->is_deprecated) { + return 'deprecated'; + } + + if ($this->is_latest) { + return 'latest'; + } + + return 'active'; + } + + /** + * Get full tool identifier (server:tool). + */ + public function getFullNameAttribute(): string + { + return "{$this->server_id}:{$this->tool_name}"; + } + + /** + * Get full versioned identifier (server:tool@version). + */ + public function getVersionedNameAttribute(): string + { + return "{$this->server_id}:{$this->tool_name}@{$this->version}"; + } + + // ------------------------------------------------------------------------- + // Methods + // ------------------------------------------------------------------------- + + /** + * Get deprecation warning message if deprecated but not sunset. + */ + public function getDeprecationWarning(): ?array + { + if (! $this->is_deprecated || $this->is_sunset) { + return null; + } + + $warning = [ + 'code' => 'TOOL_VERSION_DEPRECATED', + 'message' => "Tool version {$this->version} is deprecated.", + 'current_version' => $this->version, + ]; + + // Find the latest version to suggest + $latest = static::forServer($this->server_id) + ->forTool($this->tool_name) + ->latest() + ->first(); + + if ($latest && $latest->version !== $this->version) { + $warning['latest_version'] = $latest->version; + $warning['message'] .= " Please upgrade to version {$latest->version}."; + } + + if ($this->sunset_at) { + $warning['sunset_at'] = $this->sunset_at->toIso8601String(); + $warning['message'] .= " This version will be blocked after {$this->sunset_at->format('Y-m-d')}."; + } + + if ($this->migration_notes) { + $warning['migration_notes'] = $this->migration_notes; + } + + return $warning; + } + + /** + * Get sunset error if this version is blocked. + */ + public function getSunsetError(): ?array + { + if (! $this->is_sunset) { + return null; + } + + $error = [ + 'code' => 'TOOL_VERSION_SUNSET', + 'message' => "Tool version {$this->version} is no longer available as of {$this->sunset_at->format('Y-m-d')}.", + 'sunset_version' => $this->version, + 'sunset_at' => $this->sunset_at->toIso8601String(), + ]; + + // Find the latest version to suggest + $latest = static::forServer($this->server_id) + ->forTool($this->tool_name) + ->latest() + ->first(); + + if ($latest && $latest->version !== $this->version) { + $error['latest_version'] = $latest->version; + $error['message'] .= " Please use version {$latest->version} instead."; + } + + if ($this->migration_notes) { + $error['migration_notes'] = $this->migration_notes; + } + + return $error; + } + + /** + * Compare schemas between this version and another. + * + * @return array{added: array, removed: array, changed: array} + */ + public function compareSchemaWith(self $other): array + { + $thisProps = $this->input_schema['properties'] ?? []; + $otherProps = $other->input_schema['properties'] ?? []; + + $added = array_diff_key($otherProps, $thisProps); + $removed = array_diff_key($thisProps, $otherProps); + + $changed = []; + foreach (array_intersect_key($thisProps, $otherProps) as $key => $thisProp) { + $otherProp = $otherProps[$key]; + if (json_encode($thisProp) !== json_encode($otherProp)) { + $changed[$key] = [ + 'from' => $thisProp, + 'to' => $otherProp, + ]; + } + } + + return [ + 'added' => array_keys($added), + 'removed' => array_keys($removed), + 'changed' => $changed, + ]; + } + + /** + * Mark this version as deprecated. + */ + public function deprecate(?Carbon $sunsetAt = null): self + { + $this->deprecated_at = now(); + + if ($sunsetAt) { + $this->sunset_at = $sunsetAt; + } + + $this->save(); + + return $this; + } + + /** + * Mark this version as the latest (and unmark others). + */ + public function markAsLatest(): self + { + // Unmark all other versions for this tool + static::forServer($this->server_id) + ->forTool($this->tool_name) + ->where('id', '!=', $this->id) + ->update(['is_latest' => false]); + + $this->is_latest = true; + $this->save(); + + return $this; + } + + /** + * Export version info for API responses. + */ + public function toApiArray(): array + { + return [ + 'server_id' => $this->server_id, + 'tool_name' => $this->tool_name, + 'version' => $this->version, + 'is_latest' => $this->is_latest, + 'status' => $this->status, + 'description' => $this->description, + 'input_schema' => $this->input_schema, + 'output_schema' => $this->output_schema, + 'deprecated_at' => $this->deprecated_at?->toIso8601String(), + 'sunset_at' => $this->sunset_at?->toIso8601String(), + 'migration_notes' => $this->migration_notes, + 'changelog' => $this->changelog, + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } +} diff --git a/src/php/src/Mcp/Models/McpUsageQuota.php b/src/php/src/Mcp/Models/McpUsageQuota.php new file mode 100644 index 0000000..313e245 --- /dev/null +++ b/src/php/src/Mcp/Models/McpUsageQuota.php @@ -0,0 +1,193 @@ + 'integer', + 'input_tokens' => 'integer', + 'output_tokens' => 'integer', + ]; + + // ───────────────────────────────────────────────────────────────────────── + // Relationships + // ───────────────────────────────────────────────────────────────────────── + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + // ───────────────────────────────────────────────────────────────────────── + // Scopes + // ───────────────────────────────────────────────────────────────────────── + + public function scopeForMonth(Builder $query, string $month): Builder + { + return $query->where('month', $month); + } + + public function scopeCurrentMonth(Builder $query): Builder + { + return $query->where('month', now()->format('Y-m')); + } + + // ───────────────────────────────────────────────────────────────────────── + // Factory Methods + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get or create usage quota record for a workspace and month. + */ + public static function getOrCreate(int $workspaceId, ?string $month = null): self + { + $month = $month ?? now()->format('Y-m'); + + return static::firstOrCreate( + [ + 'workspace_id' => $workspaceId, + 'month' => $month, + ], + [ + 'tool_calls_count' => 0, + 'input_tokens' => 0, + 'output_tokens' => 0, + ] + ); + } + + /** + * Get current month's quota for a workspace. + */ + public static function getCurrentForWorkspace(int $workspaceId): self + { + return static::getOrCreate($workspaceId); + } + + // ───────────────────────────────────────────────────────────────────────── + // Usage Recording + // ───────────────────────────────────────────────────────────────────────── + + /** + * Record usage (increments counters atomically). + */ + public function recordUsage(int $toolCalls = 1, int $inputTokens = 0, int $outputTokens = 0): self + { + $this->increment('tool_calls_count', $toolCalls); + + if ($inputTokens > 0) { + $this->increment('input_tokens', $inputTokens); + } + + if ($outputTokens > 0) { + $this->increment('output_tokens', $outputTokens); + } + + return $this->fresh(); + } + + /** + * Record usage for a workspace (static convenience method). + */ + public static function record( + int $workspaceId, + int $toolCalls = 1, + int $inputTokens = 0, + int $outputTokens = 0 + ): self { + $quota = static::getCurrentForWorkspace($workspaceId); + + return $quota->recordUsage($toolCalls, $inputTokens, $outputTokens); + } + + // ───────────────────────────────────────────────────────────────────────── + // Computed Attributes + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get total tokens (input + output). + */ + public function getTotalTokensAttribute(): int + { + return $this->input_tokens + $this->output_tokens; + } + + /** + * Get formatted month (e.g., "January 2026"). + */ + public function getMonthLabelAttribute(): string + { + return \Carbon\Carbon::createFromFormat('Y-m', $this->month)->format('F Y'); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + /** + * Reset usage counters (for billing cycle reset). + */ + public function reset(): self + { + $this->update([ + 'tool_calls_count' => 0, + 'input_tokens' => 0, + 'output_tokens' => 0, + ]); + + return $this; + } + + /** + * Convert to array for API responses. + */ + public function toArray(): array + { + return [ + 'workspace_id' => $this->workspace_id, + 'month' => $this->month, + 'month_label' => $this->month_label, + 'tool_calls_count' => $this->tool_calls_count, + 'input_tokens' => $this->input_tokens, + 'output_tokens' => $this->output_tokens, + 'total_tokens' => $this->total_tokens, + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + } +} diff --git a/src/php/src/Mcp/Models/ToolMetric.php b/src/php/src/Mcp/Models/ToolMetric.php new file mode 100644 index 0000000..1ee432a --- /dev/null +++ b/src/php/src/Mcp/Models/ToolMetric.php @@ -0,0 +1,278 @@ + 'date', + 'call_count' => 'integer', + 'error_count' => 'integer', + 'total_duration_ms' => 'integer', + 'min_duration_ms' => 'integer', + 'max_duration_ms' => 'integer', + ]; + + // ------------------------------------------------------------------------- + // Scopes + // ------------------------------------------------------------------------- + + /** + * Filter metrics for a specific tool. + */ + public function scopeForTool(Builder $query, string $toolName): Builder + { + return $query->where('tool_name', $toolName); + } + + /** + * Filter metrics for a specific workspace. + */ + public function scopeForWorkspace(Builder $query, ?string $workspaceId): Builder + { + if ($workspaceId === null) { + return $query->whereNull('workspace_id'); + } + + return $query->where('workspace_id', $workspaceId); + } + + /** + * Filter metrics within a date range. + */ + public function scopeForDateRange(Builder $query, Carbon|string $start, Carbon|string $end): Builder + { + $start = $start instanceof Carbon ? $start->toDateString() : $start; + $end = $end instanceof Carbon ? $end->toDateString() : $end; + + return $query->whereBetween('date', [$start, $end]); + } + + /** + * Filter metrics for today. + */ + public function scopeToday(Builder $query): Builder + { + return $query->where('date', today()->toDateString()); + } + + /** + * Filter metrics for the last N days. + */ + public function scopeLastDays(Builder $query, int $days): Builder + { + return $query->forDateRange(now()->subDays($days - 1), now()); + } + + // ------------------------------------------------------------------------- + // Accessors + // ------------------------------------------------------------------------- + + /** + * Get the average duration in milliseconds. + */ + public function getAverageDurationAttribute(): float + { + if ($this->call_count === 0 || $this->total_duration_ms === 0) { + return 0.0; + } + + return round($this->total_duration_ms / $this->call_count, 2); + } + + /** + * Get the error rate as a percentage (0-100). + */ + public function getErrorRateAttribute(): float + { + if ($this->call_count === 0) { + return 0.0; + } + + return round(($this->error_count / $this->call_count) * 100, 2); + } + + /** + * Get average duration formatted for display. + */ + public function getAverageDurationForHumansAttribute(): string + { + $avg = $this->average_duration; + + if ($avg === 0.0) { + return '-'; + } + + if ($avg < 1000) { + return round($avg).'ms'; + } + + return round($avg / 1000, 2).'s'; + } + + // ------------------------------------------------------------------------- + // Methods + // ------------------------------------------------------------------------- + + /** + * Record a successful tool call. + */ + public static function recordCall( + string $toolName, + int $durationMs, + ?string $workspaceId = null, + ?Carbon $date = null + ): self { + $date = $date ?? now(); + + $metric = static::firstOrCreate([ + 'tool_name' => $toolName, + 'workspace_id' => $workspaceId, + 'date' => $date->toDateString(), + ], [ + 'call_count' => 0, + 'error_count' => 0, + 'total_duration_ms' => 0, + ]); + + $metric->call_count++; + $metric->total_duration_ms += $durationMs; + + if ($metric->min_duration_ms === null || $durationMs < $metric->min_duration_ms) { + $metric->min_duration_ms = $durationMs; + } + + if ($metric->max_duration_ms === null || $durationMs > $metric->max_duration_ms) { + $metric->max_duration_ms = $durationMs; + } + + $metric->save(); + + return $metric; + } + + /** + * Record a failed tool call. + */ + public static function recordError( + string $toolName, + int $durationMs, + ?string $workspaceId = null, + ?Carbon $date = null + ): self { + $date = $date ?? now(); + + $metric = static::firstOrCreate([ + 'tool_name' => $toolName, + 'workspace_id' => $workspaceId, + 'date' => $date->toDateString(), + ], [ + 'call_count' => 0, + 'error_count' => 0, + 'total_duration_ms' => 0, + ]); + + $metric->call_count++; + $metric->error_count++; + $metric->total_duration_ms += $durationMs; + + if ($metric->min_duration_ms === null || $durationMs < $metric->min_duration_ms) { + $metric->min_duration_ms = $durationMs; + } + + if ($metric->max_duration_ms === null || $durationMs > $metric->max_duration_ms) { + $metric->max_duration_ms = $durationMs; + } + + $metric->save(); + + return $metric; + } + + /** + * Get aggregated stats for a tool across all dates. + */ + public static function getAggregatedStats( + string $toolName, + ?Carbon $from = null, + ?Carbon $to = null, + ?string $workspaceId = null + ): array { + $query = static::forTool($toolName); + + if ($from && $to) { + $query->forDateRange($from, $to); + } + + if ($workspaceId !== null) { + $query->forWorkspace($workspaceId); + } + + $metrics = $query->get(); + + if ($metrics->isEmpty()) { + return [ + 'tool_name' => $toolName, + 'total_calls' => 0, + 'error_count' => 0, + 'error_rate' => 0.0, + 'avg_duration_ms' => 0.0, + 'min_duration_ms' => 0, + 'max_duration_ms' => 0, + ]; + } + + $totalCalls = $metrics->sum('call_count'); + $errorCount = $metrics->sum('error_count'); + $totalDuration = $metrics->sum('total_duration_ms'); + + return [ + 'tool_name' => $toolName, + 'total_calls' => $totalCalls, + 'error_count' => $errorCount, + 'error_rate' => $totalCalls > 0 ? round(($errorCount / $totalCalls) * 100, 2) : 0.0, + 'avg_duration_ms' => $totalCalls > 0 ? round($totalDuration / $totalCalls, 2) : 0.0, + 'min_duration_ms' => $metrics->min('min_duration_ms') ?? 0, + 'max_duration_ms' => $metrics->max('max_duration_ms') ?? 0, + ]; + } +} diff --git a/src/php/src/Mcp/Resources/AppConfig.php b/src/php/src/Mcp/Resources/AppConfig.php new file mode 100644 index 0000000..ac623ab --- /dev/null +++ b/src/php/src/Mcp/Resources/AppConfig.php @@ -0,0 +1,24 @@ + config('app.name'), + 'env' => config('app.env'), + 'debug' => config('app.debug'), + 'url' => config('app.url'), + ]; + + return Response::text(json_encode($config, JSON_PRETTY_PRINT)); + } +} diff --git a/src/php/src/Mcp/Resources/ContentResource.php b/src/php/src/Mcp/Resources/ContentResource.php new file mode 100644 index 0000000..5dc6c67 --- /dev/null +++ b/src/php/src/Mcp/Resources/ContentResource.php @@ -0,0 +1,170 @@ +get('uri', ''); + + // Parse URI: content://{workspace}/{slug} + if (! str_starts_with($uri, 'content://')) { + return Response::text('Invalid URI format. Expected: content://{workspace}/{slug}'); + } + + $path = substr($uri, 10); // Remove 'content://' + $parts = explode('/', $path, 2); + + if (count($parts) < 2) { + return Response::text('Invalid URI format. Expected: content://{workspace}/{slug}'); + } + + [$workspaceSlug, $contentSlug] = $parts; + + // Resolve workspace + $workspace = Workspace::where('slug', $workspaceSlug) + ->orWhere('id', $workspaceSlug) + ->first(); + + if (! $workspace) { + return Response::text("Workspace not found: {$workspaceSlug}"); + } + + // Find content item + $item = ContentItem::forWorkspace($workspace->id) + ->native() + ->where('slug', $contentSlug) + ->first(); + + if (! $item) { + // Try by ID + if (is_numeric($contentSlug)) { + $item = ContentItem::forWorkspace($workspace->id) + ->native() + ->find($contentSlug); + } + } + + if (! $item) { + return Response::text("Content not found: {$contentSlug}"); + } + + // Load relationships + $item->load(['author', 'taxonomies']); + + // Return as markdown with frontmatter + $markdown = $this->contentToMarkdown($item, $workspace); + + return Response::text($markdown); + } + + /** + * Convert content item to markdown with frontmatter. + */ + protected function contentToMarkdown(ContentItem $item, Workspace $workspace): string + { + $md = "---\n"; + $md .= "title: \"{$item->title}\"\n"; + $md .= "slug: {$item->slug}\n"; + $md .= "workspace: {$workspace->slug}\n"; + $md .= "type: {$item->type}\n"; + $md .= "status: {$item->status}\n"; + + if ($item->author) { + $md .= "author: {$item->author->name}\n"; + } + + $categories = $item->categories->pluck('name')->all(); + if (! empty($categories)) { + $md .= 'categories: ['.implode(', ', $categories)."]\n"; + } + + $tags = $item->tags->pluck('name')->all(); + if (! empty($tags)) { + $md .= 'tags: ['.implode(', ', $tags)."]\n"; + } + + if ($item->publish_at) { + $md .= 'publish_at: '.$item->publish_at->toIso8601String()."\n"; + } + + $md .= 'created_at: '.$item->created_at->toIso8601String()."\n"; + $md .= 'updated_at: '.$item->updated_at->toIso8601String()."\n"; + + if ($item->seo_meta) { + if (isset($item->seo_meta['title'])) { + $md .= "seo_title: \"{$item->seo_meta['title']}\"\n"; + } + if (isset($item->seo_meta['description'])) { + $md .= "seo_description: \"{$item->seo_meta['description']}\"\n"; + } + } + + $md .= "---\n\n"; + + // Add excerpt if available + if ($item->excerpt) { + $md .= "> {$item->excerpt}\n\n"; + } + + // Prefer markdown content, fall back to stripping HTML (clean > original) + $content = $item->content_markdown + ?? strip_tags($item->content_html_clean ?? $item->content_html_original ?? ''); + $md .= $content; + + return $md; + } + + /** + * Get list of available content resources. + * + * This is called when MCP lists available resources. + */ + public static function list(): array + { + $resources = []; + + // Get all workspaces with content + $workspaces = Workspace::whereHas('contentItems', function ($q) { + $q->native()->where('status', 'publish'); + })->get(); + + foreach ($workspaces as $workspace) { + // Get published content for this workspace + $items = ContentItem::forWorkspace($workspace->id) + ->native() + ->published() + ->orderByDesc('updated_at') + ->limit(50) + ->get(['id', 'slug', 'title', 'type']); + + foreach ($items as $item) { + $resources[] = [ + 'uri' => "content://{$workspace->slug}/{$item->slug}", + 'name' => $item->title, + 'description' => ucfirst($item->type).": {$item->title}", + 'mimeType' => 'text/markdown', + ]; + } + } + + return $resources; + } +} diff --git a/src/php/src/Mcp/Resources/DatabaseSchema.php b/src/php/src/Mcp/Resources/DatabaseSchema.php new file mode 100644 index 0000000..023798f --- /dev/null +++ b/src/php/src/Mcp/Resources/DatabaseSchema.php @@ -0,0 +1,27 @@ +mapWithKeys(function ($table) { + $tableName = array_values((array) $table)[0]; + $columns = DB::select("DESCRIBE {$tableName}"); + + return [$tableName => $columns]; + }) + ->toArray(); + + return Response::text(json_encode($schema, JSON_PRETTY_PRINT)); + } +} diff --git a/src/php/src/Mcp/Routes/admin.php b/src/php/src/Mcp/Routes/admin.php new file mode 100644 index 0000000..79903d0 --- /dev/null +++ b/src/php/src/Mcp/Routes/admin.php @@ -0,0 +1,70 @@ +name('mcp.')->group(function () { + // Dashboard (workspace MCP usage overview) + Route::get('dashboard', Dashboard::class) + ->name('dashboard'); + + // API key management + Route::get('keys', ApiKeyManager::class) + ->name('keys'); + + // Enhanced MCP Playground with tool browser, history, and examples + Route::get('playground', McpPlayground::class) + ->name('playground'); + + // Legacy simple playground (API-key focused) + Route::get('playground/simple', Playground::class) + ->name('playground.simple'); + + // Request log for debugging + Route::get('logs', RequestLog::class) + ->name('logs'); + + // Analytics endpoints + Route::get('servers/{id}/analytics', [McpRegistryController::class, 'analytics']) + ->name('servers.analytics'); + + // Tool Usage Analytics Dashboard + Route::get('analytics', ToolAnalyticsDashboard::class) + ->name('analytics'); + + // Single tool analytics detail + Route::get('analytics/tool/{name}', ToolAnalyticsDetail::class) + ->name('analytics.tool'); + + // Audit log viewer (compliance and security) + Route::get('audit-log', AuditLogViewer::class) + ->name('audit-log'); + + // Tool version management (Hades only) + Route::get('versions', ToolVersionManager::class) + ->name('versions'); + + // Quota usage overview + Route::get('quotas', QuotaUsage::class) + ->name('quotas'); +}); diff --git a/src/php/src/Mcp/Services/AgentSessionService.php b/src/php/src/Mcp/Services/AgentSessionService.php new file mode 100644 index 0000000..dac3aad --- /dev/null +++ b/src/php/src/Mcp/Services/AgentSessionService.php @@ -0,0 +1,336 @@ +update(['workspace_id' => $workspaceId]); + } + + if (! empty($initialContext)) { + $session->updateContextSummary($initialContext); + } + + // Cache the active session ID for quick lookup + $this->cacheActiveSession($session); + + return $session; + } + + /** + * Get an active session by ID. + */ + public function get(string $sessionId): ?AgentSession + { + return AgentSession::where('session_id', $sessionId)->first(); + } + + /** + * Resume an existing session. + */ + public function resume(string $sessionId): ?AgentSession + { + $session = $this->get($sessionId); + + if (! $session) { + return null; + } + + // Only resume if paused or was handed off + if ($session->status === AgentSession::STATUS_PAUSED) { + $session->resume(); + } + + // Update activity timestamp + $session->touchActivity(); + + // Cache as active + $this->cacheActiveSession($session); + + return $session; + } + + /** + * Get active sessions for a workspace. + */ + public function getActiveSessions(?int $workspaceId = null): Collection + { + $query = AgentSession::active(); + + if ($workspaceId !== null) { + $query->where('workspace_id', $workspaceId); + } + + return $query->orderBy('last_active_at', 'desc')->get(); + } + + /** + * Get sessions for a specific plan. + */ + public function getSessionsForPlan(AgentPlan $plan): Collection + { + return AgentSession::forPlan($plan) + ->orderBy('created_at', 'desc') + ->get(); + } + + /** + * Get the most recent session for a plan. + */ + public function getLatestSessionForPlan(AgentPlan $plan): ?AgentSession + { + return AgentSession::forPlan($plan) + ->orderBy('created_at', 'desc') + ->first(); + } + + /** + * End a session. + */ + public function end(string $sessionId, string $status = AgentSession::STATUS_COMPLETED, ?string $summary = null): ?AgentSession + { + $session = $this->get($sessionId); + + if (! $session) { + return null; + } + + $session->end($status, $summary); + + // Remove from active cache + $this->clearCachedSession($session); + + return $session; + } + + /** + * Pause a session for later resumption. + */ + public function pause(string $sessionId): ?AgentSession + { + $session = $this->get($sessionId); + + if (! $session) { + return null; + } + + $session->pause(); + + return $session; + } + + /** + * Prepare a session for handoff to another agent. + */ + public function prepareHandoff( + string $sessionId, + string $summary, + array $nextSteps = [], + array $blockers = [], + array $contextForNext = [] + ): ?AgentSession { + $session = $this->get($sessionId); + + if (! $session) { + return null; + } + + $session->prepareHandoff($summary, $nextSteps, $blockers, $contextForNext); + + return $session; + } + + /** + * Get handoff context from a session. + */ + public function getHandoffContext(string $sessionId): ?array + { + $session = $this->get($sessionId); + + if (! $session) { + return null; + } + + return $session->getHandoffContext(); + } + + /** + * Create a follow-up session continuing from a previous one. + */ + public function continueFrom(string $previousSessionId, string $newAgentType): ?AgentSession + { + $previousSession = $this->get($previousSessionId); + + if (! $previousSession) { + return null; + } + + // Get the handoff context + $handoffContext = $previousSession->getHandoffContext(); + + // Create new session with context from previous + $newSession = $this->start( + $newAgentType, + $previousSession->plan, + $previousSession->workspace_id, + [ + 'continued_from' => $previousSessionId, + 'previous_agent' => $previousSession->agent_type, + 'handoff_notes' => $handoffContext['handoff_notes'] ?? null, + 'inherited_context' => $handoffContext['context_summary'] ?? null, + ] + ); + + // Mark previous session as handed off + $previousSession->end('handed_off', 'Handed off to '.$newAgentType); + + return $newSession; + } + + /** + * Store custom state in session cache for fast access. + */ + public function setState(string $sessionId, string $key, mixed $value, ?int $ttl = null): void + { + $cacheKey = self::CACHE_PREFIX.$sessionId.':'.$key; + Cache::put($cacheKey, $value, $ttl ?? $this->getCacheTtl()); + } + + /** + * Get custom state from session cache. + */ + public function getState(string $sessionId, string $key, mixed $default = null): mixed + { + $cacheKey = self::CACHE_PREFIX.$sessionId.':'.$key; + + return Cache::get($cacheKey, $default); + } + + /** + * Check if a session exists and is valid. + */ + public function exists(string $sessionId): bool + { + return AgentSession::where('session_id', $sessionId)->exists(); + } + + /** + * Check if a session is active. + */ + public function isActive(string $sessionId): bool + { + $session = $this->get($sessionId); + + return $session !== null && $session->isActive(); + } + + /** + * Get session statistics. + */ + public function getSessionStats(?int $workspaceId = null, int $days = 7): array + { + $query = AgentSession::where('created_at', '>=', now()->subDays($days)); + + if ($workspaceId !== null) { + $query->where('workspace_id', $workspaceId); + } + + $sessions = $query->get(); + + $byStatus = $sessions->groupBy('status')->map->count(); + $byAgent = $sessions->groupBy('agent_type')->map->count(); + + $completedSessions = $sessions->where('status', AgentSession::STATUS_COMPLETED); + $avgDuration = $completedSessions->avg(fn ($s) => $s->getDuration() ?? 0); + + return [ + 'total' => $sessions->count(), + 'active' => $sessions->where('status', AgentSession::STATUS_ACTIVE)->count(), + 'by_status' => $byStatus->toArray(), + 'by_agent_type' => $byAgent->toArray(), + 'avg_duration_minutes' => round($avgDuration, 1), + 'period_days' => $days, + ]; + } + + /** + * Clean up stale sessions (active but not touched in X hours). + */ + public function cleanupStaleSessions(int $hoursInactive = 24): int + { + $cutoff = now()->subHours($hoursInactive); + + $staleSessions = AgentSession::active() + ->where('last_active_at', '<', $cutoff) + ->get(); + + foreach ($staleSessions as $session) { + $session->fail('Session timed out due to inactivity'); + $this->clearCachedSession($session); + } + + return $staleSessions->count(); + } + + /** + * Cache the active session for quick lookup. + */ + protected function cacheActiveSession(AgentSession $session): void + { + $cacheKey = self::CACHE_PREFIX.'active:'.$session->session_id; + Cache::put($cacheKey, [ + 'session_id' => $session->session_id, + 'agent_type' => $session->agent_type, + 'plan_id' => $session->agent_plan_id, + 'workspace_id' => $session->workspace_id, + 'started_at' => $session->started_at?->toIso8601String(), + ], $this->getCacheTtl()); + } + + /** + * Clear cached session data. + */ + protected function clearCachedSession(AgentSession $session): void + { + $cacheKey = self::CACHE_PREFIX.'active:'.$session->session_id; + Cache::forget($cacheKey); + } +} diff --git a/src/php/src/Mcp/Services/AgentToolRegistry.php b/src/php/src/Mcp/Services/AgentToolRegistry.php new file mode 100644 index 0000000..22b102f --- /dev/null +++ b/src/php/src/Mcp/Services/AgentToolRegistry.php @@ -0,0 +1,244 @@ + + */ + protected array $tools = []; + + /** + * Register a tool. + * + * If the tool implements HasDependencies, its dependencies + * are automatically registered with the ToolDependencyService. + */ + public function register(AgentToolInterface $tool): self + { + $this->tools[$tool->name()] = $tool; + + // Auto-register dependencies if tool declares them + if ($tool instanceof HasDependencies && method_exists($tool, 'dependencies')) { + $dependencies = $tool->dependencies(); + if (! empty($dependencies)) { + app(ToolDependencyService::class)->register($tool->name(), $dependencies); + } + } + + return $this; + } + + /** + * Register multiple tools at once. + * + * @param array $tools + */ + public function registerMany(array $tools): self + { + foreach ($tools as $tool) { + $this->register($tool); + } + + return $this; + } + + /** + * Check if a tool is registered. + */ + public function has(string $name): bool + { + return isset($this->tools[$name]); + } + + /** + * Get a tool by name. + */ + public function get(string $name): ?AgentToolInterface + { + return $this->tools[$name] ?? null; + } + + /** + * Get all registered tools. + * + * @return Collection + */ + public function all(): Collection + { + return collect($this->tools); + } + + /** + * Get tools filtered by category. + * + * @return Collection + */ + public function byCategory(string $category): Collection + { + return $this->all()->filter( + fn (AgentToolInterface $tool) => $tool->category() === $category + ); + } + + /** + * Get tools accessible by an API key. + * + * @return Collection + */ + public function forApiKey(ApiKey $apiKey): Collection + { + return $this->all()->filter(function (AgentToolInterface $tool) use ($apiKey) { + // Check if API key has required scopes + foreach ($tool->requiredScopes() as $scope) { + if (! $apiKey->hasScope($scope)) { + return false; + } + } + + // Check if API key has tool-level permission + return $this->apiKeyCanAccessTool($apiKey, $tool->name()); + }); + } + + /** + * Check if an API key can access a specific tool. + */ + public function apiKeyCanAccessTool(ApiKey $apiKey, string $toolName): bool + { + $allowedTools = $apiKey->tool_scopes ?? null; + + // Null means all tools allowed + if ($allowedTools === null) { + return true; + } + + return in_array($toolName, $allowedTools, true); + } + + /** + * Execute a tool with permission and dependency checking. + * + * @param string $name Tool name + * @param array $args Tool arguments + * @param array $context Execution context + * @param ApiKey|null $apiKey Optional API key for permission checking + * @param bool $validateDependencies Whether to validate dependencies + * @return array Tool result + * + * @throws \InvalidArgumentException If tool not found + * @throws \RuntimeException If permission denied + * @throws \Core\Mcp\Exceptions\MissingDependencyException If dependencies not met + */ + public function execute( + string $name, + array $args, + array $context = [], + ?ApiKey $apiKey = null, + bool $validateDependencies = true + ): array { + $tool = $this->get($name); + + if (! $tool) { + throw new \InvalidArgumentException("Unknown tool: {$name}"); + } + + // Permission check if API key provided + if ($apiKey !== null) { + // Check scopes + foreach ($tool->requiredScopes() as $scope) { + if (! $apiKey->hasScope($scope)) { + throw new \RuntimeException( + "Permission denied: API key missing scope '{$scope}' for tool '{$name}'" + ); + } + } + + // Check tool-level permission + if (! $this->apiKeyCanAccessTool($apiKey, $name)) { + throw new \RuntimeException( + "Permission denied: API key does not have access to tool '{$name}'" + ); + } + } + + // Dependency check + if ($validateDependencies) { + $sessionId = $context['session_id'] ?? 'anonymous'; + $dependencyService = app(ToolDependencyService::class); + + $dependencyService->validateDependencies($sessionId, $name, $context, $args); + } + + $result = $tool->handle($args, $context); + + // Record successful tool call for dependency tracking + if ($validateDependencies && ($result['success'] ?? true) !== false) { + $sessionId = $context['session_id'] ?? 'anonymous'; + app(ToolDependencyService::class)->recordToolCall($sessionId, $name, $args); + } + + return $result; + } + + /** + * Get all tools as MCP tool definitions. + * + * @param ApiKey|null $apiKey Filter by API key permissions + */ + public function toMcpDefinitions(?ApiKey $apiKey = null): array + { + $tools = $apiKey !== null + ? $this->forApiKey($apiKey) + : $this->all(); + + return $tools->map(fn (AgentToolInterface $tool) => $tool->toMcpDefinition()) + ->values() + ->all(); + } + + /** + * Get tool categories with counts. + */ + public function categories(): Collection + { + return $this->all() + ->groupBy(fn (AgentToolInterface $tool) => $tool->category()) + ->map(fn ($tools) => $tools->count()); + } + + /** + * Get all tool names. + * + * @return array + */ + public function names(): array + { + return array_keys($this->tools); + } + + /** + * Get tool count. + */ + public function count(): int + { + return count($this->tools); + } +} diff --git a/src/php/src/Mcp/Services/AuditLogService.php b/src/php/src/Mcp/Services/AuditLogService.php new file mode 100644 index 0000000..cb288f9 --- /dev/null +++ b/src/php/src/Mcp/Services/AuditLogService.php @@ -0,0 +1,480 @@ +getSensitivityInfo($toolName); + $isSensitive = $sensitivityInfo !== null; + $sensitivityReason = $sensitivityInfo['reason'] ?? null; + $redactFields = $sensitivityInfo['redact_fields'] ?? []; + + // Redact sensitive fields from input + $redactedInput = $this->redactFields($inputParams, $redactFields); + + // Redact output if it contains sensitive data + $redactedOutput = $outputSummary ? $this->redactFields($outputSummary, $redactFields) : null; + + // Get the previous entry's hash for chain linking + $previousEntry = McpAuditLog::orderByDesc('id')->first(); + $previousHash = $previousEntry?->entry_hash; + + // Create the audit log entry + $auditLog = new McpAuditLog([ + 'server_id' => $serverId, + 'tool_name' => $toolName, + 'workspace_id' => $workspaceId, + 'session_id' => $sessionId, + 'input_params' => $redactedInput, + 'output_summary' => $redactedOutput, + 'success' => $success, + 'duration_ms' => $durationMs, + 'error_code' => $errorCode, + 'error_message' => $errorMessage, + 'actor_type' => $actorType, + 'actor_id' => $actorId, + 'actor_ip' => $actorIp, + 'is_sensitive' => $isSensitive, + 'sensitivity_reason' => $sensitivityReason, + 'previous_hash' => $previousHash, + 'agent_type' => $agentType, + 'plan_slug' => $planSlug, + ]); + + $auditLog->save(); + + // Compute and store the entry hash + $auditLog->entry_hash = $auditLog->computeHash(); + $auditLog->saveQuietly(); // Bypass updating event to allow hash update + + return $auditLog; + }); + } + + /** + * Verify the integrity of the entire audit log chain. + * + * @return array{valid: bool, total: int, verified: int, issues: array} + */ + public function verifyChain(?int $fromId = null, ?int $toId = null): array + { + $query = McpAuditLog::orderBy('id'); + + if ($fromId !== null) { + $query->where('id', '>=', $fromId); + } + + if ($toId !== null) { + $query->where('id', '<=', $toId); + } + + $issues = []; + $verified = 0; + $previousHash = null; + $isFirst = true; + + // If starting from a specific ID, get the previous entry's hash + if ($fromId !== null && $fromId > 1) { + $previousEntry = McpAuditLog::where('id', '<', $fromId) + ->orderByDesc('id') + ->first(); + $previousHash = $previousEntry?->entry_hash; + $isFirst = false; + } + + $total = $query->count(); + + // Process in chunks to avoid memory issues + $query->chunk(1000, function ($entries) use (&$issues, &$verified, &$previousHash, &$isFirst) { + foreach ($entries as $entry) { + // Verify hash + if (! $entry->verifyHash()) { + $issues[] = [ + 'id' => $entry->id, + 'type' => 'hash_mismatch', + 'message' => "Entry #{$entry->id}: Hash mismatch - data may have been tampered", + 'expected' => $entry->computeHash(), + 'actual' => $entry->entry_hash, + ]; + } + + // Verify chain link + if ($isFirst) { + if ($entry->previous_hash !== null) { + $issues[] = [ + 'id' => $entry->id, + 'type' => 'chain_break', + 'message' => "Entry #{$entry->id}: First entry should have null previous_hash", + ]; + } + $isFirst = false; + } else { + if ($entry->previous_hash !== $previousHash) { + $issues[] = [ + 'id' => $entry->id, + 'type' => 'chain_break', + 'message' => "Entry #{$entry->id}: Chain link broken", + 'expected' => $previousHash, + 'actual' => $entry->previous_hash, + ]; + } + } + + $previousHash = $entry->entry_hash; + $verified++; + } + }); + + return [ + 'valid' => empty($issues), + 'total' => $total, + 'verified' => $verified, + 'issues' => $issues, + ]; + } + + /** + * Get audit logs for export. + */ + public function export( + ?int $workspaceId = null, + ?Carbon $from = null, + ?Carbon $to = null, + ?string $toolName = null, + bool $sensitiveOnly = false + ): Collection { + $query = McpAuditLog::orderBy('id'); + + if ($workspaceId !== null) { + $query->where('workspace_id', $workspaceId); + } + + if ($from !== null) { + $query->where('created_at', '>=', $from); + } + + if ($to !== null) { + $query->where('created_at', '<=', $to); + } + + if ($toolName !== null) { + $query->where('tool_name', $toolName); + } + + if ($sensitiveOnly) { + $query->where('is_sensitive', true); + } + + return $query->get()->map(fn ($entry) => $entry->toExportArray()); + } + + /** + * Export to CSV format. + */ + public function exportToCsv( + ?int $workspaceId = null, + ?Carbon $from = null, + ?Carbon $to = null, + ?string $toolName = null, + bool $sensitiveOnly = false + ): string { + $data = $this->export($workspaceId, $from, $to, $toolName, $sensitiveOnly); + + if ($data->isEmpty()) { + return ''; + } + + $headers = array_keys($data->first()); + $output = fopen('php://temp', 'r+'); + + fputcsv($output, $headers); + + foreach ($data as $row) { + fputcsv($output, array_values($row)); + } + + rewind($output); + $csv = stream_get_contents($output); + fclose($output); + + return $csv; + } + + /** + * Export to JSON format. + */ + public function exportToJson( + ?int $workspaceId = null, + ?Carbon $from = null, + ?Carbon $to = null, + ?string $toolName = null, + bool $sensitiveOnly = false + ): string { + $data = $this->export($workspaceId, $from, $to, $toolName, $sensitiveOnly); + + // Include integrity verification in export + $verification = $this->verifyChain(); + + return json_encode([ + 'exported_at' => now()->toIso8601String(), + 'integrity' => [ + 'valid' => $verification['valid'], + 'total_entries' => $verification['total'], + 'verified' => $verification['verified'], + 'issues_count' => count($verification['issues']), + ], + 'filters' => [ + 'workspace_id' => $workspaceId, + 'from' => $from?->toIso8601String(), + 'to' => $to?->toIso8601String(), + 'tool_name' => $toolName, + 'sensitive_only' => $sensitiveOnly, + ], + 'entries' => $data->toArray(), + ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); + } + + /** + * Get statistics for the audit log. + */ + public function getStats(?int $workspaceId = null, ?int $days = 30): array + { + $query = McpAuditLog::query(); + + if ($workspaceId !== null) { + $query->where('workspace_id', $workspaceId); + } + + if ($days !== null) { + $query->where('created_at', '>=', now()->subDays($days)); + } + + $total = $query->count(); + $successful = (clone $query)->where('success', true)->count(); + $failed = (clone $query)->where('success', false)->count(); + $sensitive = (clone $query)->where('is_sensitive', true)->count(); + + $topTools = (clone $query) + ->select('tool_name', DB::raw('COUNT(*) as count')) + ->groupBy('tool_name') + ->orderByDesc('count') + ->limit(10) + ->pluck('count', 'tool_name') + ->toArray(); + + $dailyCounts = (clone $query) + ->select(DB::raw('DATE(created_at) as date'), DB::raw('COUNT(*) as count')) + ->groupBy('date') + ->orderBy('date') + ->limit($days ?? 30) + ->pluck('count', 'date') + ->toArray(); + + return [ + 'total' => $total, + 'successful' => $successful, + 'failed' => $failed, + 'success_rate' => $total > 0 ? round(($successful / $total) * 100, 2) : 0, + 'sensitive_calls' => $sensitive, + 'top_tools' => $topTools, + 'daily_counts' => $dailyCounts, + ]; + } + + /** + * Register a sensitive tool. + */ + public function registerSensitiveTool( + string $toolName, + string $reason, + array $redactFields = [], + bool $requireConsent = false + ): void { + McpSensitiveTool::register($toolName, $reason, $redactFields, $requireConsent); + $this->clearSensitiveToolsCache(); + } + + /** + * Unregister a sensitive tool. + */ + public function unregisterSensitiveTool(string $toolName): bool + { + $result = McpSensitiveTool::unregister($toolName); + $this->clearSensitiveToolsCache(); + + return $result; + } + + /** + * Get all registered sensitive tools. + */ + public function getSensitiveTools(): Collection + { + return McpSensitiveTool::all(); + } + + /** + * Check if a tool requires explicit consent. + */ + public function requiresConsent(string $toolName): bool + { + $info = $this->getSensitivityInfo($toolName); + + return $info !== null && ($info['require_explicit_consent'] ?? false); + } + + // ------------------------------------------------------------------------- + // Protected Methods + // ------------------------------------------------------------------------- + + /** + * Get sensitivity info for a tool (cached). + */ + protected function getSensitivityInfo(string $toolName): ?array + { + $sensitiveTools = Cache::remember( + self::SENSITIVE_TOOLS_CACHE_KEY, + self::SENSITIVE_TOOLS_CACHE_TTL, + fn () => McpSensitiveTool::all()->keyBy('tool_name')->toArray() + ); + + if (! isset($sensitiveTools[$toolName])) { + return null; + } + + $tool = $sensitiveTools[$toolName]; + + return [ + 'is_sensitive' => true, + 'reason' => $tool['reason'], + 'redact_fields' => $tool['redact_fields'] ?? [], + 'require_explicit_consent' => $tool['require_explicit_consent'] ?? false, + ]; + } + + /** + * Redact sensitive fields from data. + */ + protected function redactFields(array $data, array $additionalFields = []): array + { + $fieldsToRedact = array_merge($this->defaultRedactFields, $additionalFields); + + return $this->redactRecursive($data, $fieldsToRedact); + } + + /** + * Recursively redact fields in nested arrays. + */ + protected function redactRecursive(array $data, array $fieldsToRedact): array + { + foreach ($data as $key => $value) { + $keyLower = strtolower((string) $key); + + // Check if this key should be redacted + foreach ($fieldsToRedact as $field) { + if (str_contains($keyLower, strtolower($field))) { + $data[$key] = '[REDACTED]'; + + continue 2; + } + } + + // Recurse into nested arrays + if (is_array($value)) { + $data[$key] = $this->redactRecursive($value, $fieldsToRedact); + } + } + + return $data; + } + + /** + * Clear the sensitive tools cache. + */ + protected function clearSensitiveToolsCache(): void + { + Cache::forget(self::SENSITIVE_TOOLS_CACHE_KEY); + } +} diff --git a/src/php/src/Mcp/Services/CircuitBreaker.php b/src/php/src/Mcp/Services/CircuitBreaker.php new file mode 100644 index 0000000..f6deabb --- /dev/null +++ b/src/php/src/Mcp/Services/CircuitBreaker.php @@ -0,0 +1,442 @@ +getState($service); + + // Fast fail when circuit is open + if ($state === self::STATE_OPEN) { + Log::debug("Circuit breaker open for {$service}, failing fast"); + + if ($fallback !== null) { + return $fallback(); + } + + throw new CircuitOpenException($service); + } + + // Handle half-open state with trial lock to prevent concurrent trial requests + $hasTrialLock = false; + if ($state === self::STATE_HALF_OPEN) { + $hasTrialLock = $this->acquireTrialLock($service); + + if (! $hasTrialLock) { + // Another request is already testing the service, fail fast + Log::debug("Circuit breaker half-open for {$service}, trial in progress, failing fast"); + + if ($fallback !== null) { + return $fallback(); + } + + throw new CircuitOpenException($service, "Service '{$service}' is being tested. Please try again shortly."); + } + } + + // Try the operation + try { + $result = $operation(); + + // Record success and release trial lock if held + $this->recordSuccess($service); + + if ($hasTrialLock) { + $this->releaseTrialLock($service); + } + + return $result; + } catch (Throwable $e) { + // Release trial lock if held + if ($hasTrialLock) { + $this->releaseTrialLock($service); + } + + // Record failure + $this->recordFailure($service, $e); + + // Check if we should trip the circuit + if ($this->shouldTrip($service)) { + $this->tripCircuit($service); + } + + // If fallback provided and this is a recoverable error, use it + if ($fallback !== null && $this->isRecoverableError($e)) { + Log::warning("Circuit breaker using fallback for {$service}", [ + 'error' => $e->getMessage(), + ]); + + return $fallback(); + } + + throw $e; + } + } + + /** + * Get the current state of a circuit. + */ + public function getState(string $service): string + { + $cacheKey = $this->getStateKey($service); + + $state = Cache::get($cacheKey); + + if ($state === null) { + return self::STATE_CLOSED; + } + + // Check if open circuit should transition to half-open + if ($state === self::STATE_OPEN) { + $openedAt = Cache::get($this->getOpenedAtKey($service)); + $resetTimeout = $this->getResetTimeout($service); + + if ($openedAt && (time() - $openedAt) >= $resetTimeout) { + $this->setState($service, self::STATE_HALF_OPEN); + + return self::STATE_HALF_OPEN; + } + } + + return $state; + } + + /** + * Get circuit statistics for monitoring. + */ + public function getStats(string $service): array + { + return [ + 'service' => $service, + 'state' => $this->getState($service), + 'failures' => (int) Cache::get($this->getFailureCountKey($service), 0), + 'successes' => (int) Cache::get($this->getSuccessCountKey($service), 0), + 'last_failure' => Cache::get($this->getLastFailureKey($service)), + 'opened_at' => Cache::get($this->getOpenedAtKey($service)), + 'threshold' => $this->getFailureThreshold($service), + 'reset_timeout' => $this->getResetTimeout($service), + ]; + } + + /** + * Manually reset a circuit to closed state. + */ + public function reset(string $service): void + { + $this->setState($service, self::STATE_CLOSED); + Cache::forget($this->getFailureCountKey($service)); + Cache::forget($this->getSuccessCountKey($service)); + Cache::forget($this->getLastFailureKey($service)); + Cache::forget($this->getOpenedAtKey($service)); + + Log::info("Circuit breaker manually reset for {$service}"); + } + + /** + * Check if a service is available (circuit not open). + */ + public function isAvailable(string $service): bool + { + return $this->getState($service) !== self::STATE_OPEN; + } + + /** + * Record a successful operation. + */ + protected function recordSuccess(string $service): void + { + $state = $this->getState($service); + + // Increment success counter with TTL + $this->atomicIncrement($this->getSuccessCountKey($service), self::COUNTER_TTL); + + // If half-open and we got a success, close the circuit + if ($state === self::STATE_HALF_OPEN) { + $this->closeCircuit($service); + } + + // Decay failures over time (successful calls reduce failure count) + $this->atomicDecrement($this->getFailureCountKey($service)); + } + + /** + * Record a failed operation. + */ + protected function recordFailure(string $service, Throwable $e): void + { + $failureKey = $this->getFailureCountKey($service); + $lastFailureKey = $this->getLastFailureKey($service); + $window = $this->getFailureWindow($service); + + // Atomic increment with TTL refresh using lock + $newCount = $this->atomicIncrement($failureKey, $window); + + // Record last failure details + Cache::put($lastFailureKey, [ + 'message' => $e->getMessage(), + 'class' => get_class($e), + 'time' => now()->toIso8601String(), + ], $window); + + Log::warning("Circuit breaker recorded failure for {$service}", [ + 'error' => $e->getMessage(), + 'failures' => $newCount, + ]); + } + + /** + * Check if the circuit should trip (open). + */ + protected function shouldTrip(string $service): bool + { + $failures = (int) Cache::get($this->getFailureCountKey($service), 0); + $threshold = $this->getFailureThreshold($service); + + return $failures >= $threshold; + } + + /** + * Trip the circuit to open state. + */ + protected function tripCircuit(string $service): void + { + $this->setState($service, self::STATE_OPEN); + Cache::put($this->getOpenedAtKey($service), time(), 86400); // 24h max + + Log::error("Circuit breaker tripped for {$service}", [ + 'failures' => Cache::get($this->getFailureCountKey($service)), + ]); + } + + /** + * Close the circuit after successful recovery. + */ + protected function closeCircuit(string $service): void + { + $this->setState($service, self::STATE_CLOSED); + Cache::forget($this->getFailureCountKey($service)); + Cache::forget($this->getOpenedAtKey($service)); + + Log::info("Circuit breaker closed for {$service} after successful recovery"); + } + + /** + * Set circuit state. + */ + protected function setState(string $service, string $state): void + { + Cache::put($this->getStateKey($service), $state, 86400); // 24h max + } + + /** + * Check if an exception is recoverable (should use fallback). + */ + protected function isRecoverableError(Throwable $e): bool + { + // Database connection errors, table not found, etc. + $recoverablePatterns = [ + 'SQLSTATE', + 'Connection refused', + 'Table .* doesn\'t exist', + 'Base table or view not found', + 'Connection timed out', + 'Too many connections', + ]; + + $message = $e->getMessage(); + + foreach ($recoverablePatterns as $pattern) { + if (preg_match('/'.$pattern.'/i', $message)) { + return true; + } + } + + return false; + } + + /** + * Get the failure threshold from config. + */ + protected function getFailureThreshold(string $service): int + { + return (int) config("mcp.circuit_breaker.{$service}.threshold", + config('mcp.circuit_breaker.default_threshold', 5) + ); + } + + /** + * Get the reset timeout (how long to wait before trying again). + */ + protected function getResetTimeout(string $service): int + { + return (int) config("mcp.circuit_breaker.{$service}.reset_timeout", + config('mcp.circuit_breaker.default_reset_timeout', 60) + ); + } + + /** + * Get the failure window (how long failures are counted). + */ + protected function getFailureWindow(string $service): int + { + return (int) config("mcp.circuit_breaker.{$service}.failure_window", + config('mcp.circuit_breaker.default_failure_window', 120) + ); + } + + /** + * Atomically increment a counter with TTL refresh. + * + * Uses a lock to ensure the increment and TTL refresh are atomic. + */ + protected function atomicIncrement(string $key, int $ttl): int + { + $lock = Cache::lock($key.':lock', 5); + + try { + $lock->block(3); + + $current = (int) Cache::get($key, 0); + $newValue = $current + 1; + Cache::put($key, $newValue, $ttl); + + return $newValue; + } finally { + $lock->release(); + } + } + + /** + * Atomically decrement a counter (only if positive). + * + * Note: We use COUNTER_TTL as a fallback since Laravel's Cache facade + * doesn't expose remaining TTL. The counter will refresh on activity. + */ + protected function atomicDecrement(string $key): int + { + $lock = Cache::lock($key.':lock', 5); + + try { + $lock->block(3); + + $current = (int) Cache::get($key, 0); + if ($current > 0) { + $newValue = $current - 1; + Cache::put($key, $newValue, self::COUNTER_TTL); + + return $newValue; + } + + return 0; + } finally { + $lock->release(); + } + } + + /** + * Acquire a trial lock for half-open state. + * + * Only one request can hold the trial lock at a time, preventing + * concurrent trial requests during half-open state. + */ + protected function acquireTrialLock(string $service): bool + { + $lockKey = $this->getTrialLockKey($service); + + // Try to acquire lock with a short TTL (auto-release if request hangs) + return Cache::add($lockKey, true, 30); + } + + /** + * Release the trial lock. + */ + protected function releaseTrialLock(string $service): void + { + Cache::forget($this->getTrialLockKey($service)); + } + + /** + * Get the trial lock cache key. + */ + protected function getTrialLockKey(string $service): string + { + return self::CACHE_PREFIX.$service.':trial_lock'; + } + + // Cache key helpers + protected function getStateKey(string $service): string + { + return self::CACHE_PREFIX.$service.':state'; + } + + protected function getFailureCountKey(string $service): string + { + return self::CACHE_PREFIX.$service.':failures'; + } + + protected function getSuccessCountKey(string $service): string + { + return self::CACHE_PREFIX.$service.':successes'; + } + + protected function getLastFailureKey(string $service): string + { + return self::CACHE_PREFIX.$service.':last_failure'; + } + + protected function getOpenedAtKey(string $service): string + { + return self::CACHE_PREFIX.$service.':opened_at'; + } +} diff --git a/src/php/src/Mcp/Services/DataRedactor.php b/src/php/src/Mcp/Services/DataRedactor.php new file mode 100644 index 0000000..00f6c63 --- /dev/null +++ b/src/php/src/Mcp/Services/DataRedactor.php @@ -0,0 +1,305 @@ +redactArray($data, $maxDepth - 1); + } + + if (is_string($data)) { + return $this->redactString($data); + } + + return $data; + } + + /** + * Redact sensitive values from an array. + */ + protected function redactArray(array $data, int $maxDepth): array + { + $result = []; + + foreach ($data as $key => $value) { + $lowerKey = strtolower((string) $key); + + // Check for fully sensitive keys + if ($this->isSensitiveKey($lowerKey)) { + $result[$key] = self::REDACTED; + + continue; + } + + // Check for PII keys - partially redact + if ($this->isPiiKey($lowerKey) && is_string($value)) { + $result[$key] = $this->partialRedact($value); + + continue; + } + + // Recurse into nested arrays (with depth guard) + if (is_array($value)) { + if ($maxDepth <= 0) { + $result[$key] = '[MAX_DEPTH_EXCEEDED]'; + } else { + $result[$key] = $this->redactArray($value, $maxDepth - 1); + } + + continue; + } + + // Check string values for embedded sensitive patterns + if (is_string($value)) { + $result[$key] = $this->redactString($value); + + continue; + } + + $result[$key] = $value; + } + + return $result; + } + + /** + * Check if a key name indicates sensitive data. + */ + protected function isSensitiveKey(string $key): bool + { + foreach (self::SENSITIVE_KEYS as $sensitiveKey) { + if (str_contains($key, $sensitiveKey)) { + return true; + } + } + + return false; + } + + /** + * Check if a key name indicates PII. + */ + protected function isPiiKey(string $key): bool + { + foreach (self::PII_KEYS as $piiKey) { + if (str_contains($key, $piiKey)) { + return true; + } + } + + return false; + } + + /** + * Redact sensitive patterns from a string value. + */ + protected function redactString(string $value): string + { + // Redact bearer tokens + $value = preg_replace( + '/Bearer\s+[A-Za-z0-9\-_\.]+/i', + 'Bearer '.self::REDACTED, + $value + ) ?? $value; + + // Redact Basic auth + $value = preg_replace( + '/Basic\s+[A-Za-z0-9\+\/=]+/i', + 'Basic '.self::REDACTED, + $value + ) ?? $value; + + // Redact common API key patterns (key_xxx, sk_xxx, pk_xxx) + $value = preg_replace( + '/\b(sk|pk|key|api|token)_[a-zA-Z0-9]{16,}/i', + '$1_'.self::REDACTED, + $value + ) ?? $value; + + // Redact JWT tokens (xxx.xxx.xxx format with base64) + $value = preg_replace( + '/eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*/i', + self::REDACTED, + $value + ) ?? $value; + + // Redact UK National Insurance numbers + $value = preg_replace( + '/[A-Z]{2}\s?\d{2}\s?\d{2}\s?\d{2}\s?[A-Z]/i', + self::REDACTED, + $value + ) ?? $value; + + // Redact credit card numbers (basic pattern) + $value = preg_replace( + '/\b\d{4}[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4}\b/', + self::REDACTED, + $value + ) ?? $value; + + return $value; + } + + /** + * Partially redact a value, showing first and last characters. + */ + protected function partialRedact(string $value): string + { + $length = strlen($value); + + if ($length <= 4) { + return self::REDACTED; + } + + if ($length <= 8) { + return substr($value, 0, 2).'***'.substr($value, -1); + } + + // For longer values, show more context + $showChars = min(3, (int) floor($length / 4)); + + return substr($value, 0, $showChars).'***'.substr($value, -$showChars); + } + + /** + * Create a summary of array data without sensitive information. + * + * Useful for result_summary where we want structure info without details. + */ + public function summarize(mixed $data, int $maxDepth = 3): mixed + { + if ($maxDepth <= 0) { + return '[...]'; + } + + if (is_array($data)) { + $result = []; + $count = count($data); + + // Limit array size in summary + $limit = 10; + $truncated = $count > $limit; + $items = array_slice($data, 0, $limit, true); + + foreach ($items as $key => $value) { + $lowerKey = strtolower((string) $key); + + // Fully redact sensitive keys + if ($this->isSensitiveKey($lowerKey)) { + $result[$key] = self::REDACTED; + + continue; + } + + // Partially redact PII keys + if ($this->isPiiKey($lowerKey) && is_string($value)) { + $result[$key] = $this->partialRedact($value); + + continue; + } + + // Recurse with reduced depth + $result[$key] = $this->summarize($value, $maxDepth - 1); + } + + if ($truncated) { + $result['_truncated'] = '... and '.($count - $limit).' more items'; + } + + return $result; + } + + if (is_string($data)) { + // Redact first, then truncate (prevents leaking sensitive patterns) + $redacted = $this->redactString($data); + if (strlen($redacted) > 100) { + return substr($redacted, 0, 97).'...'; + } + + return $redacted; + } + + return $data; + } +} diff --git a/src/php/src/Mcp/Services/McpHealthService.php b/src/php/src/Mcp/Services/McpHealthService.php new file mode 100644 index 0000000..6a083e9 --- /dev/null +++ b/src/php/src/Mcp/Services/McpHealthService.php @@ -0,0 +1,303 @@ +loadServerConfig($serverId); + + if (! $server) { + $result = $this->buildResult(self::STATUS_UNKNOWN, 'Server not found'); + Cache::put($cacheKey, $result, $this->cacheTtl); + + return $result; + } + + $result = $this->pingServer($server); + Cache::put($cacheKey, $result, $this->cacheTtl); + + return $result; + } + + /** + * Check health of all registered MCP servers. + */ + public function checkAll(bool $forceRefresh = false): array + { + $servers = $this->getRegisteredServers(); + $results = []; + + foreach ($servers as $serverId) { + $results[$serverId] = $this->check($serverId, $forceRefresh); + } + + return $results; + } + + /** + * Get cached health status without triggering a check. + */ + public function getCachedStatus(string $serverId): ?array + { + return Cache::get("mcp:health:{$serverId}"); + } + + /** + * Clear cached health status for a server. + */ + public function clearCache(string $serverId): void + { + Cache::forget("mcp:health:{$serverId}"); + } + + /** + * Clear all cached health statuses. + */ + public function clearAllCache(): void + { + foreach ($this->getRegisteredServers() as $serverId) { + Cache::forget("mcp:health:{$serverId}"); + } + } + + /** + * Ping a server by sending a minimal MCP request. + */ + protected function pingServer(array $server): array + { + $connection = $server['connection'] ?? []; + $type = $connection['type'] ?? 'stdio'; + + // Only support stdio for now + if ($type !== 'stdio') { + return $this->buildResult( + self::STATUS_UNKNOWN, + "Connection type '{$type}' health check not supported" + ); + } + + $command = $connection['command'] ?? null; + $args = $connection['args'] ?? []; + $cwd = $this->resolveEnvVars($connection['cwd'] ?? getcwd()); + + if (! $command) { + return $this->buildResult(self::STATUS_OFFLINE, 'No command configured'); + } + + // Build the MCP initialize request + $initRequest = json_encode([ + 'jsonrpc' => '2.0', + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2024-11-05', + 'capabilities' => [], + 'clientInfo' => [ + 'name' => 'mcp-health-check', + 'version' => '1.0.0', + ], + ], + 'id' => 1, + ]); + + try { + $startTime = microtime(true); + + // Build full command + $fullCommand = array_merge([$command], $args); + $process = new Process($fullCommand, $cwd); + $process->setInput($initRequest); + $process->setTimeout($this->timeout); + + $process->run(); + + $duration = round((microtime(true) - $startTime) * 1000); + $output = $process->getOutput(); + + // Check for valid JSON-RPC response + if ($process->isSuccessful() && ! empty($output)) { + // Try to parse the response + $lines = explode("\n", trim($output)); + foreach ($lines as $line) { + $response = json_decode($line, true); + if ($response && isset($response['result'])) { + return $this->buildResult( + self::STATUS_ONLINE, + 'Server responding', + [ + 'response_time_ms' => $duration, + 'server_info' => $response['result']['serverInfo'] ?? null, + 'protocol_version' => $response['result']['protocolVersion'] ?? null, + ] + ); + } + } + } + + // Process ran but didn't return expected response + if ($process->isSuccessful()) { + return $this->buildResult( + self::STATUS_DEGRADED, + 'Server started but returned unexpected response', + [ + 'response_time_ms' => $duration, + 'output' => substr($output, 0, 500), + ] + ); + } + + // Process failed + return $this->buildResult( + self::STATUS_OFFLINE, + 'Server failed to start', + [ + 'exit_code' => $process->getExitCode(), + 'error' => substr($process->getErrorOutput(), 0, 500), + ] + ); + + } catch (\Exception $e) { + Log::warning("MCP health check failed for {$server['id']}", [ + 'error' => $e->getMessage(), + ]); + + return $this->buildResult( + self::STATUS_OFFLINE, + 'Health check failed: '.$e->getMessage() + ); + } + } + + /** + * Build a health check result array. + */ + protected function buildResult(string $status, string $message, array $extra = []): array + { + return array_merge([ + 'status' => $status, + 'message' => $message, + 'checked_at' => now()->toIso8601String(), + ], $extra); + } + + /** + * Get list of registered server IDs. + */ + protected function getRegisteredServers(): array + { + $registry = $this->loadRegistry(); + + return collect($registry['servers'] ?? []) + ->pluck('id') + ->all(); + } + + /** + * Load the main registry file. + */ + protected function loadRegistry(): array + { + $path = resource_path('mcp/registry.yaml'); + + if (! file_exists($path)) { + return ['servers' => []]; + } + + return Yaml::parseFile($path); + } + + /** + * Load a server's YAML config. + */ + protected function loadServerConfig(string $id): ?array + { + $path = resource_path("mcp/servers/{$id}.yaml"); + + if (! file_exists($path)) { + return null; + } + + return Yaml::parseFile($path); + } + + /** + * Resolve environment variables in a string. + */ + protected function resolveEnvVars(string $value): string + { + return preg_replace_callback('/\$\{([^}]+)\}/', function ($matches) { + $parts = explode(':-', $matches[1], 2); + $var = $parts[0]; + $default = $parts[1] ?? ''; + + return env($var, $default); + }, $value); + } + + /** + * Get status badge HTML. + */ + public function getStatusBadge(string $status): string + { + return match ($status) { + self::STATUS_ONLINE => 'Online', + self::STATUS_OFFLINE => 'Offline', + self::STATUS_DEGRADED => 'Degraded', + default => 'Unknown', + }; + } + + /** + * Get status colour class for Tailwind. + */ + public function getStatusColour(string $status): string + { + return match ($status) { + self::STATUS_ONLINE => 'green', + self::STATUS_OFFLINE => 'red', + self::STATUS_DEGRADED => 'yellow', + default => 'gray', + }; + } +} diff --git a/src/php/src/Mcp/Services/McpMetricsService.php b/src/php/src/Mcp/Services/McpMetricsService.php new file mode 100644 index 0000000..ce1b391 --- /dev/null +++ b/src/php/src/Mcp/Services/McpMetricsService.php @@ -0,0 +1,267 @@ +subDays($days - 1)->startOfDay(); + + $stats = McpToolCallStat::forDateRange($startDate, now())->get(); + + $totalCalls = $stats->sum('call_count'); + $successCalls = $stats->sum('success_count'); + $errorCalls = $stats->sum('error_count'); + + $successRate = $totalCalls > 0 + ? round(($successCalls / $totalCalls) * 100, 1) + : 0; + + $avgDuration = $totalCalls > 0 + ? round($stats->sum('total_duration_ms') / $totalCalls, 1) + : 0; + + // Compare to previous period + $previousStart = $startDate->copy()->subDays($days); + $previousStats = McpToolCallStat::forDateRange($previousStart, $startDate->copy()->subDay())->get(); + $previousCalls = $previousStats->sum('call_count'); + + $callsTrend = $previousCalls > 0 + ? round((($totalCalls - $previousCalls) / $previousCalls) * 100, 1) + : 0; + + return [ + 'total_calls' => $totalCalls, + 'success_calls' => $successCalls, + 'error_calls' => $errorCalls, + 'success_rate' => $successRate, + 'avg_duration_ms' => $avgDuration, + 'calls_trend_percent' => $callsTrend, + 'unique_tools' => $stats->pluck('tool_name')->unique()->count(), + 'unique_servers' => $stats->pluck('server_id')->unique()->count(), + 'period_days' => $days, + ]; + } + + /** + * Get daily call trend data for charting. + */ + public function getDailyTrend(int $days = 7): Collection + { + $trend = McpToolCallStat::getDailyTrend($days); + + // Fill in missing dates with zeros + $dates = collect(); + for ($i = $days - 1; $i >= 0; $i--) { + $date = now()->subDays($i)->toDateString(); + $existing = $trend->firstWhere('date', $date); + + $dates->push([ + 'date' => $date, + 'date_formatted' => Carbon::parse($date)->format('M j'), + 'total_calls' => $existing->total_calls ?? 0, + 'total_success' => $existing->total_success ?? 0, + 'total_errors' => $existing->total_errors ?? 0, + 'success_rate' => $existing->success_rate ?? 0, + ]); + } + + return $dates; + } + + /** + * Get top tools by call count. + */ + public function getTopTools(int $days = 7, int $limit = 10): Collection + { + return McpToolCallStat::getTopTools($days, $limit); + } + + /** + * Get server breakdown. + */ + public function getServerStats(int $days = 7): Collection + { + return McpToolCallStat::getServerStats($days); + } + + /** + * Get recent tool calls for activity feed. + */ + public function getRecentCalls(int $limit = 20): Collection + { + return McpToolCall::query() + ->orderByDesc('created_at') + ->limit($limit) + ->get() + ->map(function ($call) { + return [ + 'id' => $call->id, + 'server_id' => $call->server_id, + 'tool_name' => $call->tool_name, + 'success' => $call->success, + 'duration' => $call->getDurationForHumans(), + 'duration_ms' => $call->duration_ms, + 'error_message' => $call->error_message, + 'session_id' => $call->session_id, + 'plan_slug' => $call->plan_slug, + 'created_at' => $call->created_at->diffForHumans(), + 'created_at_full' => $call->created_at->toIso8601String(), + ]; + }); + } + + /** + * Get error breakdown. + */ + public function getErrorBreakdown(int $days = 7): Collection + { + return McpToolCall::query() + ->select('tool_name', 'error_code') + ->selectRaw('COUNT(*) as error_count') + ->where('success', false) + ->where('created_at', '>=', now()->subDays($days)) + ->groupBy('tool_name', 'error_code') + ->orderByDesc('error_count') + ->limit(20) + ->get(); + } + + /** + * Get tool performance metrics (p50, p95, p99). + */ + public function getToolPerformance(int $days = 7, int $limit = 10): Collection + { + // Get raw call data for percentile calculations + $calls = McpToolCall::query() + ->select('tool_name', 'duration_ms') + ->whereNotNull('duration_ms') + ->where('success', true) + ->where('created_at', '>=', now()->subDays($days)) + ->get() + ->groupBy('tool_name'); + + $performance = collect(); + + foreach ($calls as $toolName => $toolCalls) { + $durations = $toolCalls->pluck('duration_ms')->sort()->values(); + $count = $durations->count(); + + if ($count === 0) { + continue; + } + + $performance->push([ + 'tool_name' => $toolName, + 'call_count' => $count, + 'min_ms' => $durations->first(), + 'max_ms' => $durations->last(), + 'avg_ms' => round($durations->avg(), 1), + 'p50_ms' => $this->percentile($durations, 50), + 'p95_ms' => $this->percentile($durations, 95), + 'p99_ms' => $this->percentile($durations, 99), + ]); + } + + return $performance + ->sortByDesc('call_count') + ->take($limit) + ->values(); + } + + /** + * Get hourly distribution for the last 24 hours. + */ + public function getHourlyDistribution(): Collection + { + $hourly = McpToolCall::query() + ->selectRaw('HOUR(created_at) as hour') + ->selectRaw('COUNT(*) as call_count') + ->selectRaw('SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_count') + ->where('created_at', '>=', now()->subHours(24)) + ->groupBy('hour') + ->orderBy('hour') + ->get() + ->keyBy('hour'); + + // Fill in missing hours + $result = collect(); + for ($i = 0; $i < 24; $i++) { + $hour = str_pad((string) $i, 2, '0', STR_PAD_LEFT); + $existing = $hourly->get($i); + + $result->push([ + 'hour' => $hour, + 'hour_formatted' => Carbon::createFromTime($i)->format('ga'), + 'call_count' => $existing->call_count ?? 0, + 'success_count' => $existing->success_count ?? 0, + ]); + } + + return $result; + } + + /** + * Get plan activity - which plans are using MCP tools. + */ + public function getPlanActivity(int $days = 7, int $limit = 10): Collection + { + return McpToolCall::query() + ->select('plan_slug') + ->selectRaw('COUNT(*) as call_count') + ->selectRaw('COUNT(DISTINCT tool_name) as unique_tools') + ->selectRaw('SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_count') + ->whereNotNull('plan_slug') + ->where('created_at', '>=', now()->subDays($days)) + ->groupBy('plan_slug') + ->orderByDesc('call_count') + ->limit($limit) + ->get() + ->map(function ($item) { + $item->success_rate = $item->call_count > 0 + ? round(($item->success_count / $item->call_count) * 100, 1) + : 0; + + return $item; + }); + } + + /** + * Calculate percentile from a sorted collection. + */ + protected function percentile(Collection $sortedValues, int $percentile): float + { + $count = $sortedValues->count(); + if ($count === 0) { + return 0; + } + + $index = ($percentile / 100) * ($count - 1); + $lower = (int) floor($index); + $upper = (int) ceil($index); + + if ($lower === $upper) { + return $sortedValues[$lower]; + } + + $fraction = $index - $lower; + + return round($sortedValues[$lower] + ($sortedValues[$upper] - $sortedValues[$lower]) * $fraction, 1); + } +} diff --git a/src/php/src/Mcp/Services/McpQuotaService.php b/src/php/src/Mcp/Services/McpQuotaService.php new file mode 100644 index 0000000..36a9131 --- /dev/null +++ b/src/php/src/Mcp/Services/McpQuotaService.php @@ -0,0 +1,395 @@ +id : $workspace; + + $quota = McpUsageQuota::record($workspaceId, $toolCalls, $inputTokens, $outputTokens); + + // Invalidate cached usage + $this->invalidateUsageCache($workspaceId); + + return $quota; + } + + // ───────────────────────────────────────────────────────────────────────── + // Quota Checking + // ───────────────────────────────────────────────────────────────────────── + + /** + * Check if workspace is within quota limits. + * + * Returns true if within limits (or unlimited), false if quota exceeded. + */ + public function checkQuota(Workspace|int $workspace): bool + { + $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; + $workspace = $workspace instanceof Workspace ? $workspace : Workspace::find($workspaceId); + + if (! $workspace) { + return false; + } + + // Check tool calls quota + $toolCallsResult = $this->entitlements->can($workspace, self::FEATURE_MONTHLY_TOOL_CALLS); + + if ($toolCallsResult->isDenied()) { + // Feature not in plan - deny access + return false; + } + + if (! $toolCallsResult->isUnlimited()) { + $usage = $this->getCurrentUsage($workspace); + $limit = $toolCallsResult->limit; + + if ($limit !== null && $usage['tool_calls_count'] >= $limit) { + return false; + } + } + + // Check tokens quota + $tokensResult = $this->entitlements->can($workspace, self::FEATURE_MONTHLY_TOKENS); + + if (! $tokensResult->isUnlimited() && $tokensResult->isAllowed()) { + $usage = $this->getCurrentUsage($workspace); + $limit = $tokensResult->limit; + + if ($limit !== null && $usage['total_tokens'] >= $limit) { + return false; + } + } + + return true; + } + + /** + * Get detailed quota check result with reasons. + * + * @return array{allowed: bool, reason: ?string, tool_calls: array, tokens: array} + */ + public function checkQuotaDetailed(Workspace|int $workspace): array + { + $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; + $workspace = $workspace instanceof Workspace ? $workspace : Workspace::find($workspaceId); + + if (! $workspace) { + return [ + 'allowed' => false, + 'reason' => 'Workspace not found', + 'tool_calls' => ['allowed' => false], + 'tokens' => ['allowed' => false], + ]; + } + + $usage = $this->getCurrentUsage($workspace); + + // Check tool calls + $toolCallsResult = $this->entitlements->can($workspace, self::FEATURE_MONTHLY_TOOL_CALLS); + $toolCallsAllowed = true; + $toolCallsReason = null; + + if ($toolCallsResult->isDenied()) { + $toolCallsAllowed = false; + $toolCallsReason = 'MCP tool calls not included in your plan'; + } elseif (! $toolCallsResult->isUnlimited()) { + $limit = $toolCallsResult->limit; + if ($limit !== null && $usage['tool_calls_count'] >= $limit) { + $toolCallsAllowed = false; + $toolCallsReason = "Monthly tool calls limit reached ({$usage['tool_calls_count']}/{$limit})"; + } + } + + // Check tokens + $tokensResult = $this->entitlements->can($workspace, self::FEATURE_MONTHLY_TOKENS); + $tokensAllowed = true; + $tokensReason = null; + + if ($tokensResult->isDenied()) { + // Tokens might not be tracked separately - this is OK + $tokensAllowed = true; + } elseif (! $tokensResult->isUnlimited() && $tokensResult->isAllowed()) { + $limit = $tokensResult->limit; + if ($limit !== null && $usage['total_tokens'] >= $limit) { + $tokensAllowed = false; + $tokensReason = "Monthly token limit reached ({$usage['total_tokens']}/{$limit})"; + } + } + + $allowed = $toolCallsAllowed && $tokensAllowed; + $reason = $toolCallsReason ?? $tokensReason; + + return [ + 'allowed' => $allowed, + 'reason' => $reason, + 'tool_calls' => [ + 'allowed' => $toolCallsAllowed, + 'reason' => $toolCallsReason, + 'used' => $usage['tool_calls_count'], + 'limit' => $toolCallsResult->isUnlimited() ? null : $toolCallsResult->limit, + 'unlimited' => $toolCallsResult->isUnlimited(), + ], + 'tokens' => [ + 'allowed' => $tokensAllowed, + 'reason' => $tokensReason, + 'used' => $usage['total_tokens'], + 'input_tokens' => $usage['input_tokens'], + 'output_tokens' => $usage['output_tokens'], + 'limit' => $tokensResult->isUnlimited() ? null : $tokensResult->limit, + 'unlimited' => $tokensResult->isUnlimited(), + ], + ]; + } + + // ───────────────────────────────────────────────────────────────────────── + // Usage Retrieval + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get current month's usage for a workspace. + * + * @return array{tool_calls_count: int, input_tokens: int, output_tokens: int, total_tokens: int, month: string} + */ + public function getCurrentUsage(Workspace|int $workspace): array + { + $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; + + return Cache::remember( + $this->getUsageCacheKey($workspaceId), + 60, // 1 minute cache for current usage + function () use ($workspaceId) { + $quota = McpUsageQuota::getCurrentForWorkspace($workspaceId); + + return [ + 'tool_calls_count' => $quota->tool_calls_count, + 'input_tokens' => $quota->input_tokens, + 'output_tokens' => $quota->output_tokens, + 'total_tokens' => $quota->total_tokens, + 'month' => $quota->month, + ]; + } + ); + } + + /** + * Get remaining quota for a workspace. + * + * @return array{tool_calls: int|null, tokens: int|null, tool_calls_unlimited: bool, tokens_unlimited: bool} + */ + public function getRemainingQuota(Workspace|int $workspace): array + { + $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; + $workspace = $workspace instanceof Workspace ? $workspace : Workspace::find($workspaceId); + + if (! $workspace) { + return [ + 'tool_calls' => 0, + 'tokens' => 0, + 'tool_calls_unlimited' => false, + 'tokens_unlimited' => false, + ]; + } + + $usage = $this->getCurrentUsage($workspace); + + // Tool calls remaining + $toolCallsResult = $this->entitlements->can($workspace, self::FEATURE_MONTHLY_TOOL_CALLS); + $toolCallsRemaining = null; + $toolCallsUnlimited = $toolCallsResult->isUnlimited(); + + if ($toolCallsResult->isAllowed() && ! $toolCallsUnlimited && $toolCallsResult->limit !== null) { + $toolCallsRemaining = max(0, $toolCallsResult->limit - $usage['tool_calls_count']); + } + + // Tokens remaining + $tokensResult = $this->entitlements->can($workspace, self::FEATURE_MONTHLY_TOKENS); + $tokensRemaining = null; + $tokensUnlimited = $tokensResult->isUnlimited(); + + if ($tokensResult->isAllowed() && ! $tokensUnlimited && $tokensResult->limit !== null) { + $tokensRemaining = max(0, $tokensResult->limit - $usage['total_tokens']); + } + + return [ + 'tool_calls' => $toolCallsRemaining, + 'tokens' => $tokensRemaining, + 'tool_calls_unlimited' => $toolCallsUnlimited, + 'tokens_unlimited' => $tokensUnlimited, + ]; + } + + // ───────────────────────────────────────────────────────────────────────── + // Quota Management + // ───────────────────────────────────────────────────────────────────────── + + /** + * Reset monthly quota for a workspace (for billing cycle reset). + */ + public function resetMonthlyQuota(Workspace|int $workspace): McpUsageQuota + { + $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; + + $quota = McpUsageQuota::getCurrentForWorkspace($workspaceId); + $quota->reset(); + + $this->invalidateUsageCache($workspaceId); + + return $quota; + } + + /** + * Get usage history for a workspace (last N months). + * + * @return \Illuminate\Support\Collection + */ + public function getUsageHistory(Workspace|int $workspace, int $months = 12): \Illuminate\Support\Collection + { + $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; + + return McpUsageQuota::where('workspace_id', $workspaceId) + ->orderByDesc('month') + ->limit($months) + ->get(); + } + + /** + * Get quota limits from entitlements. + * + * @return array{tool_calls_limit: int|null, tokens_limit: int|null, tool_calls_unlimited: bool, tokens_unlimited: bool} + */ + public function getQuotaLimits(Workspace|int $workspace): array + { + $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; + $workspace = $workspace instanceof Workspace ? $workspace : Workspace::find($workspaceId); + + if (! $workspace) { + return [ + 'tool_calls_limit' => 0, + 'tokens_limit' => 0, + 'tool_calls_unlimited' => false, + 'tokens_unlimited' => false, + ]; + } + + $cacheKey = "mcp_quota_limits:{$workspaceId}"; + + return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($workspace) { + $toolCallsResult = $this->entitlements->can($workspace, self::FEATURE_MONTHLY_TOOL_CALLS); + $tokensResult = $this->entitlements->can($workspace, self::FEATURE_MONTHLY_TOKENS); + + return [ + 'tool_calls_limit' => $toolCallsResult->isUnlimited() ? null : $toolCallsResult->limit, + 'tokens_limit' => $tokensResult->isUnlimited() ? null : $tokensResult->limit, + 'tool_calls_unlimited' => $toolCallsResult->isUnlimited(), + 'tokens_unlimited' => $tokensResult->isUnlimited(), + ]; + }); + } + + // ───────────────────────────────────────────────────────────────────────── + // Response Headers + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get quota info formatted for HTTP response headers. + * + * @return array + */ + public function getQuotaHeaders(Workspace|int $workspace): array + { + $usage = $this->getCurrentUsage($workspace); + $remaining = $this->getRemainingQuota($workspace); + $limits = $this->getQuotaLimits($workspace); + + $headers = [ + 'X-MCP-Quota-Tool-Calls-Used' => (string) $usage['tool_calls_count'], + 'X-MCP-Quota-Tokens-Used' => (string) $usage['total_tokens'], + ]; + + if ($limits['tool_calls_unlimited']) { + $headers['X-MCP-Quota-Tool-Calls-Limit'] = 'unlimited'; + $headers['X-MCP-Quota-Tool-Calls-Remaining'] = 'unlimited'; + } else { + $headers['X-MCP-Quota-Tool-Calls-Limit'] = (string) ($limits['tool_calls_limit'] ?? 0); + $headers['X-MCP-Quota-Tool-Calls-Remaining'] = (string) ($remaining['tool_calls'] ?? 0); + } + + if ($limits['tokens_unlimited']) { + $headers['X-MCP-Quota-Tokens-Limit'] = 'unlimited'; + $headers['X-MCP-Quota-Tokens-Remaining'] = 'unlimited'; + } else { + $headers['X-MCP-Quota-Tokens-Limit'] = (string) ($limits['tokens_limit'] ?? 0); + $headers['X-MCP-Quota-Tokens-Remaining'] = (string) ($remaining['tokens'] ?? 0); + } + + $headers['X-MCP-Quota-Reset'] = now()->endOfMonth()->toIso8601String(); + + return $headers; + } + + // ───────────────────────────────────────────────────────────────────────── + // Cache Management + // ───────────────────────────────────────────────────────────────────────── + + /** + * Invalidate usage cache for a workspace. + */ + public function invalidateUsageCache(int $workspaceId): void + { + Cache::forget($this->getUsageCacheKey($workspaceId)); + Cache::forget("mcp_quota_limits:{$workspaceId}"); + } + + /** + * Get cache key for workspace usage. + */ + protected function getUsageCacheKey(int $workspaceId): string + { + $month = now()->format('Y-m'); + + return "mcp_usage:{$workspaceId}:{$month}"; + } +} diff --git a/src/php/src/Mcp/Services/McpWebhookDispatcher.php b/src/php/src/Mcp/Services/McpWebhookDispatcher.php new file mode 100644 index 0000000..3ec2495 --- /dev/null +++ b/src/php/src/Mcp/Services/McpWebhookDispatcher.php @@ -0,0 +1,128 @@ +forWorkspace($workspaceId) + ->active() + ->forEvent($eventType) + ->get(); + + if ($endpoints->isEmpty()) { + return; + } + + $payload = [ + 'event' => $eventType, + 'timestamp' => now()->toIso8601String(), + 'data' => [ + 'server_id' => $serverId, + 'tool_name' => $toolName, + 'arguments' => $arguments, + 'success' => $success, + 'duration_ms' => $durationMs, + 'error' => $errorMessage, + ], + ]; + + foreach ($endpoints as $endpoint) { + $this->deliverWebhook($endpoint, $payload); + } + } + + /** + * Deliver a webhook to an endpoint. + */ + protected function deliverWebhook(WebhookEndpoint $endpoint, array $payload): void + { + $payloadJson = json_encode($payload); + $signature = $endpoint->generateSignature($payloadJson); + + $startTime = microtime(true); + + try { + $response = Http::timeout(10) + ->withHeaders([ + 'Content-Type' => 'application/json', + 'X-Webhook-Signature' => $signature, + 'X-Webhook-Event' => $payload['event'], + 'X-Webhook-Timestamp' => $payload['timestamp'], + ]) + ->withBody($payloadJson, 'application/json') + ->post($endpoint->url); + + $durationMs = (int) ((microtime(true) - $startTime) * 1000); + + // Record delivery + WebhookDelivery::create([ + 'webhook_endpoint_id' => $endpoint->id, + 'event_id' => 'evt_'.uniqid(), + 'event_type' => $payload['event'], + 'payload' => $payload, + 'response_code' => $response->status(), + 'response_body' => substr($response->body(), 0, 1000), + 'status' => $response->successful() ? 'success' : 'failed', + 'attempt' => 1, + 'delivered_at' => $response->successful() ? now() : null, + ]); + + if ($response->successful()) { + $endpoint->recordSuccess(); + } else { + $endpoint->recordFailure(); + Log::warning('MCP Webhook delivery failed', [ + 'endpoint_id' => $endpoint->id, + 'url' => $endpoint->url, + 'status' => $response->status(), + ]); + } + } catch (\Throwable $e) { + $durationMs = (int) ((microtime(true) - $startTime) * 1000); + + WebhookDelivery::create([ + 'webhook_endpoint_id' => $endpoint->id, + 'event_id' => 'evt_'.uniqid(), + 'event_type' => $payload['event'], + 'payload' => $payload, + 'response_code' => 0, + 'response_body' => $e->getMessage(), + 'status' => 'failed', + 'attempt' => 1, + ]); + + $endpoint->recordFailure(); + + Log::error('MCP Webhook delivery error', [ + 'endpoint_id' => $endpoint->id, + 'url' => $endpoint->url, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/src/php/src/Mcp/Services/OpenApiGenerator.php b/src/php/src/Mcp/Services/OpenApiGenerator.php new file mode 100644 index 0000000..e2b1379 --- /dev/null +++ b/src/php/src/Mcp/Services/OpenApiGenerator.php @@ -0,0 +1,409 @@ +loadRegistry(); + $this->loadServers(); + + return [ + 'openapi' => '3.0.3', + 'info' => $this->buildInfo(), + 'servers' => $this->buildServers(), + 'tags' => $this->buildTags(), + 'paths' => $this->buildPaths(), + 'components' => $this->buildComponents(), + ]; + } + + public function toJson(): string + { + return json_encode($this->generate(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + + public function toYaml(): string + { + return Yaml::dump($this->generate(), 10, 2); + } + + protected function loadRegistry(): void + { + $path = resource_path('mcp/registry.yaml'); + $this->registry = file_exists($path) ? Yaml::parseFile($path) : ['servers' => []]; + } + + protected function loadServers(): void + { + foreach ($this->registry['servers'] ?? [] as $ref) { + $path = resource_path("mcp/servers/{$ref['id']}.yaml"); + if (file_exists($path)) { + $this->servers[$ref['id']] = Yaml::parseFile($path); + } + } + } + + protected function buildInfo(): array + { + return [ + 'title' => 'Host UK MCP API', + 'description' => 'HTTP API for interacting with Host UK MCP servers. Execute tools, read resources, and discover available capabilities.', + 'version' => '1.0.0', + 'contact' => [ + 'name' => 'Host UK Support', + 'url' => 'https://host.uk.com/contact', + 'email' => 'support@host.uk.com', + ], + 'license' => [ + 'name' => 'Proprietary', + 'url' => 'https://host.uk.com/terms', + ], + ]; + } + + protected function buildServers(): array + { + return [ + [ + 'url' => 'https://mcp.host.uk.com/api/v1/mcp', + 'description' => 'Production', + ], + [ + 'url' => 'https://mcp.test/api/v1/mcp', + 'description' => 'Local development', + ], + ]; + } + + protected function buildTags(): array + { + $tags = [ + [ + 'name' => 'Discovery', + 'description' => 'Server and tool discovery endpoints', + ], + [ + 'name' => 'Execution', + 'description' => 'Tool execution endpoints', + ], + ]; + + foreach ($this->servers as $id => $server) { + $tags[] = [ + 'name' => $server['name'] ?? $id, + 'description' => $server['tagline'] ?? $server['description'] ?? '', + ]; + } + + return $tags; + } + + protected function buildPaths(): array + { + $paths = []; + + // Discovery endpoints + $paths['/servers'] = [ + 'get' => [ + 'tags' => ['Discovery'], + 'summary' => 'List all MCP servers', + 'operationId' => 'listServers', + 'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]], + 'responses' => [ + '200' => [ + 'description' => 'List of available servers', + 'content' => [ + 'application/json' => [ + 'schema' => [ + '$ref' => '#/components/schemas/ServerList', + ], + ], + ], + ], + ], + ], + ]; + + $paths['/servers/{serverId}'] = [ + 'get' => [ + 'tags' => ['Discovery'], + 'summary' => 'Get server details', + 'operationId' => 'getServer', + 'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]], + 'parameters' => [ + [ + 'name' => 'serverId', + 'in' => 'path', + 'required' => true, + 'schema' => ['type' => 'string'], + 'description' => 'Server identifier', + ], + ], + 'responses' => [ + '200' => [ + 'description' => 'Server details with tools and resources', + 'content' => [ + 'application/json' => [ + 'schema' => [ + '$ref' => '#/components/schemas/Server', + ], + ], + ], + ], + '404' => ['description' => 'Server not found'], + ], + ], + ]; + + $paths['/servers/{serverId}/tools'] = [ + 'get' => [ + 'tags' => ['Discovery'], + 'summary' => 'List tools for a server', + 'operationId' => 'listServerTools', + 'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]], + 'parameters' => [ + [ + 'name' => 'serverId', + 'in' => 'path', + 'required' => true, + 'schema' => ['type' => 'string'], + ], + ], + 'responses' => [ + '200' => [ + 'description' => 'List of tools', + 'content' => [ + 'application/json' => [ + 'schema' => [ + '$ref' => '#/components/schemas/ToolList', + ], + ], + ], + ], + ], + ], + ]; + + // Execution endpoint + $paths['/tools/call'] = [ + 'post' => [ + 'tags' => ['Execution'], + 'summary' => 'Execute an MCP tool', + 'operationId' => 'callTool', + 'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]], + 'requestBody' => [ + 'required' => true, + 'content' => [ + 'application/json' => [ + 'schema' => [ + '$ref' => '#/components/schemas/ToolCallRequest', + ], + ], + ], + ], + 'responses' => [ + '200' => [ + 'description' => 'Tool executed successfully', + 'content' => [ + 'application/json' => [ + 'schema' => [ + '$ref' => '#/components/schemas/ToolCallResponse', + ], + ], + ], + ], + '400' => ['description' => 'Invalid request'], + '401' => ['description' => 'Unauthorized'], + '404' => ['description' => 'Server or tool not found'], + '500' => ['description' => 'Tool execution error'], + ], + ], + ]; + + // Resource endpoint + $paths['/resources/{uri}'] = [ + 'get' => [ + 'tags' => ['Execution'], + 'summary' => 'Read a resource', + 'operationId' => 'readResource', + 'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]], + 'parameters' => [ + [ + 'name' => 'uri', + 'in' => 'path', + 'required' => true, + 'schema' => ['type' => 'string'], + 'description' => 'Resource URI (server://path)', + ], + ], + 'responses' => [ + '200' => [ + 'description' => 'Resource content', + 'content' => [ + 'application/json' => [ + 'schema' => [ + '$ref' => '#/components/schemas/ResourceResponse', + ], + ], + ], + ], + ], + ], + ]; + + return $paths; + } + + protected function buildComponents(): array + { + return [ + 'securitySchemes' => [ + 'bearerAuth' => [ + 'type' => 'http', + 'scheme' => 'bearer', + 'description' => 'API key in Bearer format: hk_xxx_yyy', + ], + 'apiKeyAuth' => [ + 'type' => 'apiKey', + 'in' => 'header', + 'name' => 'X-API-Key', + 'description' => 'API key header', + ], + ], + 'schemas' => $this->buildSchemas(), + ]; + } + + protected function buildSchemas(): array + { + $schemas = [ + 'ServerList' => [ + 'type' => 'object', + 'properties' => [ + 'servers' => [ + 'type' => 'array', + 'items' => ['$ref' => '#/components/schemas/ServerSummary'], + ], + 'count' => ['type' => 'integer'], + ], + ], + 'ServerSummary' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'tagline' => ['type' => 'string'], + 'status' => ['type' => 'string', 'enum' => ['available', 'beta', 'deprecated']], + 'tool_count' => ['type' => 'integer'], + 'resource_count' => ['type' => 'integer'], + ], + ], + 'Server' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'tagline' => ['type' => 'string'], + 'description' => ['type' => 'string'], + 'tools' => [ + 'type' => 'array', + 'items' => ['$ref' => '#/components/schemas/Tool'], + ], + 'resources' => [ + 'type' => 'array', + 'items' => ['$ref' => '#/components/schemas/Resource'], + ], + ], + ], + 'Tool' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + 'description' => ['type' => 'string'], + 'inputSchema' => [ + 'type' => 'object', + 'additionalProperties' => true, + ], + ], + ], + 'Resource' => [ + 'type' => 'object', + 'properties' => [ + 'uri' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'description' => ['type' => 'string'], + 'mimeType' => ['type' => 'string'], + ], + ], + 'ToolList' => [ + 'type' => 'object', + 'properties' => [ + 'server' => ['type' => 'string'], + 'tools' => [ + 'type' => 'array', + 'items' => ['$ref' => '#/components/schemas/Tool'], + ], + 'count' => ['type' => 'integer'], + ], + ], + 'ToolCallRequest' => [ + 'type' => 'object', + 'required' => ['server', 'tool'], + 'properties' => [ + 'server' => [ + 'type' => 'string', + 'description' => 'Server ID', + ], + 'tool' => [ + 'type' => 'string', + 'description' => 'Tool name', + ], + 'arguments' => [ + 'type' => 'object', + 'description' => 'Tool arguments', + 'additionalProperties' => true, + ], + ], + ], + 'ToolCallResponse' => [ + 'type' => 'object', + 'properties' => [ + 'success' => ['type' => 'boolean'], + 'server' => ['type' => 'string'], + 'tool' => ['type' => 'string'], + 'result' => [ + 'type' => 'object', + 'additionalProperties' => true, + ], + 'duration_ms' => ['type' => 'integer'], + 'error' => ['type' => 'string'], + ], + ], + 'ResourceResponse' => [ + 'type' => 'object', + 'properties' => [ + 'uri' => ['type' => 'string'], + 'content' => [ + 'type' => 'object', + 'additionalProperties' => true, + ], + ], + ], + ]; + + return $schemas; + } +} diff --git a/src/php/src/Mcp/Services/QueryAuditService.php b/src/php/src/Mcp/Services/QueryAuditService.php new file mode 100644 index 0000000..e45f8ba --- /dev/null +++ b/src/php/src/Mcp/Services/QueryAuditService.php @@ -0,0 +1,330 @@ + $bindings + * @param array $context + */ + public function record( + string $query, + array $bindings, + string $status, + ?int $workspaceId = null, + ?int $userId = null, + ?string $userIp = null, + ?int $durationMs = null, + ?int $rowCount = null, + ?string $errorMessage = null, + ?string $errorCode = null, + array $context = [] + ): void { + $logData = [ + 'timestamp' => now()->toIso8601String(), + 'query' => $this->sanitiseQuery($query), + 'bindings_count' => count($bindings), + 'status' => $status, + 'workspace_id' => $workspaceId, + 'user_id' => $userId, + 'user_ip' => $userIp, + 'duration_ms' => $durationMs, + 'row_count' => $rowCount, + 'request_id' => request()?->header('X-Request-ID'), + 'session_id' => $context['session_id'] ?? null, + 'agent_type' => $context['agent_type'] ?? null, + 'tier' => $context['tier'] ?? 'default', + ]; + + if ($errorMessage !== null) { + $logData['error_message'] = $this->sanitiseErrorMessage($errorMessage); + } + + if ($errorCode !== null) { + $logData['error_code'] = $errorCode; + } + + // Add additional context fields + foreach (['connection', 'explain_requested', 'truncated_at'] as $key) { + if (isset($context[$key])) { + $logData[$key] = $context[$key]; + } + } + + // Determine log level based on status + $level = match ($status) { + self::STATUS_SUCCESS => 'info', + self::STATUS_TRUNCATED => 'notice', + self::STATUS_TIMEOUT => 'warning', + self::STATUS_BLOCKED => 'warning', + self::STATUS_ERROR => 'error', + default => 'info', + }; + + $this->log($level, 'MCP query audit', $logData); + + // Additional security logging for blocked queries + if ($status === self::STATUS_BLOCKED) { + $this->logSecurityEvent($query, $bindings, $workspaceId, $userId, $userIp, $errorMessage); + } + } + + /** + * Record a successful query. + * + * @param array $bindings + * @param array $context + */ + public function recordSuccess( + string $query, + array $bindings, + int $durationMs, + int $rowCount, + ?int $workspaceId = null, + ?int $userId = null, + ?string $userIp = null, + array $context = [] + ): void { + $this->record( + query: $query, + bindings: $bindings, + status: self::STATUS_SUCCESS, + workspaceId: $workspaceId, + userId: $userId, + userIp: $userIp, + durationMs: $durationMs, + rowCount: $rowCount, + context: $context + ); + } + + /** + * Record a blocked query (security violation). + * + * @param array $bindings + * @param array $context + */ + public function recordBlocked( + string $query, + array $bindings, + string $reason, + ?int $workspaceId = null, + ?int $userId = null, + ?string $userIp = null, + array $context = [] + ): void { + $this->record( + query: $query, + bindings: $bindings, + status: self::STATUS_BLOCKED, + workspaceId: $workspaceId, + userId: $userId, + userIp: $userIp, + errorMessage: $reason, + errorCode: 'QUERY_BLOCKED', + context: $context + ); + } + + /** + * Record a query timeout. + * + * @param array $bindings + * @param array $context + */ + public function recordTimeout( + string $query, + array $bindings, + int $timeoutSeconds, + ?int $workspaceId = null, + ?int $userId = null, + ?string $userIp = null, + array $context = [] + ): void { + $this->record( + query: $query, + bindings: $bindings, + status: self::STATUS_TIMEOUT, + workspaceId: $workspaceId, + userId: $userId, + userIp: $userIp, + durationMs: $timeoutSeconds * 1000, + errorMessage: "Query exceeded timeout of {$timeoutSeconds} seconds", + errorCode: 'QUERY_TIMEOUT', + context: $context + ); + } + + /** + * Record a query error. + * + * @param array $bindings + * @param array $context + */ + public function recordError( + string $query, + array $bindings, + string $errorMessage, + ?int $durationMs = null, + ?int $workspaceId = null, + ?int $userId = null, + ?string $userIp = null, + array $context = [] + ): void { + $this->record( + query: $query, + bindings: $bindings, + status: self::STATUS_ERROR, + workspaceId: $workspaceId, + userId: $userId, + userIp: $userIp, + durationMs: $durationMs, + errorMessage: $errorMessage, + errorCode: 'QUERY_ERROR', + context: $context + ); + } + + /** + * Record a truncated result (result size limit exceeded). + * + * @param array $bindings + * @param array $context + */ + public function recordTruncated( + string $query, + array $bindings, + int $durationMs, + int $returnedRows, + int $maxRows, + ?int $workspaceId = null, + ?int $userId = null, + ?string $userIp = null, + array $context = [] + ): void { + $context['truncated_at'] = $maxRows; + + $this->record( + query: $query, + bindings: $bindings, + status: self::STATUS_TRUNCATED, + workspaceId: $workspaceId, + userId: $userId, + userIp: $userIp, + durationMs: $durationMs, + rowCount: $returnedRows, + errorMessage: "Results truncated from {$returnedRows}+ to {$maxRows} rows", + errorCode: 'RESULT_TRUNCATED', + context: $context + ); + } + + /** + * Log a security event for blocked queries. + * + * @param array $bindings + */ + protected function logSecurityEvent( + string $query, + array $bindings, + ?int $workspaceId, + ?int $userId, + ?string $userIp, + ?string $reason + ): void { + Log::channel('security')->warning('MCP query blocked by security policy', [ + 'type' => 'mcp_query_blocked', + 'query_hash' => hash('sha256', $query), + 'query_length' => strlen($query), + 'workspace_id' => $workspaceId, + 'user_id' => $userId, + 'user_ip' => $userIp, + 'reason' => $reason, + 'timestamp' => now()->toIso8601String(), + ]); + } + + /** + * Sanitise query for logging (remove sensitive data patterns). + */ + protected function sanitiseQuery(string $query): string + { + // Truncate very long queries + if (strlen($query) > 2000) { + $query = substr($query, 0, 2000).'... [TRUNCATED]'; + } + + return $query; + } + + /** + * Sanitise error messages to avoid leaking sensitive information. + */ + protected function sanitiseErrorMessage(string $message): string + { + // Remove specific file paths + $message = preg_replace('/\/[^\s]+/', '[path]', $message) ?? $message; + + // Remove IP addresses + $message = preg_replace('/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/', '[ip]', $message) ?? $message; + + // Truncate long messages + if (strlen($message) > 500) { + $message = substr($message, 0, 500).'...'; + } + + return $message; + } + + /** + * Write to the appropriate log channel. + * + * @param array $context + */ + protected function log(string $level, string $message, array $context): void + { + // Use dedicated channel if configured, otherwise use default + $channel = config('mcp.audit.log_channel', self::LOG_CHANNEL); + + try { + Log::channel($channel)->log($level, $message, $context); + } catch (\Exception $e) { + // Fallback to default logger if channel doesn't exist + Log::log($level, $message, $context); + } + } +} diff --git a/src/php/src/Mcp/Services/QueryExecutionService.php b/src/php/src/Mcp/Services/QueryExecutionService.php new file mode 100644 index 0000000..c13fedd --- /dev/null +++ b/src/php/src/Mcp/Services/QueryExecutionService.php @@ -0,0 +1,369 @@ + [ + 'max_rows' => 100, + 'timeout_seconds' => 5, + ], + 'starter' => [ + 'max_rows' => 500, + 'timeout_seconds' => 10, + ], + 'professional' => [ + 'max_rows' => 1000, + 'timeout_seconds' => 30, + ], + 'enterprise' => [ + 'max_rows' => 5000, + 'timeout_seconds' => 60, + ], + 'unlimited' => [ + 'max_rows' => 10000, + 'timeout_seconds' => 120, + ], + ]; + + public function __construct( + protected QueryAuditService $auditService, + protected ?EntitlementService $entitlementService = null + ) {} + + /** + * Execute a query with tier-based limits and audit logging. + * + * @param array $context Additional context for logging + * @return array{data: array, meta: array} + * + * @throws QueryTimeoutException + */ + public function execute( + string $query, + ?string $connection = null, + ?int $workspaceId = null, + ?int $userId = null, + ?string $userIp = null, + array $context = [] + ): array { + $startTime = microtime(true); + $tier = $this->determineTier($workspaceId); + $limits = $this->getLimitsForTier($tier); + $context['tier'] = $tier; + $context['connection'] = $connection; + + try { + // Set up the connection with timeout + $db = $this->getConnection($connection); + $this->applyTimeout($db, $limits['timeout_seconds']); + + // Execute the query + $results = $db->select($query); + $durationMs = (int) ((microtime(true) - $startTime) * 1000); + $totalRows = count($results); + + // Check result size and truncate if necessary + $truncated = false; + $maxRows = $limits['max_rows']; + + if ($totalRows > $maxRows) { + $truncated = true; + $results = array_slice($results, 0, $maxRows); + } + + // Log the query execution + if ($truncated) { + $this->auditService->recordTruncated( + query: $query, + bindings: [], + durationMs: $durationMs, + returnedRows: $totalRows, + maxRows: $maxRows, + workspaceId: $workspaceId, + userId: $userId, + userIp: $userIp, + context: $context + ); + } else { + $this->auditService->recordSuccess( + query: $query, + bindings: [], + durationMs: $durationMs, + rowCount: $totalRows, + workspaceId: $workspaceId, + userId: $userId, + userIp: $userIp, + context: $context + ); + } + + // Build response with metadata + return [ + 'data' => $results, + 'meta' => [ + 'rows_returned' => count($results), + 'rows_total' => $truncated ? "{$totalRows}+" : $totalRows, + 'truncated' => $truncated, + 'max_rows' => $maxRows, + 'tier' => $tier, + 'duration_ms' => $durationMs, + 'warning' => $truncated + ? "Results truncated to {$maxRows} rows (tier limit: {$tier}). Add more specific filters to reduce result size." + : null, + ], + ]; + } catch (\PDOException $e) { + $durationMs = (int) ((microtime(true) - $startTime) * 1000); + + // Check if this is a timeout error + if ($this->isTimeoutError($e)) { + $this->auditService->recordTimeout( + query: $query, + bindings: [], + timeoutSeconds: $limits['timeout_seconds'], + workspaceId: $workspaceId, + userId: $userId, + userIp: $userIp, + context: $context + ); + + throw QueryTimeoutException::exceeded($query, $limits['timeout_seconds']); + } + + // Log general errors + $this->auditService->recordError( + query: $query, + bindings: [], + errorMessage: $e->getMessage(), + durationMs: $durationMs, + workspaceId: $workspaceId, + userId: $userId, + userIp: $userIp, + context: $context + ); + + throw $e; + } catch (\Exception $e) { + $durationMs = (int) ((microtime(true) - $startTime) * 1000); + + $this->auditService->recordError( + query: $query, + bindings: [], + errorMessage: $e->getMessage(), + durationMs: $durationMs, + workspaceId: $workspaceId, + userId: $userId, + userIp: $userIp, + context: $context + ); + + throw $e; + } + } + + /** + * Get the effective limits for a tier. + * + * @return array{max_rows: int, timeout_seconds: int} + */ + public function getLimitsForTier(string $tier): array + { + $configuredLimits = Config::get('mcp.database.tier_limits', []); + $defaultLimits = self::DEFAULT_TIER_LIMITS[$tier] ?? self::DEFAULT_TIER_LIMITS['free']; + + return [ + 'max_rows' => $configuredLimits[$tier]['max_rows'] ?? $defaultLimits['max_rows'], + 'timeout_seconds' => $configuredLimits[$tier]['timeout_seconds'] ?? $defaultLimits['timeout_seconds'], + ]; + } + + /** + * Get available tiers and their limits. + * + * @return array + */ + public function getAvailableTiers(): array + { + $tiers = []; + + foreach (array_keys(self::DEFAULT_TIER_LIMITS) as $tier) { + $tiers[$tier] = $this->getLimitsForTier($tier); + } + + return $tiers; + } + + /** + * Determine the tier for a workspace. + */ + protected function determineTier(?int $workspaceId): string + { + if ($workspaceId === null) { + return Config::get('mcp.database.default_tier', 'free'); + } + + // Check entitlements if service is available + if ($this->entitlementService !== null) { + try { + $workspace = \Core\Tenant\Models\Workspace::find($workspaceId); + + if ($workspace) { + // Check for custom max_rows entitlement + $maxRowsResult = $this->entitlementService->can($workspace, self::FEATURE_MAX_ROWS); + + if ($maxRowsResult->isAllowed() && $maxRowsResult->limit !== null) { + // Map the limit to a tier + return $this->mapLimitToTier($maxRowsResult->limit); + } + } + } catch (\Exception $e) { + // Fall back to default tier on error + report($e); + } + } + + return Config::get('mcp.database.default_tier', 'free'); + } + + /** + * Map a row limit to the corresponding tier. + */ + protected function mapLimitToTier(int $limit): string + { + foreach (self::DEFAULT_TIER_LIMITS as $tier => $limits) { + if ($limits['max_rows'] >= $limit) { + return $tier; + } + } + + return 'unlimited'; + } + + /** + * Get the database connection. + */ + protected function getConnection(?string $connection): Connection + { + return DB::connection($connection); + } + + /** + * Apply timeout to the database connection. + */ + protected function applyTimeout(Connection $connection, int $timeoutSeconds): void + { + $driver = $connection->getDriverName(); + + try { + $pdo = $connection->getPdo(); + + switch ($driver) { + case 'mysql': + case 'mariadb': + // MySQL/MariaDB: Use session variable for max execution time + $timeoutMs = $timeoutSeconds * 1000; + $statement = $pdo->prepare('SET SESSION max_execution_time = ?'); + $statement->execute([$timeoutMs]); + break; + + case 'pgsql': + // PostgreSQL: Use statement_timeout + $timeoutMs = $timeoutSeconds * 1000; + $statement = $pdo->prepare('SET statement_timeout = ?'); + $statement->execute([$timeoutMs]); + break; + + case 'sqlite': + // SQLite: Use busy_timeout (in milliseconds) + $timeoutMs = $timeoutSeconds * 1000; + $pdo->setAttribute(PDO::ATTR_TIMEOUT, $timeoutSeconds); + break; + + default: + // Use PDO timeout as fallback + $pdo->setAttribute(PDO::ATTR_TIMEOUT, $timeoutSeconds); + break; + } + } catch (\Exception $e) { + // Log but don't fail - timeout is a safety measure + report($e); + } + } + + /** + * Check if an exception indicates a timeout. + */ + protected function isTimeoutError(\PDOException $e): bool + { + $message = strtolower($e->getMessage()); + $code = $e->getCode(); + + // MySQL timeout indicators + if (str_contains($message, 'query execution was interrupted')) { + return true; + } + + if (str_contains($message, 'max_execution_time exceeded')) { + return true; + } + + // PostgreSQL timeout indicators + if (str_contains($message, 'statement timeout')) { + return true; + } + + if (str_contains($message, 'canceling statement due to statement timeout')) { + return true; + } + + // SQLite timeout indicators + if (str_contains($message, 'database is locked')) { + return true; + } + + // Generic timeout indicators + if (str_contains($message, 'timeout')) { + return true; + } + + // Check SQLSTATE codes + if ($code === 'HY000' && str_contains($message, 'execution time')) { + return true; + } + + return false; + } +} diff --git a/src/php/src/Mcp/Services/SqlQueryValidator.php b/src/php/src/Mcp/Services/SqlQueryValidator.php new file mode 100644 index 0000000..3ce98ad --- /dev/null +++ b/src/php/src/Mcp/Services/SqlQueryValidator.php @@ -0,0 +1,302 @@ + value, etc. + * - Supports AND/OR logical operators + * - Allows LIKE, IN, BETWEEN, IS NULL/NOT NULL operators + * - No subqueries (no nested SELECT) + * - No function calls except common safe ones + */ + private const DEFAULT_WHITELIST = [ + // Simple SELECT from single table with optional WHERE + '/^\s*SELECT\s+[\w\s,.*`]+\s+FROM\s+`?\w+`?(\s+WHERE\s+[\w\s`.,!=<>\'"%()]+(\s+(AND|OR)\s+[\w\s`.,!=<>\'"%()]+)*)?(\s+ORDER\s+BY\s+[\w\s,`]+(\s+(ASC|DESC))?)?(\s+LIMIT\s+\d+(\s*,\s*\d+)?)?;?\s*$/i', + // COUNT queries + '/^\s*SELECT\s+COUNT\s*\(\s*\*?\s*\)\s+FROM\s+`?\w+`?(\s+WHERE\s+[\w\s`.,!=<>\'"%()]+(\s+(AND|OR)\s+[\w\s`.,!=<>\'"%()]+)*)?;?\s*$/i', + // SELECT with explicit column list + '/^\s*SELECT\s+`?\w+`?(\s*,\s*`?\w+`?)*\s+FROM\s+`?\w+`?(\s+WHERE\s+[\w\s`.,!=<>\'"%()]+(\s+(AND|OR)\s+[\w\s`.,!=<>\'"%()]+)*)?(\s+ORDER\s+BY\s+[\w\s,`]+)?(\s+LIMIT\s+\d+)?;?\s*$/i', + ]; + + private array $whitelist; + + private bool $useWhitelist; + + public function __construct( + ?array $whitelist = null, + bool $useWhitelist = true + ) { + $this->whitelist = $whitelist ?? self::DEFAULT_WHITELIST; + $this->useWhitelist = $useWhitelist; + } + + /** + * Validate a SQL query for safety. + * + * @throws ForbiddenQueryException If the query fails validation + */ + public function validate(string $query): void + { + // Check for dangerous patterns on the ORIGINAL query first + // This catches attempts to obfuscate keywords with comments + $this->checkDangerousPatterns($query); + + // Now normalise and continue validation + $query = $this->normaliseQuery($query); + + $this->checkBlockedKeywords($query); + $this->checkQueryStructure($query); + + if ($this->useWhitelist) { + $this->checkWhitelist($query); + } + } + + /** + * Check if a query is valid without throwing. + */ + public function isValid(string $query): bool + { + try { + $this->validate($query); + + return true; + } catch (ForbiddenQueryException) { + return false; + } + } + + /** + * Add a pattern to the whitelist. + */ + public function addWhitelistPattern(string $pattern): self + { + $this->whitelist[] = $pattern; + + return $this; + } + + /** + * Replace the entire whitelist. + */ + public function setWhitelist(array $patterns): self + { + $this->whitelist = $patterns; + + return $this; + } + + /** + * Enable or disable whitelist checking. + */ + public function setUseWhitelist(bool $use): self + { + $this->useWhitelist = $use; + + return $this; + } + + /** + * Normalise the query for consistent validation. + */ + private function normaliseQuery(string $query): string + { + // Remove SQL comments + $query = $this->stripComments($query); + + // Normalise whitespace + $query = preg_replace('/\s+/', ' ', $query); + + return trim($query); + } + + /** + * Strip SQL comments which could be used to bypass filters. + */ + private function stripComments(string $query): string + { + // Remove -- style comments + $query = preg_replace('/--.*$/m', '', $query); + + // Remove # style comments + $query = preg_replace('/#.*$/m', '', $query); + + // Remove /* */ style comments (including multi-line) + $query = preg_replace('/\/\*.*?\*\//s', '', $query); + + // Remove /*! MySQL-specific comments that execute code + $query = preg_replace('/\/\*!.*?\*\//s', '', $query); + + return $query; + } + + /** + * Check for blocked SQL keywords. + * + * @throws ForbiddenQueryException + */ + private function checkBlockedKeywords(string $query): void + { + $upperQuery = strtoupper($query); + + foreach (self::BLOCKED_KEYWORDS as $keyword) { + // Use word boundary check for most keywords + $pattern = '/\b'.preg_quote($keyword, '/').'\b/i'; + + if (preg_match($pattern, $query)) { + throw ForbiddenQueryException::disallowedKeyword($query, $keyword); + } + } + } + + /** + * Check for dangerous patterns that indicate injection. + * + * @throws ForbiddenQueryException + */ + private function checkDangerousPatterns(string $query): void + { + foreach (self::DANGEROUS_PATTERNS as $pattern) { + if (preg_match($pattern, $query)) { + throw ForbiddenQueryException::invalidStructure( + $query, + 'Query contains potentially malicious pattern' + ); + } + } + } + + /** + * Check basic query structure. + * + * @throws ForbiddenQueryException + */ + private function checkQueryStructure(string $query): void + { + // Must start with SELECT + if (! preg_match('/^\s*SELECT\b/i', $query)) { + throw ForbiddenQueryException::invalidStructure( + $query, + 'Query must begin with SELECT' + ); + } + + // Check for multiple statements (stacked queries) + // After stripping comments, there should be at most one semicolon at the end + $semicolonCount = substr_count($query, ';'); + if ($semicolonCount > 1) { + throw ForbiddenQueryException::invalidStructure( + $query, + 'Multiple statements detected' + ); + } + + if ($semicolonCount === 1 && ! preg_match('/;\s*$/', $query)) { + throw ForbiddenQueryException::invalidStructure( + $query, + 'Semicolon only allowed at end of query' + ); + } + } + + /** + * Check if query matches at least one whitelist pattern. + * + * @throws ForbiddenQueryException + */ + private function checkWhitelist(string $query): void + { + foreach ($this->whitelist as $pattern) { + if (preg_match($pattern, $query)) { + return; // Query matches a whitelisted pattern + } + } + + throw ForbiddenQueryException::notWhitelisted($query); + } +} diff --git a/src/php/src/Mcp/Services/ToolAnalyticsService.php b/src/php/src/Mcp/Services/ToolAnalyticsService.php new file mode 100644 index 0000000..b25fc28 --- /dev/null +++ b/src/php/src/Mcp/Services/ToolAnalyticsService.php @@ -0,0 +1,386 @@ + + */ + protected array $pendingMetrics = []; + + /** + * Track tools used in current session for combination tracking. + * + * @var array> + */ + protected array $sessionTools = []; + + /** + * Record a tool execution. + */ + public function recordExecution( + string $tool, + int $durationMs, + bool $success, + ?string $workspaceId = null, + ?string $sessionId = null + ): void { + if (! config('mcp.analytics.enabled', true)) { + return; + } + + $key = $this->getMetricKey($tool, $workspaceId); + + if (! isset($this->pendingMetrics[$key])) { + $this->pendingMetrics[$key] = [ + 'tool_name' => $tool, + 'workspace_id' => $workspaceId, + 'calls' => 0, + 'errors' => 0, + 'duration' => 0, + 'min' => null, + 'max' => null, + ]; + } + + $this->pendingMetrics[$key]['calls']++; + $this->pendingMetrics[$key]['duration'] += $durationMs; + + if (! $success) { + $this->pendingMetrics[$key]['errors']++; + } + + if ($this->pendingMetrics[$key]['min'] === null || $durationMs < $this->pendingMetrics[$key]['min']) { + $this->pendingMetrics[$key]['min'] = $durationMs; + } + + if ($this->pendingMetrics[$key]['max'] === null || $durationMs > $this->pendingMetrics[$key]['max']) { + $this->pendingMetrics[$key]['max'] = $durationMs; + } + + // Track tool combinations if session ID provided + if ($sessionId !== null) { + $this->trackToolInSession($sessionId, $tool, $workspaceId); + } + + // Flush if batch size reached + $batchSize = config('mcp.analytics.batch_size', 100); + if ($this->getTotalPendingCalls() >= $batchSize) { + $this->flush(); + } + } + + /** + * Get statistics for a specific tool. + */ + public function getToolStats(string $tool, ?Carbon $from = null, ?Carbon $to = null): ToolStats + { + $from = $from ?? now()->subDays(30); + $to = $to ?? now(); + + $stats = ToolMetric::getAggregatedStats($tool, $from, $to); + + return ToolStats::fromArray($stats); + } + + /** + * Get statistics for all tools. + */ + public function getAllToolStats(?Carbon $from = null, ?Carbon $to = null): Collection + { + $from = $from ?? now()->subDays(30); + $to = $to ?? now(); + + $results = ToolMetric::query() + ->select('tool_name') + ->selectRaw('SUM(call_count) as total_calls') + ->selectRaw('SUM(error_count) as error_count') + ->selectRaw('SUM(total_duration_ms) as total_duration') + ->selectRaw('MIN(min_duration_ms) as min_duration_ms') + ->selectRaw('MAX(max_duration_ms) as max_duration_ms') + ->forDateRange($from, $to) + ->groupBy('tool_name') + ->orderByDesc('total_calls') + ->get(); + + return $results->map(function ($row) { + $totalCalls = (int) $row->total_calls; + $errorCount = (int) $row->error_count; + $totalDuration = (int) $row->total_duration; + + return new ToolStats( + toolName: $row->tool_name, + totalCalls: $totalCalls, + errorCount: $errorCount, + errorRate: $totalCalls > 0 ? round(($errorCount / $totalCalls) * 100, 2) : 0.0, + avgDurationMs: $totalCalls > 0 ? round($totalDuration / $totalCalls, 2) : 0.0, + minDurationMs: (int) ($row->min_duration_ms ?? 0), + maxDurationMs: (int) ($row->max_duration_ms ?? 0), + ); + }); + } + + /** + * Get the most popular tools by call count. + */ + public function getPopularTools(int $limit = 10, ?Carbon $from = null, ?Carbon $to = null): Collection + { + return $this->getAllToolStats($from, $to) + ->sortByDesc(fn (ToolStats $stats) => $stats->totalCalls) + ->take($limit) + ->values(); + } + + /** + * Get tools with the highest error rates. + */ + public function getErrorProneTools(int $limit = 10, ?Carbon $from = null, ?Carbon $to = null): Collection + { + $minCalls = 10; // Require minimum calls to be considered + + return $this->getAllToolStats($from, $to) + ->filter(fn (ToolStats $stats) => $stats->totalCalls >= $minCalls) + ->sortByDesc(fn (ToolStats $stats) => $stats->errorRate) + ->take($limit) + ->values(); + } + + /** + * Get tool combinations - tools frequently used together. + */ + public function getToolCombinations(int $limit = 10, ?Carbon $from = null, ?Carbon $to = null): Collection + { + $from = $from ?? now()->subDays(30); + $to = $to ?? now(); + + return DB::table('mcp_tool_combinations') + ->select('tool_a', 'tool_b') + ->selectRaw('SUM(occurrence_count) as total_occurrences') + ->whereBetween('date', [$from->toDateString(), $to->toDateString()]) + ->groupBy('tool_a', 'tool_b') + ->orderByDesc('total_occurrences') + ->limit($limit) + ->get() + ->map(fn ($row) => [ + 'tool_a' => $row->tool_a, + 'tool_b' => $row->tool_b, + 'occurrences' => (int) $row->total_occurrences, + ]); + } + + /** + * Get usage trends for a specific tool. + */ + public function getUsageTrends(string $tool, int $days = 30): array + { + $startDate = now()->subDays($days - 1)->startOfDay(); + $endDate = now()->endOfDay(); + + $metrics = ToolMetric::forTool($tool) + ->forDateRange($startDate, $endDate) + ->orderBy('date') + ->get() + ->keyBy(fn ($m) => $m->date->toDateString()); + + $trends = []; + + for ($i = $days - 1; $i >= 0; $i--) { + $date = now()->subDays($i)->toDateString(); + $metric = $metrics->get($date); + + $trends[] = [ + 'date' => $date, + 'date_formatted' => Carbon::parse($date)->format('M j'), + 'calls' => $metric?->call_count ?? 0, + 'errors' => $metric?->error_count ?? 0, + 'avg_duration_ms' => $metric?->average_duration ?? 0, + 'error_rate' => $metric?->error_rate ?? 0, + ]; + } + + return $trends; + } + + /** + * Get workspace-specific statistics. + */ + public function getWorkspaceStats(string $workspaceId, ?Carbon $from = null, ?Carbon $to = null): array + { + $from = $from ?? now()->subDays(30); + $to = $to ?? now(); + + $results = ToolMetric::query() + ->forWorkspace($workspaceId) + ->forDateRange($from, $to) + ->get(); + + $totalCalls = $results->sum('call_count'); + $errorCount = $results->sum('error_count'); + $totalDuration = $results->sum('total_duration_ms'); + $uniqueTools = $results->pluck('tool_name')->unique()->count(); + + return [ + 'workspace_id' => $workspaceId, + 'total_calls' => $totalCalls, + 'error_count' => $errorCount, + 'error_rate' => $totalCalls > 0 ? round(($errorCount / $totalCalls) * 100, 2) : 0.0, + 'avg_duration_ms' => $totalCalls > 0 ? round($totalDuration / $totalCalls, 2) : 0.0, + 'unique_tools' => $uniqueTools, + ]; + } + + /** + * Flush pending metrics to the database. + */ + public function flush(): void + { + if (empty($this->pendingMetrics)) { + return; + } + + $date = now()->toDateString(); + + foreach ($this->pendingMetrics as $data) { + $metric = ToolMetric::firstOrCreate([ + 'tool_name' => $data['tool_name'], + 'workspace_id' => $data['workspace_id'], + 'date' => $date, + ], [ + 'call_count' => 0, + 'error_count' => 0, + 'total_duration_ms' => 0, + ]); + + $metric->call_count += $data['calls']; + $metric->error_count += $data['errors']; + $metric->total_duration_ms += $data['duration']; + + if ($data['min'] !== null) { + if ($metric->min_duration_ms === null || $data['min'] < $metric->min_duration_ms) { + $metric->min_duration_ms = $data['min']; + } + } + + if ($data['max'] !== null) { + if ($metric->max_duration_ms === null || $data['max'] > $metric->max_duration_ms) { + $metric->max_duration_ms = $data['max']; + } + } + + $metric->save(); + } + + // Flush session tool combinations + $this->flushToolCombinations(); + + $this->pendingMetrics = []; + } + + /** + * Track a tool being used in a session. + */ + protected function trackToolInSession(string $sessionId, string $tool, ?string $workspaceId): void + { + $key = $sessionId.':'.($workspaceId ?? 'global'); + + if (! isset($this->sessionTools[$key])) { + $this->sessionTools[$key] = [ + 'workspace_id' => $workspaceId, + 'tools' => [], + ]; + } + + if (! in_array($tool, $this->sessionTools[$key]['tools'], true)) { + $this->sessionTools[$key]['tools'][] = $tool; + } + } + + /** + * Flush tool combinations to the database. + */ + protected function flushToolCombinations(): void + { + $date = now()->toDateString(); + + foreach ($this->sessionTools as $sessionData) { + $tools = $sessionData['tools']; + $workspaceId = $sessionData['workspace_id']; + + // Generate all unique pairs + $count = count($tools); + for ($i = 0; $i < $count; $i++) { + for ($j = $i + 1; $j < $count; $j++) { + // Ensure consistent ordering (alphabetical) + $pair = [$tools[$i], $tools[$j]]; + sort($pair); + + DB::table('mcp_tool_combinations') + ->updateOrInsert( + [ + 'tool_a' => $pair[0], + 'tool_b' => $pair[1], + 'workspace_id' => $workspaceId, + 'date' => $date, + ], + [ + 'occurrence_count' => DB::raw('occurrence_count + 1'), + 'updated_at' => now(), + ] + ); + + // Handle insert case where occurrence_count wasn't set + DB::table('mcp_tool_combinations') + ->where('tool_a', $pair[0]) + ->where('tool_b', $pair[1]) + ->where('workspace_id', $workspaceId) + ->where('date', $date) + ->whereNull('created_at') + ->update([ + 'created_at' => now(), + 'occurrence_count' => 1, + ]); + } + } + } + + $this->sessionTools = []; + } + + /** + * Get the metric key for batching. + */ + protected function getMetricKey(string $tool, ?string $workspaceId): string + { + return $tool.':'.($workspaceId ?? 'global'); + } + + /** + * Get total pending calls across all batches. + */ + protected function getTotalPendingCalls(): int + { + $total = 0; + foreach ($this->pendingMetrics as $data) { + $total += $data['calls']; + } + + return $total; + } +} diff --git a/src/php/src/Mcp/Services/ToolDependencyService.php b/src/php/src/Mcp/Services/ToolDependencyService.php new file mode 100644 index 0000000..f032faf --- /dev/null +++ b/src/php/src/Mcp/Services/ToolDependencyService.php @@ -0,0 +1,496 @@ +> + */ + protected array $dependencies = []; + + /** + * Custom dependency validators. + * + * @var array + */ + protected array $customValidators = []; + + public function __construct() + { + $this->registerDefaultDependencies(); + } + + /** + * Register dependencies for a tool. + * + * @param string $toolName The tool name + * @param array $dependencies List of dependencies + */ + public function register(string $toolName, array $dependencies): self + { + $this->dependencies[$toolName] = $dependencies; + + return $this; + } + + /** + * Register a custom validator for CUSTOM dependency types. + * + * @param string $name The custom dependency name + * @param callable $validator Function(array $context, array $args): bool + */ + public function registerCustomValidator(string $name, callable $validator): self + { + $this->customValidators[$name] = $validator; + + return $this; + } + + /** + * Get dependencies for a tool. + * + * @return array + */ + public function getDependencies(string $toolName): array + { + return $this->dependencies[$toolName] ?? []; + } + + /** + * Check if all dependencies are met for a tool. + * + * @param string $sessionId The session identifier + * @param string $toolName The tool to check + * @param array $context The execution context + * @param array $args The tool arguments + * @return bool True if all dependencies are met + */ + public function checkDependencies(string $sessionId, string $toolName, array $context = [], array $args = []): bool + { + $missing = $this->getMissingDependencies($sessionId, $toolName, $context, $args); + + return empty($missing); + } + + /** + * Get list of missing dependencies for a tool. + * + * @param string $sessionId The session identifier + * @param string $toolName The tool to check + * @param array $context The execution context + * @param array $args The tool arguments + * @return array List of unmet dependencies + */ + public function getMissingDependencies(string $sessionId, string $toolName, array $context = [], array $args = []): array + { + $dependencies = $this->getDependencies($toolName); + + if (empty($dependencies)) { + return []; + } + + $calledTools = $this->getCalledTools($sessionId); + $missing = []; + + foreach ($dependencies as $dependency) { + if ($dependency->optional) { + continue; // Skip optional dependencies + } + + $isMet = $this->isDependencyMet($dependency, $calledTools, $context, $args); + + if (! $isMet) { + $missing[] = $dependency; + } + } + + return $missing; + } + + /** + * Validate dependencies and throw exception if not met. + * + * @param string $sessionId The session identifier + * @param string $toolName The tool to validate + * @param array $context The execution context + * @param array $args The tool arguments + * + * @throws MissingDependencyException If dependencies are not met + */ + public function validateDependencies(string $sessionId, string $toolName, array $context = [], array $args = []): void + { + $missing = $this->getMissingDependencies($sessionId, $toolName, $context, $args); + + if (! empty($missing)) { + $suggestedOrder = $this->getSuggestedToolOrder($toolName, $missing); + + throw new MissingDependencyException($toolName, $missing, $suggestedOrder); + } + } + + /** + * Record that a tool was called in a session. + * + * @param string $sessionId The session identifier + * @param string $toolName The tool that was called + * @param array $args The arguments used (for entity tracking) + */ + public function recordToolCall(string $sessionId, string $toolName, array $args = []): void + { + $key = self::SESSION_CACHE_PREFIX.$sessionId; + $history = Cache::get($key, []); + + $history[] = [ + 'tool' => $toolName, + 'args' => $args, + 'timestamp' => now()->toIso8601String(), + ]; + + Cache::put($key, $history, self::SESSION_CACHE_TTL); + } + + /** + * Get list of tools called in a session. + * + * @return array Tool names that have been called + */ + public function getCalledTools(string $sessionId): array + { + $key = self::SESSION_CACHE_PREFIX.$sessionId; + $history = Cache::get($key, []); + + return array_unique(array_column($history, 'tool')); + } + + /** + * Get full tool call history for a session. + * + * @return array + */ + public function getToolHistory(string $sessionId): array + { + $key = self::SESSION_CACHE_PREFIX.$sessionId; + + return Cache::get($key, []); + } + + /** + * Clear session tool history. + */ + public function clearSession(string $sessionId): void + { + Cache::forget(self::SESSION_CACHE_PREFIX.$sessionId); + } + + /** + * Get the full dependency graph for visualization. + * + * @return array + */ + public function getDependencyGraph(): array + { + $graph = []; + + // Build forward dependencies + foreach ($this->dependencies as $tool => $deps) { + $graph[$tool] = [ + 'dependencies' => array_map(fn (ToolDependency $d) => $d->toArray(), $deps), + 'dependents' => [], + ]; + } + + // Build reverse dependencies (who depends on whom) + foreach ($this->dependencies as $tool => $deps) { + foreach ($deps as $dep) { + if ($dep->type === DependencyType::TOOL_CALLED) { + if (! isset($graph[$dep->key])) { + $graph[$dep->key] = [ + 'dependencies' => [], + 'dependents' => [], + ]; + } + $graph[$dep->key]['dependents'][] = $tool; + } + } + } + + return $graph; + } + + /** + * Get all tools that depend on a specific tool. + * + * @return array Tool names that depend on the given tool + */ + public function getDependentTools(string $toolName): array + { + $dependents = []; + + foreach ($this->dependencies as $tool => $deps) { + foreach ($deps as $dep) { + if ($dep->type === DependencyType::TOOL_CALLED && $dep->key === $toolName) { + $dependents[] = $tool; + } + } + } + + return $dependents; + } + + /** + * Get all tools in dependency order (topological sort). + * + * @return array Tools sorted by dependency order + */ + public function getTopologicalOrder(): array + { + $visited = []; + $order = []; + $tools = array_keys($this->dependencies); + + foreach ($tools as $tool) { + $this->topologicalVisit($tool, $visited, $order); + } + + return $order; + } + + /** + * Check if a specific dependency is met. + */ + protected function isDependencyMet( + ToolDependency $dependency, + array $calledTools, + array $context, + array $args + ): bool { + return match ($dependency->type) { + DependencyType::TOOL_CALLED => in_array($dependency->key, $calledTools, true), + DependencyType::SESSION_STATE => isset($context[$dependency->key]) && $context[$dependency->key] !== null, + DependencyType::CONTEXT_EXISTS => array_key_exists($dependency->key, $context), + DependencyType::ENTITY_EXISTS => $this->checkEntityExists($dependency, $args, $context), + DependencyType::CUSTOM => $this->checkCustomDependency($dependency, $context, $args), + }; + } + + /** + * Check if an entity exists based on the dependency configuration. + */ + protected function checkEntityExists(ToolDependency $dependency, array $args, array $context): bool + { + $entityType = $dependency->key; + $argKey = $dependency->metadata['arg_key'] ?? null; + + if (! $argKey || ! isset($args[$argKey])) { + return false; + } + + // Check based on entity type + return match ($entityType) { + 'plan' => $this->planExists($args[$argKey]), + 'session' => $this->sessionExists($args[$argKey] ?? $context['session_id'] ?? null), + 'phase' => $this->phaseExists($args['plan_slug'] ?? null, $args[$argKey] ?? null), + default => true, // Unknown entity types pass by default + }; + } + + /** + * Check if a plan exists. + */ + protected function planExists(?string $slug): bool + { + if (! $slug) { + return false; + } + + // Use a simple database check - the model namespace may vary + return \DB::table('agent_plans')->where('slug', $slug)->exists(); + } + + /** + * Check if a session exists. + */ + protected function sessionExists(?string $sessionId): bool + { + if (! $sessionId) { + return false; + } + + return \DB::table('agent_sessions')->where('session_id', $sessionId)->exists(); + } + + /** + * Check if a phase exists. + */ + protected function phaseExists(?string $planSlug, ?string $phaseIdentifier): bool + { + if (! $planSlug || ! $phaseIdentifier) { + return false; + } + + $plan = \DB::table('agent_plans')->where('slug', $planSlug)->first(); + if (! $plan) { + return false; + } + + $query = \DB::table('agent_phases')->where('agent_plan_id', $plan->id); + + if (is_numeric($phaseIdentifier)) { + return $query->where('order', (int) $phaseIdentifier)->exists(); + } + + return $query->where('name', $phaseIdentifier)->exists(); + } + + /** + * Check a custom dependency using registered validator. + */ + protected function checkCustomDependency(ToolDependency $dependency, array $context, array $args): bool + { + $validator = $this->customValidators[$dependency->key] ?? null; + + if (! $validator) { + // No validator registered - pass by default with warning + return true; + } + + return call_user_func($validator, $context, $args); + } + + /** + * Get suggested tool order to satisfy dependencies. + * + * @param array $missing + * @return array + */ + protected function getSuggestedToolOrder(string $targetTool, array $missing): array + { + $order = []; + + foreach ($missing as $dep) { + if ($dep->type === DependencyType::TOOL_CALLED) { + // Recursively get dependencies of the required tool + $preDeps = $this->getDependencies($dep->key); + foreach ($preDeps as $preDep) { + if ($preDep->type === DependencyType::TOOL_CALLED && ! in_array($preDep->key, $order, true)) { + $order[] = $preDep->key; + } + } + + if (! in_array($dep->key, $order, true)) { + $order[] = $dep->key; + } + } + } + + $order[] = $targetTool; + + return $order; + } + + /** + * Helper for topological sort. + */ + protected function topologicalVisit(string $tool, array &$visited, array &$order): void + { + if (isset($visited[$tool])) { + return; + } + + $visited[$tool] = true; + + foreach ($this->getDependencies($tool) as $dep) { + if ($dep->type === DependencyType::TOOL_CALLED) { + $this->topologicalVisit($dep->key, $visited, $order); + } + } + + $order[] = $tool; + } + + /** + * Register default dependencies for known tools. + */ + protected function registerDefaultDependencies(): void + { + // Session tools - session_log/artifact/handoff require active session + $this->register('session_log', [ + ToolDependency::sessionState('session_id', 'Active session required. Call session_start first.'), + ]); + + $this->register('session_artifact', [ + ToolDependency::sessionState('session_id', 'Active session required. Call session_start first.'), + ]); + + $this->register('session_handoff', [ + ToolDependency::sessionState('session_id', 'Active session required. Call session_start first.'), + ]); + + $this->register('session_end', [ + ToolDependency::sessionState('session_id', 'Active session required. Call session_start first.'), + ]); + + // Plan tools - require workspace context + $this->register('plan_create', [ + ToolDependency::contextExists('workspace_id', 'Workspace context required'), + ]); + + // Task tools - require plan to exist + $this->register('task_update', [ + ToolDependency::entityExists('plan', 'Plan must exist', ['arg_key' => 'plan_slug']), + ]); + + $this->register('task_toggle', [ + ToolDependency::entityExists('plan', 'Plan must exist', ['arg_key' => 'plan_slug']), + ]); + + // Phase tools - require plan to exist + $this->register('phase_get', [ + ToolDependency::entityExists('plan', 'Plan must exist', ['arg_key' => 'plan_slug']), + ]); + + $this->register('phase_update_status', [ + ToolDependency::entityExists('plan', 'Plan must exist', ['arg_key' => 'plan_slug']), + ]); + + $this->register('phase_add_checkpoint', [ + ToolDependency::entityExists('plan', 'Plan must exist', ['arg_key' => 'plan_slug']), + ]); + + // Content tools - require brief to exist for generation + $this->register('content_generate', [ + ToolDependency::contextExists('workspace_id', 'Workspace context required'), + ]); + + $this->register('content_batch_generate', [ + ToolDependency::contextExists('workspace_id', 'Workspace context required'), + ]); + } +} diff --git a/src/php/src/Mcp/Services/ToolRateLimiter.php b/src/php/src/Mcp/Services/ToolRateLimiter.php new file mode 100644 index 0000000..f9e2755 --- /dev/null +++ b/src/php/src/Mcp/Services/ToolRateLimiter.php @@ -0,0 +1,144 @@ + false, 'remaining' => PHP_INT_MAX, 'retry_after' => null]; + } + + $limit = $this->getLimitForTool($toolName); + $decaySeconds = config('mcp.rate_limiting.decay_seconds', 60); + $cacheKey = $this->getCacheKey($identifier, $toolName); + + $current = (int) Cache::get($cacheKey, 0); + + if ($current >= $limit) { + $ttl = Cache::ttl($cacheKey); + + return [ + 'limited' => true, + 'remaining' => 0, + 'retry_after' => $ttl > 0 ? $ttl : $decaySeconds, + ]; + } + + return [ + 'limited' => false, + 'remaining' => $limit - $current - 1, + 'retry_after' => null, + ]; + } + + /** + * Record a tool call against the rate limit. + * + * @param string $identifier Session ID, API key, or other unique identifier + * @param string $toolName The tool being called + */ + public function hit(string $identifier, string $toolName): void + { + if (! config('mcp.rate_limiting.enabled', true)) { + return; + } + + $decaySeconds = config('mcp.rate_limiting.decay_seconds', 60); + $cacheKey = $this->getCacheKey($identifier, $toolName); + + $current = (int) Cache::get($cacheKey, 0); + + if ($current === 0) { + // First call - set with expiration + Cache::put($cacheKey, 1, $decaySeconds); + } else { + // Increment without resetting TTL + Cache::increment($cacheKey); + } + } + + /** + * Clear rate limit for an identifier. + * + * @param string $identifier Session ID, API key, or other unique identifier + * @param string|null $toolName Specific tool, or null to clear all + */ + public function clear(string $identifier, ?string $toolName = null): void + { + if ($toolName !== null) { + Cache::forget($this->getCacheKey($identifier, $toolName)); + } else { + // Clear all tool rate limits for this identifier (requires knowing tools) + // For now, just clear the specific key pattern + Cache::forget($this->getCacheKey($identifier, '*')); + } + } + + /** + * Get the rate limit for a specific tool. + */ + protected function getLimitForTool(string $toolName): int + { + // Check for tool-specific limit + $perToolLimits = config('mcp.rate_limiting.per_tool', []); + + if (isset($perToolLimits[$toolName])) { + return (int) $perToolLimits[$toolName]; + } + + // Use default limit + return (int) config('mcp.rate_limiting.calls_per_minute', 60); + } + + /** + * Generate cache key for rate limiting. + */ + protected function getCacheKey(string $identifier, string $toolName): string + { + // Use general key for overall rate limiting + return self::CACHE_PREFIX.$identifier.':'.$toolName; + } + + /** + * Get rate limit status for reporting. + * + * @return array{limit: int, remaining: int, reset_at: string|null} + */ + public function getStatus(string $identifier, string $toolName): array + { + $limit = $this->getLimitForTool($toolName); + $cacheKey = $this->getCacheKey($identifier, $toolName); + $current = (int) Cache::get($cacheKey, 0); + $ttl = Cache::ttl($cacheKey); + + return [ + 'limit' => $limit, + 'remaining' => max(0, $limit - $current), + 'reset_at' => $ttl > 0 ? now()->addSeconds($ttl)->toIso8601String() : null, + ]; + } +} diff --git a/src/php/src/Mcp/Services/ToolRegistry.php b/src/php/src/Mcp/Services/ToolRegistry.php new file mode 100644 index 0000000..66de2fb --- /dev/null +++ b/src/php/src/Mcp/Services/ToolRegistry.php @@ -0,0 +1,353 @@ +> + */ + protected array $examples = [ + 'query_database' => [ + 'query' => 'SELECT id, name FROM users LIMIT 10', + ], + 'list_tables' => [], + 'list_routes' => [], + 'list_sites' => [], + 'get_stats' => [], + 'create_coupon' => [ + 'code' => 'SUMMER25', + 'discount_type' => 'percentage', + 'discount_value' => 25, + 'expires_at' => '2025-12-31', + ], + 'list_invoices' => [ + 'status' => 'paid', + 'limit' => 10, + ], + 'get_billing_status' => [], + 'upgrade_plan' => [ + 'plan_slug' => 'professional', + ], + ]; + + /** + * Get all available MCP servers. + * + * @return Collection + */ + public function getServers(): Collection + { + return Cache::remember('mcp:playground:servers', self::CACHE_TTL, function () { + $registry = $this->loadRegistry(); + + return collect($registry['servers'] ?? []) + ->map(fn ($ref) => $this->loadServerSummary($ref['id'])) + ->filter() + ->values(); + }); + } + + /** + * Get all tools for a specific server. + * + * @return Collection + */ + public function getToolsForServer(string $serverId, bool $includeVersionInfo = false): Collection + { + $cacheKey = $includeVersionInfo + ? "mcp:playground:tools:{$serverId}:versioned" + : "mcp:playground:tools:{$serverId}"; + + return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($serverId, $includeVersionInfo) { + $server = $this->loadServerFull($serverId); + + if (! $server) { + return collect(); + } + + return collect($server['tools'] ?? []) + ->map(function ($tool) use ($serverId, $includeVersionInfo) { + $name = $tool['name']; + $baseVersion = $tool['version'] ?? ToolVersionService::DEFAULT_VERSION; + + $result = [ + 'name' => $name, + 'description' => $tool['description'] ?? $tool['purpose'] ?? '', + 'category' => $this->extractCategory($tool), + 'inputSchema' => $tool['inputSchema'] ?? ['type' => 'object', 'properties' => $tool['parameters'] ?? []], + 'examples' => $this->examples[$name] ?? $this->generateExampleFromSchema($tool['inputSchema'] ?? []), + 'version' => $baseVersion, + ]; + + // Optionally enrich with database version info + if ($includeVersionInfo) { + $latestVersion = McpToolVersion::forServer($serverId) + ->forTool($name) + ->latest() + ->first(); + + if ($latestVersion) { + $result['version'] = $latestVersion->version; + $result['version_status'] = $latestVersion->status; + $result['is_deprecated'] = $latestVersion->is_deprecated; + $result['sunset_at'] = $latestVersion->sunset_at?->toIso8601String(); + + // Use versioned schema if available + if ($latestVersion->input_schema) { + $result['inputSchema'] = $latestVersion->input_schema; + } + } + } + + return $result; + }) + ->values(); + }); + } + + /** + * Get all tools grouped by category. + * + * @return Collection> + */ + public function getToolsByCategory(string $serverId): Collection + { + return $this->getToolsForServer($serverId) + ->groupBy('category') + ->sortKeys(); + } + + /** + * Search tools by name or description. + * + * @return Collection + */ + public function searchTools(string $serverId, string $query): Collection + { + $query = strtolower(trim($query)); + + if (empty($query)) { + return $this->getToolsForServer($serverId); + } + + return $this->getToolsForServer($serverId) + ->filter(function ($tool) use ($query) { + return str_contains(strtolower($tool['name']), $query) + || str_contains(strtolower($tool['description']), $query) + || str_contains(strtolower($tool['category']), $query); + }) + ->values(); + } + + /** + * Get a specific tool by name. + */ + public function getTool(string $serverId, string $toolName): ?array + { + return $this->getToolsForServer($serverId) + ->firstWhere('name', $toolName); + } + + /** + * Get example inputs for a tool. + */ + public function getExampleInputs(string $toolName): array + { + return $this->examples[$toolName] ?? []; + } + + /** + * Set custom example inputs for a tool. + */ + public function setExampleInputs(string $toolName, array $examples): void + { + $this->examples[$toolName] = $examples; + } + + /** + * Get all categories across all servers. + * + * @return Collection + */ + public function getAllCategories(): Collection + { + return $this->getServers() + ->flatMap(fn ($server) => $this->getToolsForServer($server['id'])) + ->groupBy('category') + ->map(fn ($tools) => $tools->count()) + ->sortKeys(); + } + + /** + * Get full server configuration. + */ + public function getServerFull(string $serverId): ?array + { + return $this->loadServerFull($serverId); + } + + /** + * Clear cached registry data. + */ + public function clearCache(): void + { + Cache::forget('mcp:playground:servers'); + + foreach ($this->getServers() as $server) { + Cache::forget("mcp:playground:tools:{$server['id']}"); + } + } + + /** + * Extract category from tool definition. + */ + protected function extractCategory(array $tool): string + { + // Check for explicit category + if (isset($tool['category'])) { + return ucfirst($tool['category']); + } + + // Infer from tool name + $name = $tool['name'] ?? ''; + + $categoryPatterns = [ + 'query' => ['query', 'search', 'find', 'get', 'list'], + 'commerce' => ['coupon', 'invoice', 'billing', 'plan', 'payment', 'subscription'], + 'content' => ['content', 'article', 'page', 'post', 'media'], + 'system' => ['table', 'route', 'stat', 'config', 'setting'], + 'user' => ['user', 'auth', 'session', 'permission'], + ]; + + foreach ($categoryPatterns as $category => $patterns) { + foreach ($patterns as $pattern) { + if (str_contains(strtolower($name), $pattern)) { + return ucfirst($category); + } + } + } + + return 'General'; + } + + /** + * Generate example inputs from JSON schema. + */ + protected function generateExampleFromSchema(array $schema): array + { + $properties = $schema['properties'] ?? []; + $examples = []; + + foreach ($properties as $name => $prop) { + $type = is_array($prop['type'] ?? 'string') ? ($prop['type'][0] ?? 'string') : ($prop['type'] ?? 'string'); + + // Use default if available + if (isset($prop['default'])) { + $examples[$name] = $prop['default']; + + continue; + } + + // Use example if available + if (isset($prop['example'])) { + $examples[$name] = $prop['example']; + + continue; + } + + // Use first enum value if available + if (isset($prop['enum']) && ! empty($prop['enum'])) { + $examples[$name] = $prop['enum'][0]; + + continue; + } + + // Generate based on type + $examples[$name] = match ($type) { + 'integer', 'number' => $prop['minimum'] ?? 0, + 'boolean' => false, + 'array' => [], + 'object' => new \stdClass, + default => '', // string + }; + } + + return $examples; + } + + /** + * Load the MCP registry file. + */ + protected function loadRegistry(): array + { + $path = resource_path('mcp/registry.yaml'); + + if (! file_exists($path)) { + return ['servers' => []]; + } + + return Yaml::parseFile($path); + } + + /** + * Load full server configuration. + */ + protected function loadServerFull(string $id): ?array + { + // Sanitise server ID to prevent path traversal + $id = basename($id, '.yaml'); + + if (! preg_match('/^[a-z0-9-]+$/', $id)) { + return null; + } + + $path = resource_path("mcp/servers/{$id}.yaml"); + + if (! file_exists($path)) { + return null; + } + + return Yaml::parseFile($path); + } + + /** + * Load server summary (id, name, tagline, tool count). + */ + protected function loadServerSummary(string $id): ?array + { + $server = $this->loadServerFull($id); + + if (! $server) { + return null; + } + + return [ + 'id' => $server['id'], + 'name' => $server['name'], + 'tagline' => $server['tagline'] ?? '', + 'tool_count' => count($server['tools'] ?? []), + ]; + } +} diff --git a/src/php/src/Mcp/Services/ToolVersionService.php b/src/php/src/Mcp/Services/ToolVersionService.php new file mode 100644 index 0000000..b660826 --- /dev/null +++ b/src/php/src/Mcp/Services/ToolVersionService.php @@ -0,0 +1,478 @@ +isValidSemver($version)) { + throw new \InvalidArgumentException("Invalid semver version: {$version}"); + } + + // Check if version already exists + $existing = McpToolVersion::forServer($serverId) + ->forTool($toolName) + ->forVersion($version) + ->first(); + + if ($existing) { + // Update existing version + $existing->update([ + 'input_schema' => $inputSchema ?? $existing->input_schema, + 'output_schema' => $outputSchema ?? $existing->output_schema, + 'description' => $description ?? $existing->description, + 'changelog' => $options['changelog'] ?? $existing->changelog, + 'migration_notes' => $options['migration_notes'] ?? $existing->migration_notes, + ]); + + if ($options['mark_latest'] ?? false) { + $existing->markAsLatest(); + } + + $this->clearCache($serverId, $toolName); + + return $existing->fresh(); + } + + // Create new version + $toolVersion = McpToolVersion::create([ + 'server_id' => $serverId, + 'tool_name' => $toolName, + 'version' => $version, + 'input_schema' => $inputSchema, + 'output_schema' => $outputSchema, + 'description' => $description, + 'changelog' => $options['changelog'] ?? null, + 'migration_notes' => $options['migration_notes'] ?? null, + 'is_latest' => false, + ]); + + // Mark as latest if requested or if it's the first version + $isFirst = McpToolVersion::forServer($serverId)->forTool($toolName)->count() === 1; + + if (($options['mark_latest'] ?? false) || $isFirst) { + $toolVersion->markAsLatest(); + } + + $this->clearCache($serverId, $toolName); + + Log::info('MCP tool version registered', [ + 'server_id' => $serverId, + 'tool_name' => $toolName, + 'version' => $version, + 'is_latest' => $toolVersion->is_latest, + ]); + + return $toolVersion; + } + + /** + * Get a tool at a specific version. + * + * Returns null if version doesn't exist. Use getLatestVersion() for fallback. + */ + public function getToolAtVersion(string $serverId, string $toolName, string $version): ?McpToolVersion + { + $cacheKey = self::CACHE_PREFIX."{$serverId}:{$toolName}:{$version}"; + + return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($serverId, $toolName, $version) { + return McpToolVersion::forServer($serverId) + ->forTool($toolName) + ->forVersion($version) + ->first(); + }); + } + + /** + * Get the latest version of a tool. + */ + public function getLatestVersion(string $serverId, string $toolName): ?McpToolVersion + { + $cacheKey = self::CACHE_PREFIX."{$serverId}:{$toolName}:latest"; + + return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($serverId, $toolName) { + // First try to find explicitly marked latest + $latest = McpToolVersion::forServer($serverId) + ->forTool($toolName) + ->latest() + ->first(); + + if ($latest) { + return $latest; + } + + // Fallback to newest version by semver + return McpToolVersion::forServer($serverId) + ->forTool($toolName) + ->active() + ->orderByVersion('desc') + ->first(); + }); + } + + /** + * Resolve a tool version, falling back to latest if not specified. + * + * @return array{version: McpToolVersion|null, warning: array|null, error: array|null} + */ + public function resolveVersion(string $serverId, string $toolName, ?string $requestedVersion = null): array + { + // If no version requested, use latest + if ($requestedVersion === null) { + $version = $this->getLatestVersion($serverId, $toolName); + + return [ + 'version' => $version, + 'warning' => null, + 'error' => $version === null ? [ + 'code' => 'TOOL_NOT_FOUND', + 'message' => "No versions found for tool {$serverId}:{$toolName}", + ] : null, + ]; + } + + // Look up specific version + $version = $this->getToolAtVersion($serverId, $toolName, $requestedVersion); + + if (! $version) { + return [ + 'version' => null, + 'warning' => null, + 'error' => [ + 'code' => 'VERSION_NOT_FOUND', + 'message' => "Version {$requestedVersion} not found for tool {$serverId}:{$toolName}", + ], + ]; + } + + // Check if sunset + if ($version->is_sunset) { + return [ + 'version' => null, + 'warning' => null, + 'error' => $version->getSunsetError(), + ]; + } + + // Check if deprecated (warning, not error) + $warning = $version->getDeprecationWarning(); + + return [ + 'version' => $version, + 'warning' => $warning, + 'error' => null, + ]; + } + + /** + * Check if a version is deprecated. + */ + public function isDeprecated(string $serverId, string $toolName, string $version): bool + { + $toolVersion = $this->getToolAtVersion($serverId, $toolName, $version); + + return $toolVersion?->is_deprecated ?? false; + } + + /** + * Check if a version is sunset (blocked). + */ + public function isSunset(string $serverId, string $toolName, string $version): bool + { + $toolVersion = $this->getToolAtVersion($serverId, $toolName, $version); + + return $toolVersion?->is_sunset ?? false; + } + + /** + * Compare two semver versions. + * + * @return int -1 if $a < $b, 0 if equal, 1 if $a > $b + */ + public function compareVersions(string $a, string $b): int + { + return version_compare( + $this->normalizeSemver($a), + $this->normalizeSemver($b) + ); + } + + /** + * Get version history for a tool. + * + * @return Collection + */ + public function getVersionHistory(string $serverId, string $toolName): Collection + { + return McpToolVersion::forServer($serverId) + ->forTool($toolName) + ->orderByVersion('desc') + ->get(); + } + + /** + * Attempt to migrate a tool call from an old version schema to a new one. + * + * This is a best-effort migration that: + * - Preserves arguments that exist in both schemas + * - Applies defaults for new required arguments where possible + * - Returns warnings for arguments that couldn't be migrated + * + * @return array{arguments: array, warnings: array, success: bool} + */ + public function migrateToolCall( + string $serverId, + string $toolName, + string $fromVersion, + string $toVersion, + array $arguments + ): array { + $fromTool = $this->getToolAtVersion($serverId, $toolName, $fromVersion); + $toTool = $this->getToolAtVersion($serverId, $toolName, $toVersion); + + if (! $fromTool || ! $toTool) { + return [ + 'arguments' => $arguments, + 'warnings' => ['Could not load version schemas for migration'], + 'success' => false, + ]; + } + + $toSchema = $toTool->input_schema ?? []; + $toProperties = $toSchema['properties'] ?? []; + $toRequired = $toSchema['required'] ?? []; + + $migratedArgs = []; + $warnings = []; + + // Copy over arguments that exist in the new schema + foreach ($arguments as $key => $value) { + if (isset($toProperties[$key])) { + $migratedArgs[$key] = $value; + } else { + $warnings[] = "Argument '{$key}' removed in version {$toVersion}"; + } + } + + // Check for new required arguments without defaults + foreach ($toRequired as $requiredKey) { + if (! isset($migratedArgs[$requiredKey])) { + // Try to apply default from schema + if (isset($toProperties[$requiredKey]['default'])) { + $migratedArgs[$requiredKey] = $toProperties[$requiredKey]['default']; + $warnings[] = "Applied default value for new required argument '{$requiredKey}'"; + } else { + $warnings[] = "Missing required argument '{$requiredKey}' added in version {$toVersion}"; + } + } + } + + return [ + 'arguments' => $migratedArgs, + 'warnings' => $warnings, + 'success' => empty(array_filter($warnings, fn ($w) => str_starts_with($w, 'Missing required'))), + ]; + } + + /** + * Deprecate a tool version with optional sunset date. + */ + public function deprecateVersion( + string $serverId, + string $toolName, + string $version, + ?Carbon $sunsetAt = null + ): ?McpToolVersion { + $toolVersion = McpToolVersion::forServer($serverId) + ->forTool($toolName) + ->forVersion($version) + ->first(); + + if (! $toolVersion) { + return null; + } + + $toolVersion->deprecate($sunsetAt); + $this->clearCache($serverId, $toolName); + + Log::info('MCP tool version deprecated', [ + 'server_id' => $serverId, + 'tool_name' => $toolName, + 'version' => $version, + 'sunset_at' => $sunsetAt?->toIso8601String(), + ]); + + return $toolVersion; + } + + /** + * Get all tools with version info for a server. + * + * @return Collection + */ + public function getToolsWithVersions(string $serverId): Collection + { + $versions = McpToolVersion::forServer($serverId) + ->orderByVersion('desc') + ->get(); + + return $versions->groupBy('tool_name') + ->map(function ($toolVersions, $toolName) { + return [ + 'tool_name' => $toolName, + 'latest' => $toolVersions->firstWhere('is_latest', true) ?? $toolVersions->first(), + 'versions' => $toolVersions, + 'version_count' => $toolVersions->count(), + 'has_deprecated' => $toolVersions->contains(fn ($v) => $v->is_deprecated), + 'has_sunset' => $toolVersions->contains(fn ($v) => $v->is_sunset), + ]; + }); + } + + /** + * Get all unique servers that have versioned tools. + */ + public function getServersWithVersions(): Collection + { + return McpToolVersion::select('server_id') + ->distinct() + ->orderBy('server_id') + ->pluck('server_id'); + } + + /** + * Sync tool versions from YAML server definitions. + * + * Call this during deployment to register/update versions from server configs. + * + * @param array $serverConfig Parsed YAML server configuration + * @param string $version Version to register (e.g., from deployment tag) + */ + public function syncFromServerConfig(array $serverConfig, string $version, bool $markLatest = true): int + { + $serverId = $serverConfig['id'] ?? null; + $tools = $serverConfig['tools'] ?? []; + + if (! $serverId || empty($tools)) { + return 0; + } + + $registered = 0; + + foreach ($tools as $tool) { + $toolName = $tool['name'] ?? null; + if (! $toolName) { + continue; + } + + $this->registerVersion( + serverId: $serverId, + toolName: $toolName, + version: $version, + inputSchema: $tool['inputSchema'] ?? null, + outputSchema: $tool['outputSchema'] ?? null, + description: $tool['description'] ?? $tool['purpose'] ?? null, + options: [ + 'mark_latest' => $markLatest, + ] + ); + + $registered++; + } + + return $registered; + } + + /** + * Get statistics about tool versions. + */ + public function getStats(): array + { + return [ + 'total_versions' => McpToolVersion::count(), + 'total_tools' => McpToolVersion::select('server_id', 'tool_name') + ->distinct() + ->count(), + 'deprecated_count' => McpToolVersion::deprecated()->count(), + 'sunset_count' => McpToolVersion::sunset()->count(), + 'servers' => $this->getServersWithVersions()->count(), + ]; + } + + // ------------------------------------------------------------------------- + // Protected Methods + // ------------------------------------------------------------------------- + + /** + * Validate semver format. + */ + protected function isValidSemver(string $version): bool + { + // Basic semver pattern: major.minor.patch with optional prerelease/build + $pattern = '/^(\d+)\.(\d+)\.(\d+)(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/'; + + return (bool) preg_match($pattern, $version); + } + + /** + * Normalize semver for comparison (removes prerelease/build metadata). + */ + protected function normalizeSemver(string $version): string + { + // Remove prerelease and build metadata for basic comparison + return preg_replace('/[-+].*$/', '', $version) ?? $version; + } + + /** + * Clear cache for a tool's versions. + */ + protected function clearCache(string $serverId, string $toolName): void + { + // Clear specific version caches would require tracking all versions + // For simplicity, we use a short TTL and let cache naturally expire + Cache::forget(self::CACHE_PREFIX."{$serverId}:{$toolName}:latest"); + } +} diff --git a/src/php/src/Mcp/Tests/Unit/McpQuotaServiceTest.php b/src/php/src/Mcp/Tests/Unit/McpQuotaServiceTest.php new file mode 100644 index 0000000..d8f64bf --- /dev/null +++ b/src/php/src/Mcp/Tests/Unit/McpQuotaServiceTest.php @@ -0,0 +1,1529 @@ +entitlementsMock = Mockery::mock(EntitlementService::class); + $this->quotaService = new McpQuotaService($this->entitlementsMock); + $this->workspace = Workspace::factory()->create(); + }); + + afterEach(function () { + Mockery::close(); + }); + + it('records usage for workspace with all parameters', function () { + $quota = $this->quotaService->recordUsage( + $this->workspace, + toolCalls: 5, + inputTokens: 100, + outputTokens: 50 + ); + + expect($quota)->toBeInstanceOf(McpUsageQuota::class); + expect($quota->tool_calls_count)->toBe(5); + expect($quota->input_tokens)->toBe(100); + expect($quota->output_tokens)->toBe(50); + expect($quota->total_tokens)->toBe(150); + expect($quota->month)->toBe(now()->format('Y-m')); + }); + + it('records usage with workspace ID instead of model', function () { + $quota = $this->quotaService->recordUsage( + $this->workspace->id, + toolCalls: 3, + inputTokens: 50, + outputTokens: 25 + ); + + expect($quota->workspace_id)->toBe($this->workspace->id); + expect($quota->tool_calls_count)->toBe(3); + }); + + it('increments existing usage when recording multiple times', function () { + // First call + $this->quotaService->recordUsage($this->workspace, toolCalls: 5, inputTokens: 100, outputTokens: 50); + + // Second call + $quota = $this->quotaService->recordUsage($this->workspace, toolCalls: 3, inputTokens: 200, outputTokens: 100); + + expect($quota->tool_calls_count)->toBe(8); + expect($quota->input_tokens)->toBe(300); + expect($quota->output_tokens)->toBe(150); + expect($quota->total_tokens)->toBe(450); + }); + + it('records default of 1 tool call when no count specified', function () { + $quota = $this->quotaService->recordUsage($this->workspace); + + expect($quota->tool_calls_count)->toBe(1); + expect($quota->input_tokens)->toBe(0); + expect($quota->output_tokens)->toBe(0); + }); + + it('invalidates cache after recording usage', function () { + $cacheKey = "mcp_usage:{$this->workspace->id}:" . now()->format('Y-m'); + + // Pre-populate cache + Cache::put($cacheKey, ['tool_calls_count' => 0], 60); + + // Record usage + $this->quotaService->recordUsage($this->workspace, toolCalls: 5); + + // Cache should be invalidated + expect(Cache::has($cacheKey))->toBeFalse(); + }); + + it('separates usage between different workspaces', function () { + $workspace2 = Workspace::factory()->create(); + + $this->quotaService->recordUsage($this->workspace, toolCalls: 5); + $this->quotaService->recordUsage($workspace2, toolCalls: 10); + + $quota1 = McpUsageQuota::where('workspace_id', $this->workspace->id)->first(); + $quota2 = McpUsageQuota::where('workspace_id', $workspace2->id)->first(); + + expect($quota1->tool_calls_count)->toBe(5); + expect($quota2->tool_calls_count)->toBe(10); + }); + + it('separates usage between different months', function () { + // Record for current month + $this->quotaService->recordUsage($this->workspace, toolCalls: 5); + + // Create record for previous month directly + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->subMonth()->format('Y-m'), + 'tool_calls_count' => 100, + 'input_tokens' => 0, + 'output_tokens' => 0, + ]); + + $currentMonthQuota = McpUsageQuota::where('workspace_id', $this->workspace->id) + ->where('month', now()->format('Y-m')) + ->first(); + + expect($currentMonthQuota->tool_calls_count)->toBe(5); + }); +}); + +// ============================================================================= +// Quota Checking Tests - Tier Enforcement +// ============================================================================= + +describe('Quota enforcement per tier', function () { + beforeEach(function () { + $this->entitlementsMock = Mockery::mock(EntitlementService::class); + $this->quotaService = new McpQuotaService($this->entitlementsMock); + $this->workspace = Workspace::factory()->create(); + }); + + afterEach(function () { + Mockery::close(); + }); + + it('allows unlimited usage for enterprise tier', function () { + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + // Record substantial usage + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 100000, + 'input_tokens' => 10000000, + 'output_tokens' => 5000000, + ]); + + $result = $this->quotaService->checkQuota($this->workspace); + + expect($result)->toBeTrue(); + }); + + it('enforces free tier limit of 100 tool calls', function () { + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 100, + 'input_tokens' => 0, + 'output_tokens' => 0, + ]); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::allowed(limit: 100, used: 100, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + $result = $this->quotaService->checkQuota($this->workspace); + + expect($result)->toBeFalse(); + }); + + it('enforces starter tier limit of 500 tool calls', function () { + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 500, + 'input_tokens' => 0, + 'output_tokens' => 0, + ]); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::allowed(limit: 500, used: 500, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + $result = $this->quotaService->checkQuota($this->workspace); + + expect($result)->toBeFalse(); + }); + + it('enforces professional tier limit of 2000 tool calls', function () { + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 2000, + 'input_tokens' => 0, + 'output_tokens' => 0, + ]); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::allowed(limit: 2000, used: 2000, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + $result = $this->quotaService->checkQuota($this->workspace); + + expect($result)->toBeFalse(); + }); + + it('enforces business tier limit of 10000 tool calls', function () { + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 10000, + 'input_tokens' => 0, + 'output_tokens' => 0, + ]); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::allowed(limit: 10000, used: 10000, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + $result = $this->quotaService->checkQuota($this->workspace); + + expect($result)->toBeFalse(); + }); + + it('allows usage within free tier limit', function () { + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 50, + 'input_tokens' => 0, + 'output_tokens' => 0, + ]); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::allowed(limit: 100, used: 50, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + $result = $this->quotaService->checkQuota($this->workspace); + + expect($result)->toBeTrue(); + }); + + it('denies access when feature not in plan', function () { + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::denied('Not included in plan', featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + $result = $this->quotaService->checkQuota($this->workspace); + + expect($result)->toBeFalse(); + }); +}); + +// ============================================================================= +// Token Quota Enforcement Tests +// ============================================================================= + +describe('Token quota enforcement', function () { + beforeEach(function () { + $this->entitlementsMock = Mockery::mock(EntitlementService::class); + $this->quotaService = new McpQuotaService($this->entitlementsMock); + $this->workspace = Workspace::factory()->create(); + }); + + afterEach(function () { + Mockery::close(); + }); + + it('enforces token limit independently of tool calls', function () { + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 10, + 'input_tokens' => 500000, + 'output_tokens' => 500000, + ]); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::allowed(limit: 1000, used: 10, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::allowed(limit: 1000000, used: 1000000, featureCode: McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + $result = $this->quotaService->checkQuota($this->workspace); + + expect($result)->toBeFalse(); + }); + + it('allows when tokens within limit but tool calls at limit', function () { + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 50, + 'input_tokens' => 100000, + 'output_tokens' => 100000, + ]); + + // Tool calls at limit + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::allowed(limit: 100, used: 50, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + // Tokens within limit + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::allowed(limit: 1000000, used: 200000, featureCode: McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + $result = $this->quotaService->checkQuota($this->workspace); + + expect($result)->toBeTrue(); + }); + + it('treats missing token feature as allowed', function () { + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 10, + 'input_tokens' => 500000, + 'output_tokens' => 500000, + ]); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::allowed(limit: 100, used: 10, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + // Token feature denied (not tracked separately) + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::denied('Not tracked', featureCode: McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + $result = $this->quotaService->checkQuota($this->workspace); + + expect($result)->toBeTrue(); + }); +}); + +// ============================================================================= +// Detailed Quota Check Tests +// ============================================================================= + +describe('Detailed quota check', function () { + beforeEach(function () { + $this->entitlementsMock = Mockery::mock(EntitlementService::class); + $this->quotaService = new McpQuotaService($this->entitlementsMock); + $this->workspace = Workspace::factory()->create(); + }); + + afterEach(function () { + Mockery::close(); + }); + + it('returns detailed quota information when allowed', function () { + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 30, + 'input_tokens' => 5000, + 'output_tokens' => 3000, + ]); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::allowed(limit: 100, used: 30, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::allowed(limit: 100000, used: 8000, featureCode: McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + $result = $this->quotaService->checkQuotaDetailed($this->workspace); + + expect($result['allowed'])->toBeTrue(); + expect($result['reason'])->toBeNull(); + expect($result['tool_calls']['allowed'])->toBeTrue(); + expect($result['tool_calls']['used'])->toBe(30); + expect($result['tool_calls']['limit'])->toBe(100); + expect($result['tool_calls']['unlimited'])->toBeFalse(); + expect($result['tokens']['allowed'])->toBeTrue(); + expect($result['tokens']['used'])->toBe(8000); + expect($result['tokens']['input_tokens'])->toBe(5000); + expect($result['tokens']['output_tokens'])->toBe(3000); + }); + + it('returns detailed reason when tool calls quota exceeded', function () { + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 100, + 'input_tokens' => 0, + 'output_tokens' => 0, + ]); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::allowed(limit: 100, used: 100, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + $result = $this->quotaService->checkQuotaDetailed($this->workspace); + + expect($result['allowed'])->toBeFalse(); + expect($result['reason'])->toContain('100/100'); + expect($result['tool_calls']['allowed'])->toBeFalse(); + expect($result['tool_calls']['reason'])->toContain('limit reached'); + }); + + it('returns detailed reason when token quota exceeded', function () { + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 10, + 'input_tokens' => 50000, + 'output_tokens' => 50000, + ]); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::allowed(limit: 100, used: 10, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::allowed(limit: 100000, used: 100000, featureCode: McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + $result = $this->quotaService->checkQuotaDetailed($this->workspace); + + expect($result['allowed'])->toBeFalse(); + expect($result['tokens']['allowed'])->toBeFalse(); + expect($result['tokens']['reason'])->toContain('token limit'); + }); + + it('returns detailed reason when feature not in plan', function () { + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::denied('Not included in plan', featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::denied('Not included', featureCode: McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + $result = $this->quotaService->checkQuotaDetailed($this->workspace); + + expect($result['allowed'])->toBeFalse(); + expect($result['tool_calls']['allowed'])->toBeFalse(); + expect($result['tool_calls']['reason'])->toContain('not included'); + }); + + it('returns error for non-existent workspace', function () { + $result = $this->quotaService->checkQuotaDetailed(999999); + + expect($result['allowed'])->toBeFalse(); + expect($result['reason'])->toBe('Workspace not found'); + }); + + it('marks unlimited quotas correctly', function () { + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + $result = $this->quotaService->checkQuotaDetailed($this->workspace); + + expect($result['tool_calls']['unlimited'])->toBeTrue(); + expect($result['tool_calls']['limit'])->toBeNull(); + expect($result['tokens']['unlimited'])->toBeTrue(); + expect($result['tokens']['limit'])->toBeNull(); + }); +}); + +// ============================================================================= +// Remaining Quota Tests +// ============================================================================= + +describe('Remaining quota calculation', function () { + beforeEach(function () { + $this->entitlementsMock = Mockery::mock(EntitlementService::class); + $this->quotaService = new McpQuotaService($this->entitlementsMock); + $this->workspace = Workspace::factory()->create(); + }); + + afterEach(function () { + Mockery::close(); + }); + + it('calculates remaining tool calls correctly', function () { + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 30, + 'input_tokens' => 500, + 'output_tokens' => 500, + ]); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::allowed(limit: 100, used: 30, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::allowed(limit: 5000, used: 1000, featureCode: McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + $remaining = $this->quotaService->getRemainingQuota($this->workspace); + + expect($remaining['tool_calls'])->toBe(70); + expect($remaining['tokens'])->toBe(4000); + expect($remaining['tool_calls_unlimited'])->toBeFalse(); + expect($remaining['tokens_unlimited'])->toBeFalse(); + }); + + it('returns null remaining for unlimited quotas', function () { + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + $remaining = $this->quotaService->getRemainingQuota($this->workspace); + + expect($remaining['tool_calls'])->toBeNull(); + expect($remaining['tokens'])->toBeNull(); + expect($remaining['tool_calls_unlimited'])->toBeTrue(); + expect($remaining['tokens_unlimited'])->toBeTrue(); + }); + + it('returns zero remaining when quota exceeded', function () { + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 150, + 'input_tokens' => 0, + 'output_tokens' => 0, + ]); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::allowed(limit: 100, used: 150, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + $remaining = $this->quotaService->getRemainingQuota($this->workspace); + + expect($remaining['tool_calls'])->toBe(0); + }); + + it('returns zero for non-existent workspace', function () { + $remaining = $this->quotaService->getRemainingQuota(999999); + + expect($remaining['tool_calls'])->toBe(0); + expect($remaining['tokens'])->toBe(0); + expect($remaining['tool_calls_unlimited'])->toBeFalse(); + expect($remaining['tokens_unlimited'])->toBeFalse(); + }); +}); + +// ============================================================================= +// Quota Reset Tests +// ============================================================================= + +describe('Quota reset', function () { + beforeEach(function () { + $this->entitlementsMock = Mockery::mock(EntitlementService::class); + $this->quotaService = new McpQuotaService($this->entitlementsMock); + $this->workspace = Workspace::factory()->create(); + }); + + afterEach(function () { + Mockery::close(); + }); + + it('resets monthly quota to zero', function () { + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 50, + 'input_tokens' => 1000, + 'output_tokens' => 500, + ]); + + $quota = $this->quotaService->resetMonthlyQuota($this->workspace); + + expect($quota->tool_calls_count)->toBe(0); + expect($quota->input_tokens)->toBe(0); + expect($quota->output_tokens)->toBe(0); + }); + + it('invalidates cache after reset', function () { + $cacheKey = "mcp_usage:{$this->workspace->id}:" . now()->format('Y-m'); + + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 50, + 'input_tokens' => 0, + 'output_tokens' => 0, + ]); + + // Pre-populate cache + Cache::put($cacheKey, ['tool_calls_count' => 50], 60); + + $this->quotaService->resetMonthlyQuota($this->workspace); + + expect(Cache::has($cacheKey))->toBeFalse(); + }); + + it('creates quota record if none exists during reset', function () { + $quota = $this->quotaService->resetMonthlyQuota($this->workspace); + + expect($quota)->toBeInstanceOf(McpUsageQuota::class); + expect($quota->tool_calls_count)->toBe(0); + }); +}); + +// ============================================================================= +// Usage History Tests +// ============================================================================= + +describe('Usage history', function () { + beforeEach(function () { + $this->entitlementsMock = Mockery::mock(EntitlementService::class); + $this->quotaService = new McpQuotaService($this->entitlementsMock); + $this->workspace = Workspace::factory()->create(); + }); + + afterEach(function () { + Mockery::close(); + }); + + it('returns usage history ordered by month descending', function () { + foreach (['2026-01', '2025-12', '2025-11'] as $month) { + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => $month, + 'tool_calls_count' => rand(10, 100), + 'input_tokens' => rand(100, 1000), + 'output_tokens' => rand(100, 1000), + ]); + } + + $history = $this->quotaService->getUsageHistory($this->workspace, 3); + + expect($history)->toHaveCount(3); + expect($history->first()->month)->toBe('2026-01'); + expect($history->last()->month)->toBe('2025-11'); + }); + + it('limits history to specified number of months', function () { + foreach (['2026-01', '2025-12', '2025-11', '2025-10', '2025-09'] as $month) { + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => $month, + 'tool_calls_count' => 10, + 'input_tokens' => 0, + 'output_tokens' => 0, + ]); + } + + $history = $this->quotaService->getUsageHistory($this->workspace, 3); + + expect($history)->toHaveCount(3); + }); + + it('returns empty collection for workspace with no history', function () { + $history = $this->quotaService->getUsageHistory($this->workspace, 12); + + expect($history)->toBeEmpty(); + }); + + it('accepts workspace ID instead of model', function () { + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 50, + 'input_tokens' => 0, + 'output_tokens' => 0, + ]); + + $history = $this->quotaService->getUsageHistory($this->workspace->id, 12); + + expect($history)->toHaveCount(1); + }); +}); + +// ============================================================================= +// Quota Headers Tests +// ============================================================================= + +describe('Quota headers', function () { + beforeEach(function () { + $this->entitlementsMock = Mockery::mock(EntitlementService::class); + $this->quotaService = new McpQuotaService($this->entitlementsMock); + $this->workspace = Workspace::factory()->create(); + }); + + afterEach(function () { + Mockery::close(); + }); + + it('returns correct quota headers for limited plan', function () { + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 25, + 'input_tokens' => 300, + 'output_tokens' => 200, + ]); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::allowed(limit: 100, used: 25, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::allowed(limit: 10000, used: 500, featureCode: McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + $headers = $this->quotaService->getQuotaHeaders($this->workspace); + + expect($headers['X-MCP-Quota-Tool-Calls-Used'])->toBe('25'); + expect($headers['X-MCP-Quota-Tool-Calls-Limit'])->toBe('100'); + expect($headers['X-MCP-Quota-Tool-Calls-Remaining'])->toBe('75'); + expect($headers['X-MCP-Quota-Tokens-Used'])->toBe('500'); + expect($headers['X-MCP-Quota-Tokens-Limit'])->toBe('10000'); + expect($headers)->toHaveKey('X-MCP-Quota-Reset'); + }); + + it('returns unlimited indicators for unlimited plan', function () { + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + $headers = $this->quotaService->getQuotaHeaders($this->workspace); + + expect($headers['X-MCP-Quota-Tool-Calls-Limit'])->toBe('unlimited'); + expect($headers['X-MCP-Quota-Tool-Calls-Remaining'])->toBe('unlimited'); + expect($headers['X-MCP-Quota-Tokens-Limit'])->toBe('unlimited'); + expect($headers['X-MCP-Quota-Tokens-Remaining'])->toBe('unlimited'); + }); + + it('includes reset time as ISO 8601', function () { + $this->entitlementsMock + ->shouldReceive('can') + ->andReturn(EntitlementResult::unlimited('test')); + + $headers = $this->quotaService->getQuotaHeaders($this->workspace); + + expect($headers['X-MCP-Quota-Reset'])->toMatch('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/'); + expect($headers['X-MCP-Quota-Reset'])->toContain(now()->format('Y-m')); + }); +}); + +// ============================================================================= +// Quota Limits Retrieval Tests +// ============================================================================= + +describe('Quota limits retrieval', function () { + beforeEach(function () { + $this->entitlementsMock = Mockery::mock(EntitlementService::class); + $this->quotaService = new McpQuotaService($this->entitlementsMock); + $this->workspace = Workspace::factory()->create(); + }); + + afterEach(function () { + Mockery::close(); + Cache::flush(); + }); + + it('returns quota limits from entitlements', function () { + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::allowed(limit: 500, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::allowed(limit: 100000, featureCode: McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + $limits = $this->quotaService->getQuotaLimits($this->workspace); + + expect($limits['tool_calls_limit'])->toBe(500); + expect($limits['tokens_limit'])->toBe(100000); + expect($limits['tool_calls_unlimited'])->toBeFalse(); + expect($limits['tokens_unlimited'])->toBeFalse(); + }); + + it('returns null limits for unlimited plans', function () { + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + $limits = $this->quotaService->getQuotaLimits($this->workspace); + + expect($limits['tool_calls_limit'])->toBeNull(); + expect($limits['tokens_limit'])->toBeNull(); + expect($limits['tool_calls_unlimited'])->toBeTrue(); + expect($limits['tokens_unlimited'])->toBeTrue(); + }); + + it('returns zero limits for non-existent workspace', function () { + $limits = $this->quotaService->getQuotaLimits(999999); + + expect($limits['tool_calls_limit'])->toBe(0); + expect($limits['tokens_limit'])->toBe(0); + expect($limits['tool_calls_unlimited'])->toBeFalse(); + expect($limits['tokens_unlimited'])->toBeFalse(); + }); + + it('caches quota limits', function () { + $this->entitlementsMock + ->shouldReceive('can') + ->once() + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::allowed(limit: 500, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + $this->entitlementsMock + ->shouldReceive('can') + ->once() + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::allowed(limit: 100000, featureCode: McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + // First call + $this->quotaService->getQuotaLimits($this->workspace); + + // Second call should use cache (mock only allows once) + $limits = $this->quotaService->getQuotaLimits($this->workspace); + + expect($limits['tool_calls_limit'])->toBe(500); + }); +}); + +// ============================================================================= +// CheckMcpQuota Middleware Tests +// ============================================================================= + +describe('CheckMcpQuota middleware', function () { + beforeEach(function () { + $this->entitlementsMock = Mockery::mock(EntitlementService::class); + $this->quotaService = new McpQuotaService($this->entitlementsMock); + $this->middleware = new CheckMcpQuota($this->quotaService); + $this->workspace = Workspace::factory()->create(); + }); + + afterEach(function () { + Mockery::close(); + }); + + it('allows request when quota not exceeded', function () { + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::allowed(limit: 100, used: 10, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + $request = Request::create('/api/mcp/tools/call', 'POST'); + $request->attributes->set('workspace', $this->workspace); + + $response = $this->middleware->handle($request, function ($req) { + return response()->json(['success' => true]); + }); + + expect($response->getStatusCode())->toBe(200); + expect($response->headers->has('X-MCP-Quota-Tool-Calls-Used'))->toBeTrue(); + }); + + it('returns 429 when quota exceeded', function () { + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 100, + 'input_tokens' => 0, + 'output_tokens' => 0, + ]); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::allowed(limit: 100, used: 100, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + $request = Request::create('/api/mcp/tools/call', 'POST'); + $request->attributes->set('workspace', $this->workspace); + + $response = $this->middleware->handle($request, function ($req) { + return response()->json(['success' => true]); + }); + + expect($response->getStatusCode())->toBe(429); + + $content = json_decode($response->getContent(), true); + expect($content['error'])->toBe('quota_exceeded'); + expect($content)->toHaveKey('quota'); + expect($content)->toHaveKey('upgrade_hint'); + }); + + it('skips quota check when no workspace context', function () { + $request = Request::create('/api/mcp/tools/call', 'POST'); + // No workspace attribute set + + $response = $this->middleware->handle($request, function ($req) { + return response()->json(['success' => true]); + }); + + expect($response->getStatusCode())->toBe(200); + }); + + it('adds quota headers to response', function () { + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::allowed(limit: 100, used: 10, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + $request = Request::create('/api/mcp/tools/call', 'POST'); + $request->attributes->set('workspace', $this->workspace); + + $response = $this->middleware->handle($request, function ($req) { + return response()->json(['success' => true]); + }); + + expect($response->headers->has('X-MCP-Quota-Tool-Calls-Used'))->toBeTrue(); + expect($response->headers->has('X-MCP-Quota-Tool-Calls-Limit'))->toBeTrue(); + expect($response->headers->has('X-MCP-Quota-Tool-Calls-Remaining'))->toBeTrue(); + expect($response->headers->has('X-MCP-Quota-Tokens-Used'))->toBeTrue(); + expect($response->headers->has('X-MCP-Quota-Reset'))->toBeTrue(); + }); + + it('includes resets_at in exceeded response', function () { + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 100, + 'input_tokens' => 0, + 'output_tokens' => 0, + ]); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::allowed(limit: 100, used: 100, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + $request = Request::create('/api/mcp/tools/call', 'POST'); + $request->attributes->set('workspace', $this->workspace); + + $response = $this->middleware->handle($request, function ($req) { + return response()->json(['success' => true]); + }); + + $content = json_decode($response->getContent(), true); + expect($content['quota']['resets_at'])->toMatch('/^\d{4}-\d{2}-\d{2}T/'); + }); +}); + +// ============================================================================= +// Workspace-Scoped Quota Tests +// ============================================================================= + +describe('Workspace-scoped quotas', function () { + beforeEach(function () { + $this->entitlementsMock = Mockery::mock(EntitlementService::class); + $this->quotaService = new McpQuotaService($this->entitlementsMock); + $this->workspace1 = Workspace::factory()->create(); + $this->workspace2 = Workspace::factory()->create(); + }); + + afterEach(function () { + Mockery::close(); + }); + + it('tracks usage separately per workspace', function () { + $this->quotaService->recordUsage($this->workspace1, toolCalls: 50); + $this->quotaService->recordUsage($this->workspace2, toolCalls: 75); + + $usage1 = $this->quotaService->getCurrentUsage($this->workspace1); + $usage2 = $this->quotaService->getCurrentUsage($this->workspace2); + + expect($usage1['tool_calls_count'])->toBe(50); + expect($usage2['tool_calls_count'])->toBe(75); + }); + + it('enforces limits independently per workspace', function () { + McpUsageQuota::create([ + 'workspace_id' => $this->workspace1->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 100, + 'input_tokens' => 0, + 'output_tokens' => 0, + ]); + + McpUsageQuota::create([ + 'workspace_id' => $this->workspace2->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 50, + 'input_tokens' => 0, + 'output_tokens' => 0, + ]); + + // Workspace 1 at limit + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace1, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::allowed(limit: 100, used: 100, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace1, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + // Workspace 2 has headroom + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace2, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::allowed(limit: 100, used: 50, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace2, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + expect($this->quotaService->checkQuota($this->workspace1))->toBeFalse(); + expect($this->quotaService->checkQuota($this->workspace2))->toBeTrue(); + }); + + it('resets only the specified workspace quota', function () { + McpUsageQuota::create([ + 'workspace_id' => $this->workspace1->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 50, + 'input_tokens' => 0, + 'output_tokens' => 0, + ]); + + McpUsageQuota::create([ + 'workspace_id' => $this->workspace2->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 75, + 'input_tokens' => 0, + 'output_tokens' => 0, + ]); + + $this->quotaService->resetMonthlyQuota($this->workspace1); + + $usage1 = $this->quotaService->getCurrentUsage($this->workspace1); + $usage2 = $this->quotaService->getCurrentUsage($this->workspace2); + + expect($usage1['tool_calls_count'])->toBe(0); + expect($usage2['tool_calls_count'])->toBe(75); + }); +}); + +// ============================================================================= +// McpUsageQuota Model Tests +// ============================================================================= + +describe('McpUsageQuota model', function () { + beforeEach(function () { + $this->workspace = Workspace::factory()->create(); + }); + + it('gets or creates quota for workspace', function () { + $quota = McpUsageQuota::getOrCreate($this->workspace->id); + + expect($quota)->toBeInstanceOf(McpUsageQuota::class); + expect($quota->workspace_id)->toBe($this->workspace->id); + expect($quota->month)->toBe(now()->format('Y-m')); + expect($quota->tool_calls_count)->toBe(0); + }); + + it('returns existing quota when present', function () { + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 42, + 'input_tokens' => 100, + 'output_tokens' => 50, + ]); + + $quota = McpUsageQuota::getOrCreate($this->workspace->id); + + expect($quota->tool_calls_count)->toBe(42); + }); + + it('records usage atomically', function () { + $quota = McpUsageQuota::getOrCreate($this->workspace->id); + $quota->recordUsage(toolCalls: 5, inputTokens: 100, outputTokens: 50); + + $fresh = $quota->fresh(); + expect($fresh->tool_calls_count)->toBe(5); + expect($fresh->input_tokens)->toBe(100); + expect($fresh->output_tokens)->toBe(50); + }); + + it('calculates total tokens accessor', function () { + $quota = McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 10, + 'input_tokens' => 500, + 'output_tokens' => 300, + ]); + + expect($quota->total_tokens)->toBe(800); + }); + + it('formats month label correctly', function () { + $quota = McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => '2026-01', + 'tool_calls_count' => 0, + 'input_tokens' => 0, + 'output_tokens' => 0, + ]); + + expect($quota->month_label)->toBe('January 2026'); + }); + + it('resets all counters', function () { + $quota = McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 100, + 'input_tokens' => 5000, + 'output_tokens' => 3000, + ]); + + $quota->reset(); + + expect($quota->tool_calls_count)->toBe(0); + expect($quota->input_tokens)->toBe(0); + expect($quota->output_tokens)->toBe(0); + }); + + it('scopes by month', function () { + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => '2026-01', + 'tool_calls_count' => 50, + 'input_tokens' => 0, + 'output_tokens' => 0, + ]); + + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => '2025-12', + 'tool_calls_count' => 100, + 'input_tokens' => 0, + 'output_tokens' => 0, + ]); + + $quotas = McpUsageQuota::forMonth('2026-01')->get(); + + expect($quotas)->toHaveCount(1); + expect($quotas->first()->tool_calls_count)->toBe(50); + }); + + it('scopes to current month', function () { + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 25, + 'input_tokens' => 0, + 'output_tokens' => 0, + ]); + + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->subMonth()->format('Y-m'), + 'tool_calls_count' => 100, + 'input_tokens' => 0, + 'output_tokens' => 0, + ]); + + $quotas = McpUsageQuota::currentMonth()->get(); + + expect($quotas)->toHaveCount(1); + expect($quotas->first()->tool_calls_count)->toBe(25); + }); + + it('belongs to workspace', function () { + $quota = McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 0, + 'input_tokens' => 0, + 'output_tokens' => 0, + ]); + + expect($quota->workspace)->toBeInstanceOf(Workspace::class); + expect($quota->workspace->id)->toBe($this->workspace->id); + }); + + it('converts to array for API responses', function () { + $quota = McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => '2026-01', + 'tool_calls_count' => 50, + 'input_tokens' => 500, + 'output_tokens' => 300, + ]); + + $array = $quota->toArray(); + + expect($array)->toHaveKey('workspace_id'); + expect($array)->toHaveKey('month'); + expect($array)->toHaveKey('month_label'); + expect($array)->toHaveKey('tool_calls_count'); + expect($array)->toHaveKey('input_tokens'); + expect($array)->toHaveKey('output_tokens'); + expect($array)->toHaveKey('total_tokens'); + expect($array['total_tokens'])->toBe(800); + }); + + it('uses static record method correctly', function () { + $quota = McpUsageQuota::record( + $this->workspace->id, + toolCalls: 3, + inputTokens: 100, + outputTokens: 50 + ); + + expect($quota->tool_calls_count)->toBe(3); + expect($quota->input_tokens)->toBe(100); + expect($quota->output_tokens)->toBe(50); + }); +}); + +// ============================================================================= +// Edge Cases and Concurrent Request Tests +// ============================================================================= + +describe('Edge cases', function () { + beforeEach(function () { + $this->entitlementsMock = Mockery::mock(EntitlementService::class); + $this->quotaService = new McpQuotaService($this->entitlementsMock); + $this->workspace = Workspace::factory()->create(); + }); + + afterEach(function () { + Mockery::close(); + }); + + it('handles zero token usage', function () { + $quota = $this->quotaService->recordUsage($this->workspace, toolCalls: 1, inputTokens: 0, outputTokens: 0); + + expect($quota->input_tokens)->toBe(0); + expect($quota->output_tokens)->toBe(0); + expect($quota->total_tokens)->toBe(0); + }); + + it('handles very large usage numbers', function () { + $quota = $this->quotaService->recordUsage( + $this->workspace, + toolCalls: 1000000, + inputTokens: 1000000000, + outputTokens: 500000000 + ); + + expect($quota->tool_calls_count)->toBe(1000000); + expect($quota->input_tokens)->toBe(1000000000); + expect($quota->output_tokens)->toBe(500000000); + }); + + it('handles boundary condition at exactly limit', function () { + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 99, + 'input_tokens' => 0, + 'output_tokens' => 0, + ]); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::allowed(limit: 100, used: 99, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + // Should allow as we're at 99/100 + expect($this->quotaService->checkQuota($this->workspace))->toBeTrue(); + }); + + it('handles new workspace with no usage record', function () { + $newWorkspace = Workspace::factory()->create(); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($newWorkspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) + ->andReturn(EntitlementResult::allowed(limit: 100, used: 0, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); + + $this->entitlementsMock + ->shouldReceive('can') + ->with($newWorkspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) + ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOKENS)); + + $result = $this->quotaService->checkQuota($newWorkspace); + + expect($result)->toBeTrue(); + }); + + it('handles concurrent usage recording via atomic increment', function () { + // Create initial quota + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 0, + 'input_tokens' => 0, + 'output_tokens' => 0, + ]); + + // Simulate concurrent recordings + $this->quotaService->recordUsage($this->workspace, toolCalls: 1); + $this->quotaService->recordUsage($this->workspace, toolCalls: 1); + $this->quotaService->recordUsage($this->workspace, toolCalls: 1); + + $usage = $this->quotaService->getCurrentUsage($this->workspace); + + expect($usage['tool_calls_count'])->toBe(3); + }); + + it('handles cache invalidation race conditions', function () { + $cacheKey = "mcp_usage:{$this->workspace->id}:" . now()->format('Y-m'); + + // Pre-populate cache + Cache::put($cacheKey, ['tool_calls_count' => 10], 60); + + // Record new usage + $this->quotaService->recordUsage($this->workspace, toolCalls: 5); + + // Cache should be invalidated + expect(Cache::has($cacheKey))->toBeFalse(); + + // Fresh read should reflect actual database state + $usage = $this->quotaService->getCurrentUsage($this->workspace); + expect($usage['tool_calls_count'])->toBe(5); + }); + + it('handles month boundary transitions', function () { + // Create usage for previous month + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->subMonth()->format('Y-m'), + 'tool_calls_count' => 100, + 'input_tokens' => 10000, + 'output_tokens' => 5000, + ]); + + $usage = $this->quotaService->getCurrentUsage($this->workspace); + + // Should return zero for current month (new month) + expect($usage['tool_calls_count'])->toBe(0); + expect($usage['month'])->toBe(now()->format('Y-m')); + }); +}); + +// ============================================================================= +// Cache Management Tests +// ============================================================================= + +describe('Cache management', function () { + beforeEach(function () { + $this->entitlementsMock = Mockery::mock(EntitlementService::class); + $this->quotaService = new McpQuotaService($this->entitlementsMock); + $this->workspace = Workspace::factory()->create(); + Cache::flush(); + }); + + afterEach(function () { + Mockery::close(); + Cache::flush(); + }); + + it('caches current usage for performance', function () { + McpUsageQuota::create([ + 'workspace_id' => $this->workspace->id, + 'month' => now()->format('Y-m'), + 'tool_calls_count' => 50, + 'input_tokens' => 0, + 'output_tokens' => 0, + ]); + + // First call should hit database + $usage1 = $this->quotaService->getCurrentUsage($this->workspace); + + // Modify database directly + McpUsageQuota::where('workspace_id', $this->workspace->id)->update(['tool_calls_count' => 100]); + + // Second call should return cached value + $usage2 = $this->quotaService->getCurrentUsage($this->workspace); + + expect($usage1['tool_calls_count'])->toBe(50); + expect($usage2['tool_calls_count'])->toBe(50); // Still cached value + }); + + it('invalidates both usage and limits cache', function () { + $usageCacheKey = "mcp_usage:{$this->workspace->id}:" . now()->format('Y-m'); + $limitsCacheKey = "mcp_quota_limits:{$this->workspace->id}"; + + Cache::put($usageCacheKey, ['tool_calls_count' => 10], 60); + Cache::put($limitsCacheKey, ['tool_calls_limit' => 100], 60); + + $this->quotaService->invalidateUsageCache($this->workspace->id); + + expect(Cache::has($usageCacheKey))->toBeFalse(); + expect(Cache::has($limitsCacheKey))->toBeFalse(); + }); +}); diff --git a/src/php/src/Mcp/Tests/Unit/QueryAuditServiceTest.php b/src/php/src/Mcp/Tests/Unit/QueryAuditServiceTest.php new file mode 100644 index 0000000..f2a7184 --- /dev/null +++ b/src/php/src/Mcp/Tests/Unit/QueryAuditServiceTest.php @@ -0,0 +1,283 @@ +auditService = new QueryAuditService(); + } + + public function test_record_logs_success_status(): void + { + Log::shouldReceive('channel') + ->andReturnSelf(); + + Log::shouldReceive('log') + ->once() + ->withArgs(function ($level, $message, $context) { + return $level === 'info' + && $message === 'MCP query audit' + && $context['status'] === QueryAuditService::STATUS_SUCCESS + && str_contains($context['query'], 'SELECT'); + }); + + $this->auditService->record( + query: 'SELECT * FROM users', + bindings: [], + status: QueryAuditService::STATUS_SUCCESS, + durationMs: 50, + rowCount: 10 + ); + } + + public function test_record_logs_blocked_status_with_warning_level(): void + { + Log::shouldReceive('channel') + ->andReturnSelf(); + + Log::shouldReceive('log') + ->once() + ->withArgs(function ($level, $message, $context) { + return $level === 'warning' + && $context['status'] === QueryAuditService::STATUS_BLOCKED; + }); + + // Security channel logging for blocked queries + Log::shouldReceive('channel') + ->with('security') + ->andReturnSelf(); + + Log::shouldReceive('warning') + ->once() + ->withArgs(function ($message, $context) { + return $context['type'] === 'mcp_query_blocked'; + }); + + $this->auditService->record( + query: 'SELECT * FROM users; DROP TABLE users;', + bindings: [], + status: QueryAuditService::STATUS_BLOCKED, + errorMessage: 'Multiple statements detected' + ); + } + + public function test_record_logs_timeout_status(): void + { + Log::shouldReceive('channel') + ->andReturnSelf(); + + Log::shouldReceive('log') + ->once() + ->withArgs(function ($level, $message, $context) { + return $level === 'warning' + && $context['status'] === QueryAuditService::STATUS_TIMEOUT + && $context['error_code'] === 'QUERY_TIMEOUT'; + }); + + $this->auditService->recordTimeout( + query: 'SELECT * FROM large_table', + bindings: [], + timeoutSeconds: 30, + workspaceId: 1 + ); + } + + public function test_record_logs_truncated_status(): void + { + Log::shouldReceive('channel') + ->andReturnSelf(); + + Log::shouldReceive('log') + ->once() + ->withArgs(function ($level, $message, $context) { + return $level === 'notice' + && $context['status'] === QueryAuditService::STATUS_TRUNCATED + && $context['error_code'] === 'RESULT_TRUNCATED' + && $context['truncated_at'] === 100; + }); + + $this->auditService->recordTruncated( + query: 'SELECT * FROM users', + bindings: [], + durationMs: 150, + returnedRows: 500, + maxRows: 100, + workspaceId: 1 + ); + } + + public function test_record_logs_error_status(): void + { + Log::shouldReceive('channel') + ->andReturnSelf(); + + Log::shouldReceive('log') + ->once() + ->withArgs(function ($level, $message, $context) { + return $level === 'error' + && $context['status'] === QueryAuditService::STATUS_ERROR + && str_contains($context['error_message'], 'Table not found'); + }); + + $this->auditService->recordError( + query: 'SELECT * FROM nonexistent', + bindings: [], + errorMessage: 'Table not found', + durationMs: 5 + ); + } + + public function test_record_includes_workspace_and_user_context(): void + { + Log::shouldReceive('channel') + ->andReturnSelf(); + + Log::shouldReceive('log') + ->once() + ->withArgs(function ($level, $message, $context) { + return $context['workspace_id'] === 123 + && $context['user_id'] === 456 + && $context['user_ip'] === '192.168.1.1'; + }); + + $this->auditService->recordSuccess( + query: 'SELECT 1', + bindings: [], + durationMs: 1, + rowCount: 1, + workspaceId: 123, + userId: 456, + userIp: '192.168.1.1' + ); + } + + public function test_record_includes_session_and_tier_context(): void + { + Log::shouldReceive('channel') + ->andReturnSelf(); + + Log::shouldReceive('log') + ->once() + ->withArgs(function ($level, $message, $context) { + return $context['session_id'] === 'test-session-123' + && $context['tier'] === 'enterprise'; + }); + + $this->auditService->recordSuccess( + query: 'SELECT 1', + bindings: [], + durationMs: 1, + rowCount: 1, + context: [ + 'session_id' => 'test-session-123', + 'tier' => 'enterprise', + ] + ); + } + + public function test_record_sanitises_long_queries(): void + { + Log::shouldReceive('channel') + ->andReturnSelf(); + + Log::shouldReceive('log') + ->once() + ->withArgs(function ($level, $message, $context) { + return strlen($context['query']) <= 2013 // 2000 + length of "... [TRUNCATED]" + && str_contains($context['query'], '[TRUNCATED]'); + }); + + $longQuery = 'SELECT ' . str_repeat('a', 3000) . ' FROM table'; + + $this->auditService->recordSuccess( + query: $longQuery, + bindings: [], + durationMs: 1, + rowCount: 1 + ); + } + + public function test_record_sanitises_error_messages(): void + { + Log::shouldReceive('channel') + ->andReturnSelf(); + + Log::shouldReceive('log') + ->once() + ->withArgs(function ($level, $message, $context) { + return str_contains($context['error_message'], '[path]') + && str_contains($context['error_message'], '[ip]') + && ! str_contains($context['error_message'], '/var/www') + && ! str_contains($context['error_message'], '192.168.1.100'); + }); + + $this->auditService->recordError( + query: 'SELECT 1', + bindings: [], + errorMessage: 'Error at /var/www/app/file.php connecting to 192.168.1.100' + ); + } + + public function test_blocked_queries_also_log_to_security_channel(): void + { + Log::shouldReceive('channel') + ->with('mcp-queries') + ->andReturnSelf(); + + Log::shouldReceive('log') + ->once(); + + Log::shouldReceive('channel') + ->with('security') + ->andReturnSelf(); + + Log::shouldReceive('warning') + ->once() + ->withArgs(function ($message, $context) { + return $message === 'MCP query blocked by security policy' + && $context['type'] === 'mcp_query_blocked' + && isset($context['query_hash']) + && $context['reason'] === 'SQL injection detected'; + }); + + $this->auditService->recordBlocked( + query: "SELECT * FROM users WHERE id = '1' OR '1'='1'", + bindings: [], + reason: 'SQL injection detected', + workspaceId: 1, + userId: 2, + userIp: '10.0.0.1' + ); + } + + public function test_record_counts_bindings_without_logging_values(): void + { + Log::shouldReceive('channel') + ->andReturnSelf(); + + Log::shouldReceive('log') + ->once() + ->withArgs(function ($level, $message, $context) { + return $context['bindings_count'] === 3; + }); + + $this->auditService->recordSuccess( + query: 'SELECT * FROM users WHERE id = ? AND status = ? AND role = ?', + bindings: [1, 'active', 'admin'], + durationMs: 10, + rowCount: 1 + ); + } +} diff --git a/src/php/src/Mcp/Tests/Unit/QueryExecutionServiceTest.php b/src/php/src/Mcp/Tests/Unit/QueryExecutionServiceTest.php new file mode 100644 index 0000000..3acda27 --- /dev/null +++ b/src/php/src/Mcp/Tests/Unit/QueryExecutionServiceTest.php @@ -0,0 +1,250 @@ +auditMock = Mockery::mock(QueryAuditService::class); + $this->auditMock->shouldReceive('recordSuccess')->byDefault(); + $this->auditMock->shouldReceive('recordTruncated')->byDefault(); + $this->auditMock->shouldReceive('recordError')->byDefault(); + $this->auditMock->shouldReceive('recordTimeout')->byDefault(); + + $this->executionService = new QueryExecutionService($this->auditMock); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function test_get_limits_for_tier_returns_correct_defaults(): void + { + $freeLimits = $this->executionService->getLimitsForTier('free'); + $this->assertEquals(100, $freeLimits['max_rows']); + $this->assertEquals(5, $freeLimits['timeout_seconds']); + + $starterLimits = $this->executionService->getLimitsForTier('starter'); + $this->assertEquals(500, $starterLimits['max_rows']); + $this->assertEquals(10, $starterLimits['timeout_seconds']); + + $professionalLimits = $this->executionService->getLimitsForTier('professional'); + $this->assertEquals(1000, $professionalLimits['max_rows']); + $this->assertEquals(30, $professionalLimits['timeout_seconds']); + + $enterpriseLimits = $this->executionService->getLimitsForTier('enterprise'); + $this->assertEquals(5000, $enterpriseLimits['max_rows']); + $this->assertEquals(60, $enterpriseLimits['timeout_seconds']); + + $unlimitedLimits = $this->executionService->getLimitsForTier('unlimited'); + $this->assertEquals(10000, $unlimitedLimits['max_rows']); + $this->assertEquals(120, $unlimitedLimits['timeout_seconds']); + } + + public function test_get_limits_for_tier_uses_config_overrides(): void + { + Config::set('mcp.database.tier_limits', [ + 'free' => [ + 'max_rows' => 50, + 'timeout_seconds' => 3, + ], + ]); + + $limits = $this->executionService->getLimitsForTier('free'); + + $this->assertEquals(50, $limits['max_rows']); + $this->assertEquals(3, $limits['timeout_seconds']); + } + + public function test_get_limits_for_unknown_tier_falls_back_to_free(): void + { + $limits = $this->executionService->getLimitsForTier('nonexistent'); + + $this->assertEquals(100, $limits['max_rows']); + $this->assertEquals(5, $limits['timeout_seconds']); + } + + public function test_get_available_tiers_returns_all_tiers(): void + { + $tiers = $this->executionService->getAvailableTiers(); + + $this->assertArrayHasKey('free', $tiers); + $this->assertArrayHasKey('starter', $tiers); + $this->assertArrayHasKey('professional', $tiers); + $this->assertArrayHasKey('enterprise', $tiers); + $this->assertArrayHasKey('unlimited', $tiers); + + foreach ($tiers as $tier => $limits) { + $this->assertArrayHasKey('max_rows', $limits); + $this->assertArrayHasKey('timeout_seconds', $limits); + } + } + + public function test_execute_returns_data_with_metadata(): void + { + // Use SQLite in-memory for testing + Config::set('database.default', 'sqlite'); + Config::set('database.connections.sqlite', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + DB::connection('sqlite')->statement('CREATE TABLE test_table (id INTEGER PRIMARY KEY, name TEXT)'); + DB::connection('sqlite')->insert('INSERT INTO test_table (id, name) VALUES (1, "Test")'); + + $this->auditMock->shouldReceive('recordSuccess') + ->once() + ->withArgs(function ($query, $bindings, $durationMs, $rowCount) { + return str_contains($query, 'test_table') && $rowCount === 1; + }); + + $result = $this->executionService->execute( + query: 'SELECT * FROM test_table', + connection: 'sqlite' + ); + + $this->assertArrayHasKey('data', $result); + $this->assertArrayHasKey('meta', $result); + $this->assertCount(1, $result['data']); + $this->assertEquals(1, $result['meta']['rows_returned']); + $this->assertFalse($result['meta']['truncated']); + $this->assertNull($result['meta']['warning']); + } + + public function test_execute_truncates_results_when_exceeding_tier_limit(): void + { + // Use SQLite in-memory for testing + Config::set('database.default', 'sqlite'); + Config::set('database.connections.sqlite', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + Config::set('mcp.database.default_tier', 'free'); // 100 row limit + + // Create a table with more than 100 rows + DB::connection('sqlite')->statement('CREATE TABLE large_table (id INTEGER PRIMARY KEY, name TEXT)'); + for ($i = 1; $i <= 150; $i++) { + DB::connection('sqlite')->insert('INSERT INTO large_table (id, name) VALUES (?, ?)', [$i, "Row {$i}"]); + } + + $this->auditMock->shouldReceive('recordTruncated') + ->once() + ->withArgs(function ($query, $bindings, $durationMs, $returnedRows, $maxRows) { + return $returnedRows === 150 && $maxRows === 100; + }); + + $result = $this->executionService->execute( + query: 'SELECT * FROM large_table', + connection: 'sqlite' + ); + + $this->assertCount(100, $result['data']); + $this->assertTrue($result['meta']['truncated']); + $this->assertEquals(100, $result['meta']['rows_returned']); + $this->assertStringContains('150+', (string) $result['meta']['rows_total']); + $this->assertNotNull($result['meta']['warning']); + } + + public function test_execute_includes_tier_in_metadata(): void + { + Config::set('database.default', 'sqlite'); + Config::set('database.connections.sqlite', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + Config::set('mcp.database.default_tier', 'professional'); + + DB::connection('sqlite')->statement('CREATE TABLE test_table (id INTEGER PRIMARY KEY)'); + + $result = $this->executionService->execute( + query: 'SELECT * FROM test_table', + connection: 'sqlite' + ); + + $this->assertEquals('professional', $result['meta']['tier']); + $this->assertEquals(1000, $result['meta']['max_rows']); + } + + public function test_execute_logs_errors_on_failure(): void + { + Config::set('database.default', 'sqlite'); + Config::set('database.connections.sqlite', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $this->auditMock->shouldReceive('recordError') + ->once() + ->withArgs(function ($query, $bindings, $errorMessage) { + return str_contains($query, 'nonexistent_table'); + }); + + $this->expectException(\Exception::class); + + $this->executionService->execute( + query: 'SELECT * FROM nonexistent_table', + connection: 'sqlite' + ); + } + + public function test_execute_passes_context_to_audit_service(): void + { + Config::set('database.default', 'sqlite'); + Config::set('database.connections.sqlite', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + DB::connection('sqlite')->statement('CREATE TABLE test_table (id INTEGER PRIMARY KEY)'); + + $this->auditMock->shouldReceive('recordSuccess') + ->once() + ->withArgs(function ($query, $bindings, $durationMs, $rowCount, $workspaceId, $userId, $userIp, $context) { + return $workspaceId === 123 + && $userId === 456 + && $userIp === '192.168.1.1' + && isset($context['session_id']) + && $context['session_id'] === 'test-session'; + }); + + $this->executionService->execute( + query: 'SELECT * FROM test_table', + connection: 'sqlite', + workspaceId: 123, + userId: 456, + userIp: '192.168.1.1', + context: ['session_id' => 'test-session'] + ); + } + + /** + * Helper to assert string contains substring. + */ + protected function assertStringContains(string $needle, string $haystack): void + { + $this->assertTrue( + str_contains($haystack, $needle), + "Failed asserting that '{$haystack}' contains '{$needle}'" + ); + } +} diff --git a/src/php/src/Mcp/Tests/Unit/ToolAnalyticsServiceTest.php b/src/php/src/Mcp/Tests/Unit/ToolAnalyticsServiceTest.php new file mode 100644 index 0000000..8562786 --- /dev/null +++ b/src/php/src/Mcp/Tests/Unit/ToolAnalyticsServiceTest.php @@ -0,0 +1,878 @@ +analyticsService = new ToolAnalyticsService(); + Config::set('mcp.analytics.enabled', true); + Config::set('mcp.analytics.batch_size', 100); + }); + + it('records a single successful execution', function () { + $this->analyticsService->recordExecution( + tool: 'query_database', + durationMs: 150, + success: true, + workspaceId: 'ws-123' + ); + $this->analyticsService->flush(); + + $metric = ToolMetric::forTool('query_database') + ->forWorkspace('ws-123') + ->today() + ->first(); + + expect($metric)->not->toBeNull(); + expect($metric->call_count)->toBe(1); + expect($metric->error_count)->toBe(0); + expect($metric->total_duration_ms)->toBe(150); + expect($metric->min_duration_ms)->toBe(150); + expect($metric->max_duration_ms)->toBe(150); + }); + + it('records a failed execution', function () { + $this->analyticsService->recordExecution( + tool: 'query_database', + durationMs: 50, + success: false, + workspaceId: 'ws-123' + ); + $this->analyticsService->flush(); + + $metric = ToolMetric::forTool('query_database') + ->forWorkspace('ws-123') + ->today() + ->first(); + + expect($metric)->not->toBeNull(); + expect($metric->call_count)->toBe(1); + expect($metric->error_count)->toBe(1); + expect($metric->error_rate)->toBe(100.0); + }); + + it('aggregates multiple executions', function () { + $this->analyticsService->recordExecution('query_database', 100, true, 'ws-123'); + $this->analyticsService->recordExecution('query_database', 200, true, 'ws-123'); + $this->analyticsService->recordExecution('query_database', 150, false, 'ws-123'); + $this->analyticsService->flush(); + + $metric = ToolMetric::forTool('query_database') + ->forWorkspace('ws-123') + ->today() + ->first(); + + expect($metric)->not->toBeNull(); + expect($metric->call_count)->toBe(3); + expect($metric->error_count)->toBe(1); + expect($metric->total_duration_ms)->toBe(450); + expect($metric->min_duration_ms)->toBe(100); + expect($metric->max_duration_ms)->toBe(200); + }); + + it('records execution without workspace', function () { + $this->analyticsService->recordExecution( + tool: 'system_info', + durationMs: 25, + success: true, + workspaceId: null + ); + $this->analyticsService->flush(); + + $metric = ToolMetric::forTool('system_info') + ->forWorkspace(null) + ->today() + ->first(); + + expect($metric)->not->toBeNull(); + expect($metric->call_count)->toBe(1); + }); + + it('does not record when analytics disabled', function () { + Config::set('mcp.analytics.enabled', false); + + $this->analyticsService->recordExecution('query_database', 150, true, 'ws-123'); + $this->analyticsService->flush(); + + expect(ToolMetric::count())->toBe(0); + }); + + it('auto-flushes when batch size reached', function () { + Config::set('mcp.analytics.batch_size', 5); + $service = new ToolAnalyticsService(); + + for ($i = 0; $i < 5; $i++) { + $service->recordExecution('query_database', 100, true, 'ws-123'); + } + + $metric = ToolMetric::forTool('query_database')->first(); + + expect($metric)->not->toBeNull(); + expect($metric->call_count)->toBe(5); + }); +}); + +// ============================================================================= +// ToolStats DTO Tests +// ============================================================================= + +describe('ToolStats DTO', function () { + beforeEach(function () { + $this->analyticsService = new ToolAnalyticsService(); + Config::set('mcp.analytics.enabled', true); + }); + + it('returns DTO with correct values from getToolStats', function () { + ToolMetric::create([ + 'tool_name' => 'query_database', + 'workspace_id' => null, + 'date' => now()->toDateString(), + 'call_count' => 100, + 'error_count' => 5, + 'total_duration_ms' => 15000, + 'min_duration_ms' => 50, + 'max_duration_ms' => 500, + ]); + + $stats = $this->analyticsService->getToolStats('query_database'); + + expect($stats)->toBeInstanceOf(ToolStats::class); + expect($stats->toolName)->toBe('query_database'); + expect($stats->totalCalls)->toBe(100); + expect($stats->errorCount)->toBe(5); + expect($stats->errorRate)->toBe(5.0); + expect($stats->avgDurationMs)->toBe(150.0); + expect($stats->minDurationMs)->toBe(50); + expect($stats->maxDurationMs)->toBe(500); + }); + + it('aggregates stats across dates', function () { + ToolMetric::create([ + 'tool_name' => 'query_database', + 'workspace_id' => null, + 'date' => now()->subDays(2)->toDateString(), + 'call_count' => 50, + 'error_count' => 2, + 'total_duration_ms' => 5000, + 'min_duration_ms' => 50, + 'max_duration_ms' => 200, + ]); + + ToolMetric::create([ + 'tool_name' => 'query_database', + 'workspace_id' => null, + 'date' => now()->subDays(1)->toDateString(), + 'call_count' => 100, + 'error_count' => 8, + 'total_duration_ms' => 10000, + 'min_duration_ms' => 30, + 'max_duration_ms' => 400, + ]); + + $stats = $this->analyticsService->getToolStats('query_database'); + + expect($stats->totalCalls)->toBe(150); + expect($stats->errorCount)->toBe(10); + expect($stats->minDurationMs)->toBe(30); + expect($stats->maxDurationMs)->toBe(400); + }); + + it('returns empty DTO for unknown tool', function () { + $stats = $this->analyticsService->getToolStats('nonexistent_tool'); + + expect($stats)->toBeInstanceOf(ToolStats::class); + expect($stats->toolName)->toBe('nonexistent_tool'); + expect($stats->totalCalls)->toBe(0); + expect($stats->errorCount)->toBe(0); + expect($stats->errorRate)->toBe(0.0); + }); + + it('handles snake_case in fromArray', function () { + $stats = ToolStats::fromArray([ + 'tool_name' => 'test_tool', + 'total_calls' => 50, + 'error_count' => 5, + 'error_rate' => 10.0, + 'avg_duration_ms' => 100.0, + 'min_duration_ms' => 25, + 'max_duration_ms' => 300, + ]); + + expect($stats->toolName)->toBe('test_tool'); + expect($stats->totalCalls)->toBe(50); + expect($stats->errorCount)->toBe(5); + }); + + it('handles camelCase in fromArray', function () { + $stats = ToolStats::fromArray([ + 'toolName' => 'test_tool', + 'totalCalls' => 50, + 'errorCount' => 5, + 'errorRate' => 10.0, + 'avgDurationMs' => 100.0, + 'minDurationMs' => 25, + 'maxDurationMs' => 300, + ]); + + expect($stats->toolName)->toBe('test_tool'); + expect($stats->totalCalls)->toBe(50); + }); + + it('calculates success rate correctly', function () { + $stats = new ToolStats( + toolName: 'test_tool', + totalCalls: 100, + errorCount: 15, + errorRate: 15.0, + avgDurationMs: 100.0, + minDurationMs: 50, + maxDurationMs: 200, + ); + + expect($stats->getSuccessRate())->toBe(85.0); + }); + + it('formats duration for humans in milliseconds', function () { + $stats = new ToolStats( + toolName: 'fast_tool', + totalCalls: 10, + errorCount: 0, + errorRate: 0.0, + avgDurationMs: 250.0, + minDurationMs: 100, + maxDurationMs: 500, + ); + + expect($stats->getAvgDurationForHumans())->toBe('250ms'); + }); + + it('formats duration for humans in seconds', function () { + $stats = new ToolStats( + toolName: 'slow_tool', + totalCalls: 10, + errorCount: 0, + errorRate: 0.0, + avgDurationMs: 2500.0, + minDurationMs: 1000, + maxDurationMs: 5000, + ); + + expect($stats->getAvgDurationForHumans())->toBe('2.5s'); + }); + + it('formats zero duration as dash', function () { + $stats = new ToolStats( + toolName: 'unused_tool', + totalCalls: 0, + errorCount: 0, + errorRate: 0.0, + avgDurationMs: 0.0, + minDurationMs: 0, + maxDurationMs: 0, + ); + + expect($stats->getAvgDurationForHumans())->toBe('-'); + }); + + it('detects high error rate', function () { + $stats = new ToolStats( + toolName: 'flaky_tool', + totalCalls: 100, + errorCount: 15, + errorRate: 15.0, + avgDurationMs: 100.0, + minDurationMs: 50, + maxDurationMs: 200, + ); + + expect($stats->hasHighErrorRate(10.0))->toBeTrue(); + expect($stats->hasHighErrorRate(20.0))->toBeFalse(); + }); + + it('detects slow response', function () { + $stats = new ToolStats( + toolName: 'slow_tool', + totalCalls: 10, + errorCount: 0, + errorRate: 0.0, + avgDurationMs: 6000.0, + minDurationMs: 5000, + maxDurationMs: 8000, + ); + + expect($stats->isSlowResponding(5000))->toBeTrue(); + expect($stats->isSlowResponding(10000))->toBeFalse(); + }); +}); + +// ============================================================================= +// Error Rate Calculation Tests +// ============================================================================= + +describe('Error rate calculations', function () { + beforeEach(function () { + $this->analyticsService = new ToolAnalyticsService(); + Config::set('mcp.analytics.enabled', true); + }); + + it('calculates error rate correctly', function () { + $this->analyticsService->recordExecution('test_tool', 100, true, 'ws-123'); + $this->analyticsService->recordExecution('test_tool', 100, true, 'ws-123'); + $this->analyticsService->recordExecution('test_tool', 100, false, 'ws-123'); + $this->analyticsService->recordExecution('test_tool', 100, false, 'ws-123'); + $this->analyticsService->flush(); + + $metric = ToolMetric::forTool('test_tool')->first(); + + expect($metric->error_rate)->toBe(50.0); + }); + + it('returns zero error rate when no errors', function () { + $this->analyticsService->recordExecution('test_tool', 100, true, 'ws-123'); + $this->analyticsService->recordExecution('test_tool', 100, true, 'ws-123'); + $this->analyticsService->flush(); + + $metric = ToolMetric::forTool('test_tool')->first(); + + expect($metric->error_rate)->toBe(0.0); + }); + + it('handles zero calls gracefully', function () { + $metric = new ToolMetric([ + 'tool_name' => 'test_tool', + 'workspace_id' => null, + 'date' => now()->toDateString(), + 'call_count' => 0, + 'error_count' => 0, + 'total_duration_ms' => 0, + ]); + + expect($metric->error_rate)->toBe(0.0); + }); +}); + +// ============================================================================= +// Daily Trend Aggregation Tests +// ============================================================================= + +describe('Daily trend aggregation', function () { + beforeEach(function () { + $this->analyticsService = new ToolAnalyticsService(); + Config::set('mcp.analytics.enabled', true); + }); + + it('returns daily data for usage trends', function () { + ToolMetric::create([ + 'tool_name' => 'query_database', + 'workspace_id' => null, + 'date' => now()->subDays(2)->toDateString(), + 'call_count' => 50, + 'error_count' => 5, + 'total_duration_ms' => 5000, + 'min_duration_ms' => 50, + 'max_duration_ms' => 200, + ]); + + ToolMetric::create([ + 'tool_name' => 'query_database', + 'workspace_id' => null, + 'date' => now()->subDays(1)->toDateString(), + 'call_count' => 75, + 'error_count' => 3, + 'total_duration_ms' => 7500, + 'min_duration_ms' => 60, + 'max_duration_ms' => 180, + ]); + + ToolMetric::create([ + 'tool_name' => 'query_database', + 'workspace_id' => null, + 'date' => now()->toDateString(), + 'call_count' => 100, + 'error_count' => 2, + 'total_duration_ms' => 10000, + 'min_duration_ms' => 40, + 'max_duration_ms' => 300, + ]); + + $trends = $this->analyticsService->getUsageTrends('query_database', 7); + + expect($trends)->toHaveCount(7); + + $todayTrend = collect($trends)->firstWhere('date', now()->toDateString()); + expect($todayTrend['calls'])->toBe(100); + expect($todayTrend['errors'])->toBe(2); + }); + + it('fills missing days with zeros', function () { + ToolMetric::create([ + 'tool_name' => 'query_database', + 'workspace_id' => null, + 'date' => now()->toDateString(), + 'call_count' => 10, + 'error_count' => 0, + 'total_duration_ms' => 1000, + 'min_duration_ms' => 50, + 'max_duration_ms' => 150, + ]); + + $trends = $this->analyticsService->getUsageTrends('query_database', 7); + + expect($trends)->toHaveCount(7); + + $daysWithCalls = collect($trends)->filter(fn ($day) => $day['calls'] > 0)->count(); + expect($daysWithCalls)->toBe(1); + }); + + it('includes formatted dates', function () { + $trends = $this->analyticsService->getUsageTrends('query_database', 7); + + foreach ($trends as $trend) { + expect($trend)->toHaveKey('date'); + expect($trend)->toHaveKey('date_formatted'); + expect($trend['date'])->toMatch('/\d{4}-\d{2}-\d{2}/'); + expect($trend['date_formatted'])->toMatch('/[A-Z][a-z]{2} \d{1,2}/'); + } + }); +}); + +// ============================================================================= +// Reporting Function Tests +// ============================================================================= + +describe('Reporting functions', function () { + beforeEach(function () { + $this->analyticsService = new ToolAnalyticsService(); + Config::set('mcp.analytics.enabled', true); + }); + + it('getAllToolStats returns collection', function () { + ToolMetric::create([ + 'tool_name' => 'query_database', + 'workspace_id' => null, + 'date' => now()->toDateString(), + 'call_count' => 100, + 'error_count' => 5, + 'total_duration_ms' => 10000, + 'min_duration_ms' => 50, + 'max_duration_ms' => 300, + ]); + + ToolMetric::create([ + 'tool_name' => 'file_read', + 'workspace_id' => null, + 'date' => now()->toDateString(), + 'call_count' => 50, + 'error_count' => 1, + 'total_duration_ms' => 2500, + 'min_duration_ms' => 20, + 'max_duration_ms' => 100, + ]); + + $stats = $this->analyticsService->getAllToolStats(); + + expect($stats)->toHaveCount(2); + expect($stats->first())->toBeInstanceOf(ToolStats::class); + expect($stats->first()->toolName)->toBe('query_database'); + expect($stats->last()->toolName)->toBe('file_read'); + }); + + it('getPopularTools returns top tools by call count', function () { + foreach (['tool_a', 'tool_b', 'tool_c', 'tool_d', 'tool_e'] as $index => $toolName) { + ToolMetric::create([ + 'tool_name' => $toolName, + 'workspace_id' => null, + 'date' => now()->toDateString(), + 'call_count' => 100 - ($index * 20), + 'error_count' => 0, + 'total_duration_ms' => 5000, + 'min_duration_ms' => 50, + 'max_duration_ms' => 100, + ]); + } + + $popular = $this->analyticsService->getPopularTools(3); + + expect($popular)->toHaveCount(3); + expect($popular[0]->toolName)->toBe('tool_a'); + expect($popular[1]->toolName)->toBe('tool_b'); + expect($popular[2]->toolName)->toBe('tool_c'); + }); + + it('getErrorProneTools filters by minimum calls', function () { + // Tool with high error rate but few calls (should be excluded) + ToolMetric::create([ + 'tool_name' => 'rarely_used_tool', + 'workspace_id' => null, + 'date' => now()->toDateString(), + 'call_count' => 5, + 'error_count' => 5, + 'total_duration_ms' => 500, + 'min_duration_ms' => 100, + 'max_duration_ms' => 100, + ]); + + // Tool with moderate error rate and enough calls (should be included) + ToolMetric::create([ + 'tool_name' => 'problematic_tool', + 'workspace_id' => null, + 'date' => now()->toDateString(), + 'call_count' => 50, + 'error_count' => 15, + 'total_duration_ms' => 5000, + 'min_duration_ms' => 50, + 'max_duration_ms' => 200, + ]); + + // Tool with low error rate and many calls (should be lower priority) + ToolMetric::create([ + 'tool_name' => 'reliable_tool', + 'workspace_id' => null, + 'date' => now()->toDateString(), + 'call_count' => 100, + 'error_count' => 2, + 'total_duration_ms' => 10000, + 'min_duration_ms' => 50, + 'max_duration_ms' => 150, + ]); + + $errorProne = $this->analyticsService->getErrorProneTools(10); + + $toolNames = $errorProne->map(fn (ToolStats $s) => $s->toolName)->toArray(); + expect($toolNames)->not->toContain('rarely_used_tool'); + expect($errorProne->first()->toolName)->toBe('problematic_tool'); + }); + + it('getWorkspaceStats returns aggregated workspace data', function () { + ToolMetric::create([ + 'tool_name' => 'query_database', + 'workspace_id' => 'ws-123', + 'date' => now()->toDateString(), + 'call_count' => 50, + 'error_count' => 2, + 'total_duration_ms' => 5000, + 'min_duration_ms' => 50, + 'max_duration_ms' => 200, + ]); + + ToolMetric::create([ + 'tool_name' => 'file_read', + 'workspace_id' => 'ws-123', + 'date' => now()->toDateString(), + 'call_count' => 30, + 'error_count' => 1, + 'total_duration_ms' => 1500, + 'min_duration_ms' => 20, + 'max_duration_ms' => 100, + ]); + + // Different workspace (should not be included) + ToolMetric::create([ + 'tool_name' => 'query_database', + 'workspace_id' => 'ws-456', + 'date' => now()->toDateString(), + 'call_count' => 100, + 'error_count' => 10, + 'total_duration_ms' => 10000, + 'min_duration_ms' => 50, + 'max_duration_ms' => 300, + ]); + + $stats = $this->analyticsService->getWorkspaceStats('ws-123'); + + expect($stats['workspace_id'])->toBe('ws-123'); + expect($stats['total_calls'])->toBe(80); + expect($stats['error_count'])->toBe(3); + expect($stats['error_rate'])->toBe(3.75); + expect($stats['unique_tools'])->toBe(2); + }); +}); + +// ============================================================================= +// Tool Combination Tracking Tests +// ============================================================================= + +describe('Tool combination tracking', function () { + beforeEach(function () { + $this->analyticsService = new ToolAnalyticsService(); + Config::set('mcp.analytics.enabled', true); + }); + + it('tracks tool combinations within session', function () { + $sessionId = 'session-abc-123'; + + $this->analyticsService->recordExecution('query_database', 100, true, 'ws-123', $sessionId); + $this->analyticsService->recordExecution('file_read', 50, true, 'ws-123', $sessionId); + $this->analyticsService->recordExecution('api_call', 200, true, 'ws-123', $sessionId); + $this->analyticsService->flush(); + + $combinations = DB::table('mcp_tool_combinations') + ->where('workspace_id', 'ws-123') + ->get(); + + // 3 tools = 3 unique pairs (api_call+file_read, api_call+query_database, file_read+query_database) + expect($combinations)->toHaveCount(3); + }); + + it('orders tool combinations alphabetically', function () { + $sessionId = 'session-xyz'; + + $this->analyticsService->recordExecution('zebra_tool', 100, true, 'ws-123', $sessionId); + $this->analyticsService->recordExecution('alpha_tool', 100, true, 'ws-123', $sessionId); + $this->analyticsService->flush(); + + $combination = DB::table('mcp_tool_combinations') + ->where('workspace_id', 'ws-123') + ->first(); + + expect($combination->tool_a)->toBe('alpha_tool'); + expect($combination->tool_b)->toBe('zebra_tool'); + }); + + it('getToolCombinations returns most frequent', function () { + $today = now()->toDateString(); + + DB::table('mcp_tool_combinations')->insert([ + [ + 'tool_a' => 'query_database', + 'tool_b' => 'file_read', + 'workspace_id' => 'ws-123', + 'date' => $today, + 'occurrence_count' => 50, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'tool_a' => 'api_call', + 'tool_b' => 'query_database', + 'workspace_id' => 'ws-123', + 'date' => $today, + 'occurrence_count' => 30, + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + $combinations = $this->analyticsService->getToolCombinations(5); + + expect($combinations)->toHaveCount(2); + expect($combinations[0]['tool_a'])->toBe('query_database'); + expect($combinations[0]['tool_b'])->toBe('file_read'); + expect($combinations[0]['occurrences'])->toBe(50); + }); +}); + +// ============================================================================= +// Date Range Filtering Tests +// ============================================================================= + +describe('Date range filtering', function () { + beforeEach(function () { + $this->analyticsService = new ToolAnalyticsService(); + Config::set('mcp.analytics.enabled', true); + }); + + it('getToolStats respects date range', function () { + // Metric within range + ToolMetric::create([ + 'tool_name' => 'query_database', + 'workspace_id' => null, + 'date' => now()->subDays(5)->toDateString(), + 'call_count' => 50, + 'error_count' => 2, + 'total_duration_ms' => 5000, + 'min_duration_ms' => 50, + 'max_duration_ms' => 200, + ]); + + // Metric outside range (too old) + ToolMetric::create([ + 'tool_name' => 'query_database', + 'workspace_id' => null, + 'date' => now()->subDays(60)->toDateString(), + 'call_count' => 100, + 'error_count' => 10, + 'total_duration_ms' => 10000, + 'min_duration_ms' => 50, + 'max_duration_ms' => 300, + ]); + + $stats = $this->analyticsService->getToolStats( + 'query_database', + from: now()->subDays(30), + to: now() + ); + + expect($stats->totalCalls)->toBe(50); + expect($stats->errorCount)->toBe(2); + }); +}); + +// ============================================================================= +// ToolMetric Model Tests +// ============================================================================= + +describe('ToolMetric model', function () { + it('recordCall creates new record', function () { + $metric = ToolMetric::recordCall('new_tool', 150, 'ws-123'); + + expect($metric)->toBeInstanceOf(ToolMetric::class); + expect($metric->tool_name)->toBe('new_tool'); + expect($metric->call_count)->toBe(1); + expect($metric->error_count)->toBe(0); + expect($metric->total_duration_ms)->toBe(150); + }); + + it('recordCall increments existing record', function () { + ToolMetric::recordCall('existing_tool', 100, 'ws-123'); + $metric = ToolMetric::recordCall('existing_tool', 200, 'ws-123'); + + expect($metric->call_count)->toBe(2); + expect($metric->total_duration_ms)->toBe(300); + expect($metric->min_duration_ms)->toBe(100); + expect($metric->max_duration_ms)->toBe(200); + }); + + it('recordError increments error count', function () { + $metric = ToolMetric::recordError('error_tool', 50, 'ws-123'); + + expect($metric->call_count)->toBe(1); + expect($metric->error_count)->toBe(1); + expect($metric->error_rate)->toBe(100.0); + }); + + it('average_duration accessor works correctly', function () { + $metric = new ToolMetric([ + 'call_count' => 10, + 'total_duration_ms' => 1500, + ]); + + expect($metric->average_duration)->toBe(150.0); + }); + + it('average_duration_for_humans returns milliseconds', function () { + $metric = new ToolMetric([ + 'call_count' => 10, + 'total_duration_ms' => 5000, + ]); + + expect($metric->average_duration_for_humans)->toBe('500ms'); + }); + + it('average_duration_for_humans returns seconds', function () { + $metric = new ToolMetric([ + 'call_count' => 10, + 'total_duration_ms' => 25000, + ]); + + expect($metric->average_duration_for_humans)->toBe('2.5s'); + }); + + it('average_duration_for_humans returns dash for zero', function () { + $metric = new ToolMetric([ + 'call_count' => 0, + 'total_duration_ms' => 0, + ]); + + expect($metric->average_duration_for_humans)->toBe('-'); + }); + + it('forDateRange scope filters correctly', function () { + ToolMetric::create([ + 'tool_name' => 'test_tool', + 'workspace_id' => null, + 'date' => now()->subDays(5)->toDateString(), + 'call_count' => 10, + 'error_count' => 0, + 'total_duration_ms' => 1000, + ]); + + ToolMetric::create([ + 'tool_name' => 'test_tool', + 'workspace_id' => null, + 'date' => now()->subDays(15)->toDateString(), + 'call_count' => 20, + 'error_count' => 0, + 'total_duration_ms' => 2000, + ]); + + $metrics = ToolMetric::forDateRange(now()->subDays(10), now())->get(); + + expect($metrics)->toHaveCount(1); + expect($metrics->first()->call_count)->toBe(10); + }); + + it('lastDays scope filters correctly', function () { + ToolMetric::create([ + 'tool_name' => 'test_tool', + 'workspace_id' => null, + 'date' => now()->subDays(3)->toDateString(), + 'call_count' => 10, + 'error_count' => 0, + 'total_duration_ms' => 1000, + ]); + + ToolMetric::create([ + 'tool_name' => 'test_tool', + 'workspace_id' => null, + 'date' => now()->subDays(10)->toDateString(), + 'call_count' => 20, + 'error_count' => 0, + 'total_duration_ms' => 2000, + ]); + + $metrics = ToolMetric::lastDays(7)->get(); + + expect($metrics)->toHaveCount(1); + }); + + it('today scope filters correctly', function () { + ToolMetric::create([ + 'tool_name' => 'test_tool', + 'workspace_id' => null, + 'date' => now()->toDateString(), + 'call_count' => 10, + 'error_count' => 0, + 'total_duration_ms' => 1000, + ]); + + ToolMetric::create([ + 'tool_name' => 'test_tool', + 'workspace_id' => null, + 'date' => now()->subDays(1)->toDateString(), + 'call_count' => 20, + 'error_count' => 0, + 'total_duration_ms' => 2000, + ]); + + $metrics = ToolMetric::today()->get(); + + expect($metrics)->toHaveCount(1); + expect($metrics->first()->call_count)->toBe(10); + }); +}); diff --git a/src/php/src/Mcp/Tests/Unit/ToolDependencyServiceTest.php b/src/php/src/Mcp/Tests/Unit/ToolDependencyServiceTest.php new file mode 100644 index 0000000..840e197 --- /dev/null +++ b/src/php/src/Mcp/Tests/Unit/ToolDependencyServiceTest.php @@ -0,0 +1,480 @@ +service = new ToolDependencyService; + Cache::flush(); + } + + public function test_can_register_dependencies(): void + { + $deps = [ + ToolDependency::toolCalled('plan_create'), + ToolDependency::contextExists('workspace_id'), + ]; + + $this->service->register('custom_tool', $deps); + + $registered = $this->service->getDependencies('custom_tool'); + + $this->assertCount(2, $registered); + $this->assertSame('plan_create', $registered[0]->key); + $this->assertSame(DependencyType::TOOL_CALLED, $registered[0]->type); + } + + public function test_returns_empty_for_unregistered_tool(): void + { + $deps = $this->service->getDependencies('nonexistent_tool'); + + $this->assertEmpty($deps); + } + + public function test_check_dependencies_passes_when_no_deps(): void + { + $result = $this->service->checkDependencies( + sessionId: 'test-session', + toolName: 'tool_without_deps', + context: [], + args: [], + ); + + $this->assertTrue($result); + } + + public function test_check_dependencies_fails_when_tool_not_called(): void + { + $this->service->register('dependent_tool', [ + ToolDependency::toolCalled('required_tool'), + ]); + + $result = $this->service->checkDependencies( + sessionId: 'test-session', + toolName: 'dependent_tool', + context: [], + args: [], + ); + + $this->assertFalse($result); + } + + public function test_check_dependencies_passes_after_tool_called(): void + { + $this->service->register('dependent_tool', [ + ToolDependency::toolCalled('required_tool'), + ]); + + // Record the required tool call + $this->service->recordToolCall('test-session', 'required_tool'); + + $result = $this->service->checkDependencies( + sessionId: 'test-session', + toolName: 'dependent_tool', + context: [], + args: [], + ); + + $this->assertTrue($result); + } + + public function test_check_context_exists_dependency(): void + { + $this->service->register('workspace_tool', [ + ToolDependency::contextExists('workspace_id'), + ]); + + // Without workspace_id + $result = $this->service->checkDependencies( + sessionId: 'test-session', + toolName: 'workspace_tool', + context: [], + args: [], + ); + $this->assertFalse($result); + + // With workspace_id + $result = $this->service->checkDependencies( + sessionId: 'test-session', + toolName: 'workspace_tool', + context: ['workspace_id' => 123], + args: [], + ); + $this->assertTrue($result); + } + + public function test_check_session_state_dependency(): void + { + $this->service->register('session_tool', [ + ToolDependency::sessionState('session_id'), + ]); + + // Without session_id + $result = $this->service->checkDependencies( + sessionId: 'test-session', + toolName: 'session_tool', + context: [], + args: [], + ); + $this->assertFalse($result); + + // With null session_id (should still fail) + $result = $this->service->checkDependencies( + sessionId: 'test-session', + toolName: 'session_tool', + context: ['session_id' => null], + args: [], + ); + $this->assertFalse($result); + + // With valid session_id + $result = $this->service->checkDependencies( + sessionId: 'test-session', + toolName: 'session_tool', + context: ['session_id' => 'ses_123'], + args: [], + ); + $this->assertTrue($result); + } + + public function test_get_missing_dependencies(): void + { + $this->service->register('multi_dep_tool', [ + ToolDependency::toolCalled('tool_a'), + ToolDependency::toolCalled('tool_b'), + ToolDependency::contextExists('workspace_id'), + ]); + + // Record one tool call + $this->service->recordToolCall('test-session', 'tool_a'); + + $missing = $this->service->getMissingDependencies( + sessionId: 'test-session', + toolName: 'multi_dep_tool', + context: [], + args: [], + ); + + $this->assertCount(2, $missing); + $this->assertSame('tool_b', $missing[0]->key); + $this->assertSame('workspace_id', $missing[1]->key); + } + + public function test_validate_dependencies_throws_exception(): void + { + $this->service->register('validated_tool', [ + ToolDependency::toolCalled('required_tool', 'You must call required_tool first'), + ]); + + $this->expectException(MissingDependencyException::class); + $this->expectExceptionMessage('Cannot execute \'validated_tool\''); + + $this->service->validateDependencies( + sessionId: 'test-session', + toolName: 'validated_tool', + context: [], + args: [], + ); + } + + public function test_validate_dependencies_passes_when_met(): void + { + $this->service->register('validated_tool', [ + ToolDependency::toolCalled('required_tool'), + ]); + + $this->service->recordToolCall('test-session', 'required_tool'); + + // Should not throw + $this->service->validateDependencies( + sessionId: 'test-session', + toolName: 'validated_tool', + context: [], + args: [], + ); + + $this->assertTrue(true); // No exception means pass + } + + public function test_optional_dependencies_are_skipped(): void + { + $this->service->register('soft_dep_tool', [ + ToolDependency::toolCalled('hard_req'), + ToolDependency::toolCalled('soft_req')->asOptional(), + ]); + + $this->service->recordToolCall('test-session', 'hard_req'); + + // Should pass even though soft_req not called + $result = $this->service->checkDependencies( + sessionId: 'test-session', + toolName: 'soft_dep_tool', + context: [], + args: [], + ); + + $this->assertTrue($result); + } + + public function test_record_and_get_tool_call_history(): void + { + $this->service->recordToolCall('test-session', 'tool_a', ['arg1' => 'value1']); + $this->service->recordToolCall('test-session', 'tool_b'); + $this->service->recordToolCall('test-session', 'tool_a', ['arg1' => 'value2']); + + $calledTools = $this->service->getCalledTools('test-session'); + + $this->assertCount(2, $calledTools); + $this->assertContains('tool_a', $calledTools); + $this->assertContains('tool_b', $calledTools); + + $history = $this->service->getToolHistory('test-session'); + + $this->assertCount(3, $history); + $this->assertSame('tool_a', $history[0]['tool']); + $this->assertSame(['arg1' => 'value1'], $history[0]['args']); + } + + public function test_clear_session(): void + { + $this->service->recordToolCall('test-session', 'tool_a'); + + $this->assertNotEmpty($this->service->getCalledTools('test-session')); + + $this->service->clearSession('test-session'); + + $this->assertEmpty($this->service->getCalledTools('test-session')); + } + + public function test_get_dependency_graph(): void + { + $this->service->register('tool_a', []); + $this->service->register('tool_b', [ + ToolDependency::toolCalled('tool_a'), + ]); + $this->service->register('tool_c', [ + ToolDependency::toolCalled('tool_b'), + ]); + + $graph = $this->service->getDependencyGraph(); + + $this->assertArrayHasKey('tool_a', $graph); + $this->assertArrayHasKey('tool_b', $graph); + $this->assertArrayHasKey('tool_c', $graph); + + // tool_b depends on tool_a + $this->assertContains('tool_b', $graph['tool_a']['dependents']); + + // tool_c depends on tool_b + $this->assertContains('tool_c', $graph['tool_b']['dependents']); + } + + public function test_get_dependent_tools(): void + { + $this->service->register('base_tool', []); + $this->service->register('dep_tool_1', [ + ToolDependency::toolCalled('base_tool'), + ]); + $this->service->register('dep_tool_2', [ + ToolDependency::toolCalled('base_tool'), + ]); + + $dependents = $this->service->getDependentTools('base_tool'); + + $this->assertCount(2, $dependents); + $this->assertContains('dep_tool_1', $dependents); + $this->assertContains('dep_tool_2', $dependents); + } + + public function test_get_topological_order(): void + { + $this->service->register('tool_a', []); + $this->service->register('tool_b', [ + ToolDependency::toolCalled('tool_a'), + ]); + $this->service->register('tool_c', [ + ToolDependency::toolCalled('tool_b'), + ]); + + $order = $this->service->getTopologicalOrder(); + + $indexA = array_search('tool_a', $order); + $indexB = array_search('tool_b', $order); + $indexC = array_search('tool_c', $order); + + $this->assertLessThan($indexB, $indexA); + $this->assertLessThan($indexC, $indexB); + } + + public function test_custom_validator(): void + { + $this->service->register('custom_validated_tool', [ + ToolDependency::custom('has_permission', 'User must have admin permission'), + ]); + + // Register custom validator that checks for admin role + $this->service->registerCustomValidator('has_permission', function ($context, $args) { + return ($context['role'] ?? null) === 'admin'; + }); + + // Without admin role + $result = $this->service->checkDependencies( + sessionId: 'test-session', + toolName: 'custom_validated_tool', + context: ['role' => 'user'], + args: [], + ); + $this->assertFalse($result); + + // With admin role + $result = $this->service->checkDependencies( + sessionId: 'test-session', + toolName: 'custom_validated_tool', + context: ['role' => 'admin'], + args: [], + ); + $this->assertTrue($result); + } + + public function test_suggested_tool_order(): void + { + $this->service->register('tool_a', []); + $this->service->register('tool_b', [ + ToolDependency::toolCalled('tool_a'), + ]); + $this->service->register('tool_c', [ + ToolDependency::toolCalled('tool_b'), + ]); + + try { + $this->service->validateDependencies( + sessionId: 'test-session', + toolName: 'tool_c', + context: [], + args: [], + ); + $this->fail('Should have thrown MissingDependencyException'); + } catch (MissingDependencyException $e) { + $this->assertContains('tool_a', $e->suggestedOrder); + $this->assertContains('tool_b', $e->suggestedOrder); + $this->assertContains('tool_c', $e->suggestedOrder); + + // Verify order + $indexA = array_search('tool_a', $e->suggestedOrder); + $indexB = array_search('tool_b', $e->suggestedOrder); + $this->assertLessThan($indexB, $indexA); + } + } + + public function test_session_isolation(): void + { + $this->service->register('isolated_tool', [ + ToolDependency::toolCalled('prereq'), + ]); + + // Record in session 1 + $this->service->recordToolCall('session-1', 'prereq'); + + // Session 1 should pass + $result1 = $this->service->checkDependencies( + sessionId: 'session-1', + toolName: 'isolated_tool', + context: [], + args: [], + ); + $this->assertTrue($result1); + + // Session 2 should fail (different session) + $result2 = $this->service->checkDependencies( + sessionId: 'session-2', + toolName: 'isolated_tool', + context: [], + args: [], + ); + $this->assertFalse($result2); + } + + public function test_missing_dependency_exception_api_response(): void + { + $missing = [ + ToolDependency::toolCalled('tool_a', 'Tool A must be called first'), + ToolDependency::contextExists('workspace_id', 'Workspace context required'), + ]; + + $exception = new MissingDependencyException( + toolName: 'target_tool', + missingDependencies: $missing, + suggestedOrder: ['tool_a', 'target_tool'], + ); + + $response = $exception->toApiResponse(); + + $this->assertSame('dependency_not_met', $response['error']); + $this->assertSame('target_tool', $response['tool']); + $this->assertCount(2, $response['missing_dependencies']); + $this->assertSame(['tool_a', 'target_tool'], $response['suggested_order']); + $this->assertArrayHasKey('help', $response); + } + + public function test_default_dependencies_registered(): void + { + // The service should have default dependencies registered + $sessionLogDeps = $this->service->getDependencies('session_log'); + + $this->assertNotEmpty($sessionLogDeps); + $this->assertSame(DependencyType::SESSION_STATE, $sessionLogDeps[0]->type); + $this->assertSame('session_id', $sessionLogDeps[0]->key); + } + + public function test_tool_dependency_factory_methods(): void + { + $toolCalled = ToolDependency::toolCalled('some_tool'); + $this->assertSame(DependencyType::TOOL_CALLED, $toolCalled->type); + $this->assertSame('some_tool', $toolCalled->key); + + $sessionState = ToolDependency::sessionState('session_key'); + $this->assertSame(DependencyType::SESSION_STATE, $sessionState->type); + + $contextExists = ToolDependency::contextExists('context_key'); + $this->assertSame(DependencyType::CONTEXT_EXISTS, $contextExists->type); + + $entityExists = ToolDependency::entityExists('plan', 'Plan must exist', ['arg_key' => 'plan_slug']); + $this->assertSame(DependencyType::ENTITY_EXISTS, $entityExists->type); + $this->assertSame('plan_slug', $entityExists->metadata['arg_key']); + + $custom = ToolDependency::custom('custom_check', 'Custom validation'); + $this->assertSame(DependencyType::CUSTOM, $custom->type); + } + + public function test_tool_dependency_to_and_from_array(): void + { + $original = ToolDependency::toolCalled('some_tool', 'Must call first') + ->asOptional(); + + $array = $original->toArray(); + + $this->assertSame('tool_called', $array['type']); + $this->assertSame('some_tool', $array['key']); + $this->assertTrue($array['optional']); + + $restored = ToolDependency::fromArray($array); + + $this->assertSame($original->type, $restored->type); + $this->assertSame($original->key, $restored->key); + $this->assertSame($original->optional, $restored->optional); + } +} diff --git a/src/php/src/Mcp/Tests/Unit/ToolVersionServiceTest.php b/src/php/src/Mcp/Tests/Unit/ToolVersionServiceTest.php new file mode 100644 index 0000000..aca9e4c --- /dev/null +++ b/src/php/src/Mcp/Tests/Unit/ToolVersionServiceTest.php @@ -0,0 +1,441 @@ +service = new ToolVersionService; + } + + public function test_can_register_new_version(): void + { + $version = $this->service->registerVersion( + serverId: 'test-server', + toolName: 'test_tool', + version: '1.0.0', + inputSchema: ['type' => 'object', 'properties' => ['query' => ['type' => 'string']]], + description: 'A test tool', + options: ['mark_latest' => true] + ); + + $this->assertSame('test-server', $version->server_id); + $this->assertSame('test_tool', $version->tool_name); + $this->assertSame('1.0.0', $version->version); + $this->assertTrue($version->is_latest); + } + + public function test_first_version_is_automatically_latest(): void + { + $version = $this->service->registerVersion( + serverId: 'test-server', + toolName: 'test_tool', + version: '1.0.0', + ); + + $this->assertTrue($version->is_latest); + } + + public function test_can_get_tool_at_specific_version(): void + { + $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); + $this->service->registerVersion('test-server', 'test_tool', '2.0.0'); + + $v1 = $this->service->getToolAtVersion('test-server', 'test_tool', '1.0.0'); + $v2 = $this->service->getToolAtVersion('test-server', 'test_tool', '2.0.0'); + + $this->assertSame('1.0.0', $v1->version); + $this->assertSame('2.0.0', $v2->version); + } + + public function test_get_latest_version(): void + { + $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); + $v2 = $this->service->registerVersion('test-server', 'test_tool', '2.0.0', options: ['mark_latest' => true]); + + $latest = $this->service->getLatestVersion('test-server', 'test_tool'); + + $this->assertSame('2.0.0', $latest->version); + $this->assertTrue($latest->is_latest); + } + + public function test_resolve_version_returns_latest_when_no_version_specified(): void + { + $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); + $this->service->registerVersion('test-server', 'test_tool', '2.0.0', options: ['mark_latest' => true]); + + $result = $this->service->resolveVersion('test-server', 'test_tool', null); + + $this->assertNotNull($result['version']); + $this->assertSame('2.0.0', $result['version']->version); + $this->assertNull($result['warning']); + $this->assertNull($result['error']); + } + + public function test_resolve_version_returns_specific_version(): void + { + $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); + $this->service->registerVersion('test-server', 'test_tool', '2.0.0', options: ['mark_latest' => true]); + + $result = $this->service->resolveVersion('test-server', 'test_tool', '1.0.0'); + + $this->assertNotNull($result['version']); + $this->assertSame('1.0.0', $result['version']->version); + } + + public function test_resolve_version_returns_error_for_nonexistent_version(): void + { + $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); + + $result = $this->service->resolveVersion('test-server', 'test_tool', '9.9.9'); + + $this->assertNull($result['version']); + $this->assertNotNull($result['error']); + $this->assertSame('VERSION_NOT_FOUND', $result['error']['code']); + } + + public function test_resolve_deprecated_version_returns_warning(): void + { + $version = $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); + $version->deprecate(Carbon::now()->addDays(30)); + + $this->service->registerVersion('test-server', 'test_tool', '2.0.0', options: ['mark_latest' => true]); + + $result = $this->service->resolveVersion('test-server', 'test_tool', '1.0.0'); + + $this->assertNotNull($result['version']); + $this->assertNotNull($result['warning']); + $this->assertSame('TOOL_VERSION_DEPRECATED', $result['warning']['code']); + } + + public function test_resolve_sunset_version_returns_error(): void + { + $version = $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); + $version->deprecated_at = Carbon::now()->subDays(60); + $version->sunset_at = Carbon::now()->subDays(30); + $version->save(); + + $this->service->registerVersion('test-server', 'test_tool', '2.0.0', options: ['mark_latest' => true]); + + $result = $this->service->resolveVersion('test-server', 'test_tool', '1.0.0'); + + $this->assertNull($result['version']); + $this->assertNotNull($result['error']); + $this->assertSame('TOOL_VERSION_SUNSET', $result['error']['code']); + } + + public function test_is_deprecated(): void + { + $version = $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); + $version->deprecate(); + + $this->assertTrue($this->service->isDeprecated('test-server', 'test_tool', '1.0.0')); + } + + public function test_is_sunset(): void + { + $version = $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); + $version->deprecated_at = Carbon::now()->subDays(60); + $version->sunset_at = Carbon::now()->subDays(30); + $version->save(); + + $this->assertTrue($this->service->isSunset('test-server', 'test_tool', '1.0.0')); + } + + public function test_compare_versions(): void + { + $this->assertSame(-1, $this->service->compareVersions('1.0.0', '2.0.0')); + $this->assertSame(0, $this->service->compareVersions('1.0.0', '1.0.0')); + $this->assertSame(1, $this->service->compareVersions('2.0.0', '1.0.0')); + $this->assertSame(-1, $this->service->compareVersions('1.0.0', '1.0.1')); + $this->assertSame(-1, $this->service->compareVersions('1.0.0', '1.1.0')); + } + + public function test_get_version_history(): void + { + $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); + $this->service->registerVersion('test-server', 'test_tool', '1.1.0'); + $this->service->registerVersion('test-server', 'test_tool', '2.0.0'); + + $history = $this->service->getVersionHistory('test-server', 'test_tool'); + + $this->assertCount(3, $history); + // Should be ordered by version desc + $this->assertSame('2.0.0', $history[0]->version); + $this->assertSame('1.1.0', $history[1]->version); + $this->assertSame('1.0.0', $history[2]->version); + } + + public function test_migrate_tool_call(): void + { + $this->service->registerVersion( + serverId: 'test-server', + toolName: 'test_tool', + version: '1.0.0', + inputSchema: [ + 'type' => 'object', + 'properties' => ['query' => ['type' => 'string']], + 'required' => ['query'], + ] + ); + + $this->service->registerVersion( + serverId: 'test-server', + toolName: 'test_tool', + version: '2.0.0', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'query' => ['type' => 'string'], + 'limit' => ['type' => 'integer', 'default' => 10], + ], + 'required' => ['query', 'limit'], + ] + ); + + $result = $this->service->migrateToolCall( + serverId: 'test-server', + toolName: 'test_tool', + fromVersion: '1.0.0', + toVersion: '2.0.0', + arguments: ['query' => 'SELECT * FROM users'] + ); + + $this->assertTrue($result['success']); + $this->assertSame('SELECT * FROM users', $result['arguments']['query']); + $this->assertSame(10, $result['arguments']['limit']); // Default applied + } + + public function test_deprecate_version(): void + { + $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); + + $sunsetDate = Carbon::now()->addDays(30); + $deprecatedVersion = $this->service->deprecateVersion( + 'test-server', + 'test_tool', + '1.0.0', + $sunsetDate + ); + + $this->assertNotNull($deprecatedVersion->deprecated_at); + $this->assertSame($sunsetDate->toDateString(), $deprecatedVersion->sunset_at->toDateString()); + } + + public function test_get_tools_with_versions(): void + { + $this->service->registerVersion('test-server', 'tool_a', '1.0.0'); + $this->service->registerVersion('test-server', 'tool_a', '2.0.0', options: ['mark_latest' => true]); + $this->service->registerVersion('test-server', 'tool_b', '1.0.0'); + + $tools = $this->service->getToolsWithVersions('test-server'); + + $this->assertCount(2, $tools); + $this->assertArrayHasKey('tool_a', $tools); + $this->assertArrayHasKey('tool_b', $tools); + $this->assertSame(2, $tools['tool_a']['version_count']); + $this->assertSame(1, $tools['tool_b']['version_count']); + } + + public function test_get_servers_with_versions(): void + { + $this->service->registerVersion('server-a', 'tool', '1.0.0'); + $this->service->registerVersion('server-b', 'tool', '1.0.0'); + + $servers = $this->service->getServersWithVersions(); + + $this->assertCount(2, $servers); + $this->assertContains('server-a', $servers); + $this->assertContains('server-b', $servers); + } + + public function test_sync_from_server_config(): void + { + $config = [ + 'id' => 'test-server', + 'tools' => [ + [ + 'name' => 'tool_a', + 'description' => 'Tool A', + 'inputSchema' => ['type' => 'object'], + ], + [ + 'name' => 'tool_b', + 'purpose' => 'Tool B purpose', + ], + ], + ]; + + $registered = $this->service->syncFromServerConfig($config, '1.0.0'); + + $this->assertSame(2, $registered); + + $toolA = $this->service->getToolAtVersion('test-server', 'tool_a', '1.0.0'); + $toolB = $this->service->getToolAtVersion('test-server', 'tool_b', '1.0.0'); + + $this->assertNotNull($toolA); + $this->assertNotNull($toolB); + $this->assertSame('Tool A', $toolA->description); + $this->assertSame('Tool B purpose', $toolB->description); + } + + public function test_get_stats(): void + { + $this->service->registerVersion('server-a', 'tool_a', '1.0.0'); + $this->service->registerVersion('server-a', 'tool_a', '2.0.0'); + $this->service->registerVersion('server-b', 'tool_b', '1.0.0'); + + $stats = $this->service->getStats(); + + $this->assertSame(3, $stats['total_versions']); + $this->assertSame(2, $stats['total_tools']); + $this->assertSame(2, $stats['servers']); + } + + public function test_invalid_semver_throws_exception(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid semver version'); + + $this->service->registerVersion('test-server', 'test_tool', 'invalid'); + } + + public function test_valid_semver_formats(): void + { + // Basic versions + $v1 = $this->service->registerVersion('test-server', 'tool', '1.0.0'); + $this->assertSame('1.0.0', $v1->version); + + // Prerelease + $v2 = $this->service->registerVersion('test-server', 'tool', '2.0.0-beta'); + $this->assertSame('2.0.0-beta', $v2->version); + + // Prerelease with dots + $v3 = $this->service->registerVersion('test-server', 'tool', '2.0.0-alpha.1'); + $this->assertSame('2.0.0-alpha.1', $v3->version); + + // Build metadata + $v4 = $this->service->registerVersion('test-server', 'tool', '2.0.0+build.123'); + $this->assertSame('2.0.0+build.123', $v4->version); + } + + public function test_updating_existing_version(): void + { + $original = $this->service->registerVersion( + serverId: 'test-server', + toolName: 'test_tool', + version: '1.0.0', + description: 'Original description' + ); + + $updated = $this->service->registerVersion( + serverId: 'test-server', + toolName: 'test_tool', + version: '1.0.0', + description: 'Updated description' + ); + + $this->assertSame($original->id, $updated->id); + $this->assertSame('Updated description', $updated->description); + } + + public function test_model_compare_schema_with(): void + { + $v1 = $this->service->registerVersion( + serverId: 'test-server', + toolName: 'test_tool', + version: '1.0.0', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'query' => ['type' => 'string'], + 'format' => ['type' => 'string'], + ], + ] + ); + + $v2 = $this->service->registerVersion( + serverId: 'test-server', + toolName: 'test_tool', + version: '2.0.0', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'query' => ['type' => 'string', 'maxLength' => 1000], // Changed + 'limit' => ['type' => 'integer'], // Added + ], + ] + ); + + $diff = $v1->compareSchemaWith($v2); + + $this->assertContains('limit', $diff['added']); + $this->assertContains('format', $diff['removed']); + $this->assertArrayHasKey('query', $diff['changed']); + } + + public function test_model_mark_as_latest(): void + { + $v1 = $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); + $v2 = $this->service->registerVersion('test-server', 'test_tool', '2.0.0'); + + $v2->markAsLatest(); + + $this->assertFalse($v1->fresh()->is_latest); + $this->assertTrue($v2->fresh()->is_latest); + } + + public function test_model_status_attribute(): void + { + $version = $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); + + $this->assertSame('latest', $version->status); + + $version->is_latest = false; + $version->save(); + $this->assertSame('active', $version->fresh()->status); + + $version->deprecated_at = Carbon::now()->subDay(); + $version->save(); + $this->assertSame('deprecated', $version->fresh()->status); + + $version->sunset_at = Carbon::now()->subDay(); + $version->save(); + $this->assertSame('sunset', $version->fresh()->status); + } + + public function test_model_to_api_array(): void + { + $version = $this->service->registerVersion( + serverId: 'test-server', + toolName: 'test_tool', + version: '1.0.0', + inputSchema: ['type' => 'object'], + description: 'Test tool', + options: ['changelog' => 'Initial release'] + ); + + $array = $version->toApiArray(); + + $this->assertSame('test-server', $array['server_id']); + $this->assertSame('test_tool', $array['tool_name']); + $this->assertSame('1.0.0', $array['version']); + $this->assertTrue($array['is_latest']); + $this->assertSame('latest', $array['status']); + $this->assertSame('Test tool', $array['description']); + $this->assertSame('Initial release', $array['changelog']); + } +} diff --git a/src/php/src/Mcp/Tests/Unit/ValidateWorkspaceContextMiddlewareTest.php b/src/php/src/Mcp/Tests/Unit/ValidateWorkspaceContextMiddlewareTest.php new file mode 100644 index 0000000..20bb189 --- /dev/null +++ b/src/php/src/Mcp/Tests/Unit/ValidateWorkspaceContextMiddlewareTest.php @@ -0,0 +1,110 @@ +middleware = new ValidateWorkspaceContext; + $this->user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + }); + + it('sets workspace context when mcp_workspace attribute exists', function () { + $request = Request::create('/api/mcp/tools/call', 'POST'); + $request->attributes->set('mcp_workspace', $this->workspace); + + $contextSet = null; + $response = $this->middleware->handle($request, function ($request) use (&$contextSet) { + $contextSet = $request->attributes->get('mcp_workspace_context'); + + return response()->json(['success' => true]); + }); + + expect($contextSet)->toBeInstanceOf(WorkspaceContext::class); + expect($contextSet->workspaceId)->toBe($this->workspace->id); + expect($response->getStatusCode())->toBe(200); + }); + + it('rejects requests without workspace when mode is required', function () { + $request = Request::create('/api/mcp/tools/call', 'POST'); + $request->headers->set('Accept', 'application/json'); + + $response = $this->middleware->handle($request, function () { + return response()->json(['success' => true]); + }, 'required'); + + expect($response->getStatusCode())->toBe(403); + + $data = json_decode($response->getContent(), true); + expect($data['error'])->toBe('missing_workspace_context'); + }); + + it('allows requests without workspace when mode is optional', function () { + $request = Request::create('/api/mcp/tools/call', 'POST'); + + $response = $this->middleware->handle($request, function ($request) { + $context = $request->attributes->get('mcp_workspace_context'); + + return response()->json(['has_context' => $context !== null]); + }, 'optional'); + + expect($response->getStatusCode())->toBe(200); + + $data = json_decode($response->getContent(), true); + expect($data['has_context'])->toBeFalse(); + }); + + it('extracts workspace from authenticated user', function () { + $request = Request::create('/api/mcp/tools/call', 'POST'); + $request->setUserResolver(fn () => $this->user); + + $contextSet = null; + $response = $this->middleware->handle($request, function ($request) use (&$contextSet) { + $contextSet = $request->attributes->get('mcp_workspace_context'); + + return response()->json(['success' => true]); + }); + + expect($contextSet)->toBeInstanceOf(WorkspaceContext::class); + expect($contextSet->workspaceId)->toBe($this->workspace->id); + }); + + it('defaults to required mode', function () { + $request = Request::create('/api/mcp/tools/call', 'POST'); + $request->headers->set('Accept', 'application/json'); + + $response = $this->middleware->handle($request, function () { + return response()->json(['success' => true]); + }); + + expect($response->getStatusCode())->toBe(403); + }); + + it('returns HTML response for non-API requests', function () { + $request = Request::create('/mcp/tools', 'GET'); + // Not setting Accept: application/json + + $response = $this->middleware->handle($request, function () { + return response()->json(['success' => true]); + }, 'required'); + + expect($response->getStatusCode())->toBe(403); + expect($response->headers->get('Content-Type'))->not->toContain('application/json'); + }); +}); diff --git a/src/php/src/Mcp/Tests/Unit/WorkspaceContextSecurityTest.php b/src/php/src/Mcp/Tests/Unit/WorkspaceContextSecurityTest.php new file mode 100644 index 0000000..78d1eb0 --- /dev/null +++ b/src/php/src/Mcp/Tests/Unit/WorkspaceContextSecurityTest.php @@ -0,0 +1,689 @@ +tool)->toBe('ListInvoices'); + expect($exception->getMessage())->toContain('ListInvoices'); + expect($exception->getMessage())->toContain('workspace context'); + }); + + it('creates exception with custom message', function () { + $exception = new MissingWorkspaceContextException('TestTool', 'Custom error message'); + + expect($exception->getMessage())->toBe('Custom error message'); + expect($exception->tool)->toBe('TestTool'); + }); + + it('returns correct status code', function () { + $exception = new MissingWorkspaceContextException('TestTool'); + + expect($exception->getStatusCode())->toBe(403); + }); + + it('returns correct error type', function () { + $exception = new MissingWorkspaceContextException('TestTool'); + + expect($exception->getErrorType())->toBe('missing_workspace_context'); + }); + + it('includes authentication guidance in default message', function () { + $exception = new MissingWorkspaceContextException('QueryDatabase'); + + expect($exception->getMessage())->toContain('API key'); + expect($exception->getMessage())->toContain('session'); + }); + + it('preserves tool name across serialisation', function () { + $exception = new MissingWorkspaceContextException('SerialiseTest'); + $serialised = serialize($exception); + $restored = unserialize($serialised); + + expect($restored->tool)->toBe('SerialiseTest'); + }); +}); + +// ============================================================================= +// WorkspaceContext Core Tests +// ============================================================================= + +describe('WorkspaceContext', function () { + beforeEach(function () { + $this->workspace = Workspace::factory()->create([ + 'name' => 'Test Workspace', + 'slug' => 'test-workspace', + ]); + }); + + it('creates context from workspace model', function () { + $context = WorkspaceContext::fromWorkspace($this->workspace); + + expect($context->workspaceId)->toBe($this->workspace->id); + expect($context->workspace)->toBe($this->workspace); + }); + + it('creates context from workspace ID', function () { + $context = WorkspaceContext::fromId($this->workspace->id); + + expect($context->workspaceId)->toBe($this->workspace->id); + expect($context->workspace)->toBeNull(); + }); + + it('loads workspace when accessing from ID-only context', function () { + $context = WorkspaceContext::fromId($this->workspace->id); + + $loadedWorkspace = $context->getWorkspace(); + + expect($loadedWorkspace->id)->toBe($this->workspace->id); + expect($loadedWorkspace->name)->toBe('Test Workspace'); + }); + + it('validates ownership correctly', function () { + $context = WorkspaceContext::fromWorkspace($this->workspace); + + // Should not throw for matching workspace + $context->validateOwnership($this->workspace->id, 'invoice'); + + expect(true)->toBeTrue(); // If we get here, no exception was thrown + }); + + it('throws on ownership validation failure', function () { + $context = WorkspaceContext::fromWorkspace($this->workspace); + $differentWorkspaceId = $this->workspace->id + 999; + + expect(fn () => $context->validateOwnership($differentWorkspaceId, 'invoice')) + ->toThrow(\RuntimeException::class, 'invoice does not belong to the authenticated workspace'); + }); + + it('checks workspace ID correctly', function () { + $context = WorkspaceContext::fromWorkspace($this->workspace); + + expect($context->hasWorkspaceId($this->workspace->id))->toBeTrue(); + expect($context->hasWorkspaceId($this->workspace->id + 1))->toBeFalse(); + }); + + it('context is immutable after creation', function () { + $context = WorkspaceContext::fromWorkspace($this->workspace); + + // The workspaceId property is readonly + $reflection = new ReflectionProperty(WorkspaceContext::class, 'workspaceId'); + expect($reflection->isReadOnly())->toBeTrue(); + }); +}); + +// ============================================================================= +// WorkspaceContext Resolution from Headers Tests +// ============================================================================= + +describe('WorkspaceContext resolution from headers', function () { + beforeEach(function () { + $this->user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + }); + + it('resolves context from mcp_workspace request attribute', function () { + $request = Request::create('/api/mcp/tools/call', 'POST'); + $request->attributes->set('mcp_workspace', $this->workspace); + + $context = WorkspaceContext::fromRequest($request, 'TestTool'); + + expect($context->workspaceId)->toBe($this->workspace->id); + expect($context->workspace)->toBeInstanceOf(Workspace::class); + }); + + it('resolves context from generic workspace attribute', function () { + $request = Request::create('/api/mcp/tools/call', 'POST'); + $request->attributes->set('workspace', $this->workspace); + + $context = WorkspaceContext::fromRequest($request, 'TestTool'); + + expect($context->workspaceId)->toBe($this->workspace->id); + }); + + it('throws MissingWorkspaceContextException when no context available', function () { + $request = Request::create('/api/mcp/tools/call', 'POST'); + + expect(fn () => WorkspaceContext::fromRequest($request, 'QueryDatabase')) + ->toThrow(MissingWorkspaceContextException::class); + }); + + it('includes tool name in exception when context missing', function () { + $request = Request::create('/api/mcp/tools/call', 'POST'); + + try { + WorkspaceContext::fromRequest($request, 'ListInvoices'); + $this->fail('Expected MissingWorkspaceContextException'); + } catch (MissingWorkspaceContextException $e) { + expect($e->tool)->toBe('ListInvoices'); + } + }); + + it('prioritises mcp_workspace over generic workspace attribute', function () { + $otherWorkspace = Workspace::factory()->create(); + + $request = Request::create('/api/mcp/tools/call', 'POST'); + $request->attributes->set('mcp_workspace', $this->workspace); + $request->attributes->set('workspace', $otherWorkspace); + + $context = WorkspaceContext::fromRequest($request, 'TestTool'); + + expect($context->workspaceId)->toBe($this->workspace->id); + }); + + it('falls back to authenticated user default workspace', function () { + $request = Request::create('/api/mcp/tools/call', 'POST'); + $request->setUserResolver(fn () => $this->user); + + $context = WorkspaceContext::fromRequest($request, 'TestTool'); + + expect($context->workspaceId)->toBe($this->workspace->id); + }); +}); + +// ============================================================================= +// RequiresWorkspaceContext Trait Tests +// ============================================================================= + +describe('RequiresWorkspaceContext trait', function () { + beforeEach(function () { + $this->workspace = Workspace::factory()->create(); + $this->tool = new TestToolWithWorkspaceContext; + }); + + it('throws MissingWorkspaceContextException when no context set', function () { + expect(fn () => $this->tool->getWorkspaceId()) + ->toThrow(MissingWorkspaceContextException::class); + }); + + it('returns workspace ID when context is set', function () { + $this->tool->setWorkspace($this->workspace); + + expect($this->tool->getWorkspaceId())->toBe($this->workspace->id); + }); + + it('returns workspace when context is set', function () { + $this->tool->setWorkspace($this->workspace); + + $workspace = $this->tool->getWorkspace(); + + expect($workspace->id)->toBe($this->workspace->id); + }); + + it('allows setting context from workspace ID', function () { + $this->tool->setWorkspaceId($this->workspace->id); + + expect($this->tool->getWorkspaceId())->toBe($this->workspace->id); + }); + + it('allows setting context object directly', function () { + $context = WorkspaceContext::fromWorkspace($this->workspace); + $this->tool->setWorkspaceContext($context); + + expect($this->tool->getWorkspaceId())->toBe($this->workspace->id); + }); + + it('correctly reports whether context is available', function () { + expect($this->tool->hasWorkspaceContext())->toBeFalse(); + + $this->tool->setWorkspace($this->workspace); + + expect($this->tool->hasWorkspaceContext())->toBeTrue(); + }); + + it('validates resource ownership through context', function () { + $this->tool->setWorkspace($this->workspace); + $differentWorkspaceId = $this->workspace->id + 999; + + expect(fn () => $this->tool->validateResourceOwnership($differentWorkspaceId, 'subscription')) + ->toThrow(\RuntimeException::class, 'subscription does not belong'); + }); + + it('requires context with custom error message', function () { + expect(fn () => $this->tool->requireWorkspaceContext('listing invoices')) + ->toThrow(MissingWorkspaceContextException::class, 'listing invoices'); + }); + + it('uses class name when tool name property not set', function () { + $tool = new class { + use RequiresWorkspaceContext; + }; + + try { + $tool->getWorkspaceId(); + $this->fail('Expected exception'); + } catch (MissingWorkspaceContextException $e) { + // Should use the anonymous class basename + expect($e->tool)->not->toBeEmpty(); + } + }); + + it('allows clearing context by setting null workspace ID', function () { + $this->tool->setWorkspace($this->workspace); + expect($this->tool->hasWorkspaceContext())->toBeTrue(); + + // Setting via a new context with different workspace + $otherWorkspace = Workspace::factory()->create(); + $this->tool->setWorkspace($otherWorkspace); + + expect($this->tool->getWorkspaceId())->toBe($otherWorkspace->id); + }); +}); + +// ============================================================================= +// Cross-Workspace Isolation Tests +// ============================================================================= + +describe('Workspace-scoped tool security', function () { + beforeEach(function () { + $this->user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + + // Create another workspace to test isolation + $this->otherWorkspace = Workspace::factory()->create(); + }); + + it('prevents accessing another workspace data by setting context correctly', function () { + $context = WorkspaceContext::fromWorkspace($this->workspace); + + // Trying to validate ownership of data from another workspace should fail + expect(fn () => $context->validateOwnership($this->otherWorkspace->id, 'data')) + ->toThrow(\RuntimeException::class); + }); + + it('enforces workspace boundary for each resource type', function () { + $context = WorkspaceContext::fromWorkspace($this->workspace); + $otherWorkspaceId = $this->otherWorkspace->id; + + // Test various resource types + $resourceTypes = ['invoice', 'order', 'subscription', 'api_key', 'webhook', 'template']; + + foreach ($resourceTypes as $resourceType) { + expect(fn () => $context->validateOwnership($otherWorkspaceId, $resourceType)) + ->toThrow(\RuntimeException::class, "{$resourceType} does not belong"); + } + }); + + it('allows access to own workspace resources', function () { + $context = WorkspaceContext::fromWorkspace($this->workspace); + + // Should not throw for any resource type when workspace matches + $resourceTypes = ['invoice', 'order', 'subscription', 'api_key', 'webhook', 'template']; + + foreach ($resourceTypes as $resourceType) { + // This should not throw + $context->validateOwnership($this->workspace->id, $resourceType); + } + + expect(true)->toBeTrue(); // If we reach here, all validations passed + }); +}); + +// ============================================================================= +// Cross-Workspace Query Prevention Tests +// ============================================================================= + +describe('Cross-workspace query prevention', function () { + beforeEach(function () { + $this->workspaceA = Workspace::factory()->create(['name' => 'Workspace A']); + $this->workspaceB = Workspace::factory()->create(['name' => 'Workspace B']); + $this->tool = new TestToolWithWorkspaceContext; + }); + + it('prevents workspace A tool from accessing workspace B data', function () { + $this->tool->setWorkspace($this->workspaceA); + + expect(fn () => $this->tool->validateResourceOwnership($this->workspaceB->id, 'customer')) + ->toThrow(\RuntimeException::class); + }); + + it('prevents workspace B tool from accessing workspace A data', function () { + $this->tool->setWorkspace($this->workspaceB); + + expect(fn () => $this->tool->validateResourceOwnership($this->workspaceA->id, 'order')) + ->toThrow(\RuntimeException::class); + }); + + it('maintains isolation when workspace context changes', function () { + // Start with workspace A + $this->tool->setWorkspace($this->workspaceA); + expect($this->tool->getWorkspaceId())->toBe($this->workspaceA->id); + + // Validate workspace A can access its own data + $this->tool->validateResourceOwnership($this->workspaceA->id, 'data'); + + // Change to workspace B + $this->tool->setWorkspace($this->workspaceB); + expect($this->tool->getWorkspaceId())->toBe($this->workspaceB->id); + + // Workspace B should NOT be able to access workspace A data + expect(fn () => $this->tool->validateResourceOwnership($this->workspaceA->id, 'data')) + ->toThrow(\RuntimeException::class); + + // Workspace B can access its own data + $this->tool->validateResourceOwnership($this->workspaceB->id, 'data'); + expect(true)->toBeTrue(); + }); + + it('prevents cross-workspace access via ID manipulation', function () { + $this->tool->setWorkspace($this->workspaceA); + + // Try to access data by directly using the other workspace's ID + $attackWorkspaceId = $this->workspaceB->id; + + expect(fn () => $this->tool->validateResourceOwnership($attackWorkspaceId, 'sensitive_data')) + ->toThrow(\RuntimeException::class); + }); + + it('rejects zero as workspace ID', function () { + $context = WorkspaceContext::fromId(0); + + // A zero workspace ID should not match any real workspace + expect($context->hasWorkspaceId($this->workspaceA->id))->toBeFalse(); + expect($context->hasWorkspaceId($this->workspaceB->id))->toBeFalse(); + }); + + it('rejects negative workspace IDs in ownership checks', function () { + $context = WorkspaceContext::fromWorkspace($this->workspaceA); + + expect(fn () => $context->validateOwnership(-1, 'resource')) + ->toThrow(\RuntimeException::class); + }); +}); + +// ============================================================================= +// Workspace Boundary Enforcement Tests +// ============================================================================= + +describe('Workspace boundary enforcement', function () { + beforeEach(function () { + $this->workspace = Workspace::factory()->create(); + $this->tool = new TestToolWithWorkspaceContext; + }); + + it('enforces boundary before any database operation', function () { + // Without workspace context set, attempting to get workspace ID should fail + expect(fn () => $this->tool->getWorkspaceId()) + ->toThrow(MissingWorkspaceContextException::class); + }); + + it('enforces boundary for workspace model access', function () { + // Without workspace context set, attempting to get workspace should fail + expect(fn () => $this->tool->getWorkspace()) + ->toThrow(MissingWorkspaceContextException::class); + }); + + it('enforces boundary in require context method', function () { + expect(fn () => $this->tool->requireWorkspaceContext('database query')) + ->toThrow(MissingWorkspaceContextException::class, 'database query'); + }); + + it('provides clear error message about authentication requirements', function () { + try { + $this->tool->getWorkspaceId(); + $this->fail('Expected MissingWorkspaceContextException'); + } catch (MissingWorkspaceContextException $e) { + expect($e->getMessage())->toContain('test_tool'); + expect($e->getMessage())->toContain('workspace context'); + } + }); + + it('has context returns false for uninitialised tool', function () { + expect($this->tool->hasWorkspaceContext())->toBeFalse(); + }); + + it('has context returns true after setting workspace', function () { + $this->tool->setWorkspace($this->workspace); + expect($this->tool->hasWorkspaceContext())->toBeTrue(); + }); + + it('has context returns true after setting workspace ID', function () { + $this->tool->setWorkspaceId($this->workspace->id); + expect($this->tool->hasWorkspaceContext())->toBeTrue(); + }); + + it('has context returns true after setting context object', function () { + $context = WorkspaceContext::fromWorkspace($this->workspace); + $this->tool->setWorkspaceContext($context); + expect($this->tool->hasWorkspaceContext())->toBeTrue(); + }); +}); + +// ============================================================================= +// Context Injection Tests +// ============================================================================= + +describe('Context injection', function () { + beforeEach(function () { + $this->workspace = Workspace::factory()->create(); + $this->middleware = new ValidateWorkspaceContext; + }); + + it('injects context into request attributes', function () { + $request = Request::create('/api/mcp/tools/call', 'POST'); + $request->attributes->set('mcp_workspace', $this->workspace); + + $injectedContext = null; + $this->middleware->handle($request, function ($request) use (&$injectedContext) { + $injectedContext = $request->attributes->get('mcp_workspace_context'); + + return response()->json(['success' => true]); + }); + + expect($injectedContext)->toBeInstanceOf(WorkspaceContext::class); + expect($injectedContext->workspaceId)->toBe($this->workspace->id); + }); + + it('injects context with correct workspace reference', function () { + $request = Request::create('/api/mcp/tools/call', 'POST'); + $request->attributes->set('mcp_workspace', $this->workspace); + + $injectedContext = null; + $this->middleware->handle($request, function ($request) use (&$injectedContext) { + $injectedContext = $request->attributes->get('mcp_workspace_context'); + + return response()->json(['success' => true]); + }); + + expect($injectedContext->workspace)->toBe($this->workspace); + }); + + it('context remains accessible throughout request lifecycle', function () { + $request = Request::create('/api/mcp/tools/call', 'POST'); + $request->attributes->set('mcp_workspace', $this->workspace); + + $contextChecks = []; + $this->middleware->handle($request, function ($request) use (&$contextChecks) { + // First access + $contextChecks['first'] = $request->attributes->get('mcp_workspace_context'); + + // Simulated middleware/controller access + $contextChecks['second'] = $request->attributes->get('mcp_workspace_context'); + + return response()->json(['success' => true]); + }); + + expect($contextChecks['first'])->toBe($contextChecks['second']); + expect($contextChecks['first']->workspaceId)->toBe($this->workspace->id); + }); +}); + +// ============================================================================= +// Automatic Workspace Scoping Tests +// ============================================================================= + +describe('Automatic workspace scoping', function () { + beforeEach(function () { + $this->workspace = Workspace::factory()->create(); + $this->tool = new TestToolWithWorkspaceContext; + $this->tool->setWorkspace($this->workspace); + }); + + it('provides workspace ID for query scoping', function () { + $workspaceId = $this->tool->getWorkspaceId(); + + expect($workspaceId)->toBe($this->workspace->id); + expect($workspaceId)->toBeInt(); + }); + + it('provides workspace model for relationship queries', function () { + $workspace = $this->tool->getWorkspace(); + + expect($workspace)->toBeInstanceOf(Workspace::class); + expect($workspace->id)->toBe($this->workspace->id); + }); + + it('workspace ID is consistent across multiple calls', function () { + $id1 = $this->tool->getWorkspaceId(); + $id2 = $this->tool->getWorkspaceId(); + $id3 = $this->tool->getWorkspaceId(); + + expect($id1)->toBe($id2); + expect($id2)->toBe($id3); + }); + + it('validates resource belongs to workspace before access', function () { + // Create resource belonging to this workspace (simulated) + $resourceWorkspaceId = $this->workspace->id; + + // This should not throw + $this->tool->validateResourceOwnership($resourceWorkspaceId, 'record'); + + expect(true)->toBeTrue(); + }); + + it('blocks access to resource from different workspace', function () { + $otherWorkspace = Workspace::factory()->create(); + $resourceWorkspaceId = $otherWorkspace->id; + + expect(fn () => $this->tool->validateResourceOwnership($resourceWorkspaceId, 'record')) + ->toThrow(\RuntimeException::class); + }); +}); + +// ============================================================================= +// Edge Cases and Security Tests +// ============================================================================= + +describe('Edge cases and security', function () { + beforeEach(function () { + $this->workspace = Workspace::factory()->create(); + }); + + it('handles concurrent workspace context correctly', function () { + $tool1 = new TestToolWithWorkspaceContext; + $tool2 = new TestToolWithWorkspaceContext; + $otherWorkspace = Workspace::factory()->create(); + + $tool1->setWorkspace($this->workspace); + $tool2->setWorkspace($otherWorkspace); + + // Each tool should maintain its own context + expect($tool1->getWorkspaceId())->toBe($this->workspace->id); + expect($tool2->getWorkspaceId())->toBe($otherWorkspace->id); + }); + + it('workspace context is isolated per tool instance', function () { + $tool1 = new TestToolWithWorkspaceContext; + $tool2 = new TestToolWithWorkspaceContext; + + $tool1->setWorkspace($this->workspace); + + // tool2 should not have context just because tool1 does + expect($tool1->hasWorkspaceContext())->toBeTrue(); + expect($tool2->hasWorkspaceContext())->toBeFalse(); + }); + + it('does not leak workspace data between requests', function () { + $request1 = Request::create('/api/mcp/tools/call', 'POST'); + $request1->attributes->set('mcp_workspace', $this->workspace); + + $request2 = Request::create('/api/mcp/tools/call', 'POST'); + // request2 has no workspace attribute + + $middleware = new ValidateWorkspaceContext; + + // First request should have context + $context1 = null; + $middleware->handle($request1, function ($req) use (&$context1) { + $context1 = $req->attributes->get('mcp_workspace_context'); + + return response()->json([]); + }); + + // Second request (in required mode) should fail, not inherit from first + $response = $middleware->handle($request2, function () { + return response()->json(['success' => true]); + }, 'required'); + + expect($context1)->toBeInstanceOf(WorkspaceContext::class); + expect($response->getStatusCode())->toBe(403); + }); + + it('context object prevents modification of workspace ID', function () { + $context = WorkspaceContext::fromWorkspace($this->workspace); + + // The readonly property should prevent modification + // This test verifies the architecture decision + $reflection = new ReflectionClass($context); + $property = $reflection->getProperty('workspaceId'); + + expect($property->isReadOnly())->toBeTrue(); + }); + + it('handles workspace deletion gracefully', function () { + $workspaceId = $this->workspace->id; + $context = WorkspaceContext::fromId($workspaceId); + + // Delete the workspace + $this->workspace->delete(); + + // Context still holds the ID (but getWorkspace() would fail) + expect($context->workspaceId)->toBe($workspaceId); + expect(fn () => $context->getWorkspace()) + ->toThrow(\Illuminate\Database\Eloquent\ModelNotFoundException::class); + }); +}); diff --git a/src/php/src/Mcp/Tests/UseCase/ApiKeyManagerBasic.php b/src/php/src/Mcp/Tests/UseCase/ApiKeyManagerBasic.php new file mode 100644 index 0000000..d9dcbf6 --- /dev/null +++ b/src/php/src/Mcp/Tests/UseCase/ApiKeyManagerBasic.php @@ -0,0 +1,71 @@ +user = User::factory()->create([ + 'email' => 'test@example.com', + 'password' => bcrypt('password'), + ]); + + $this->workspace = Workspace::factory()->create(); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + }); + + it('can view the API key manager page with all sections', function () { + // Login and navigate to MCP keys page + $this->actingAs($this->user); + + $response = $this->get(route('mcp.keys')); + + $response->assertOk(); + + // Verify page title and description + $response->assertSee(__('mcp::mcp.keys.title')); + $response->assertSee(__('mcp::mcp.keys.description')); + + // Verify empty state when no keys exist + $response->assertSee(__('mcp::mcp.keys.empty.title')); + $response->assertSee(__('mcp::mcp.keys.empty.description')); + + // Verify action buttons + $response->assertSee(__('mcp::mcp.keys.actions.create')); + }); + + it('can view the playground page', function () { + $this->actingAs($this->user); + + $response = $this->get(route('mcp.playground')); + + $response->assertOk(); + + // Verify page title and description + $response->assertSee(__('mcp::mcp.playground.title')); + $response->assertSee(__('mcp::mcp.playground.description')); + }); + + it('can view the request log page', function () { + $this->actingAs($this->user); + + $response = $this->get(route('mcp.logs')); + + $response->assertOk(); + + // Verify page title and description + $response->assertSee(__('mcp::mcp.logs.title')); + $response->assertSee(__('mcp::mcp.logs.description')); + }); +}); diff --git a/src/php/src/Mcp/Tools/Commerce/CreateCoupon.php b/src/php/src/Mcp/Tools/Commerce/CreateCoupon.php new file mode 100644 index 0000000..04385b2 --- /dev/null +++ b/src/php/src/Mcp/Tools/Commerce/CreateCoupon.php @@ -0,0 +1,100 @@ +input('code')); + $name = $request->input('name'); + $type = $request->input('type', 'percentage'); + $value = $request->input('value'); + $duration = $request->input('duration', 'once'); + $maxUses = $request->input('max_uses'); + $validUntil = $request->input('valid_until'); + + // Validate code format + if (! preg_match('/^[A-Z0-9_-]+$/', $code)) { + return Response::text(json_encode([ + 'error' => 'Invalid code format. Use only uppercase letters, numbers, hyphens, and underscores.', + ])); + } + + // Check for existing code + if (Coupon::where('code', $code)->exists()) { + return Response::text(json_encode([ + 'error' => 'A coupon with this code already exists.', + ])); + } + + // Validate type + if (! in_array($type, ['percentage', 'fixed_amount'])) { + return Response::text(json_encode([ + 'error' => 'Invalid type. Use percentage or fixed_amount.', + ])); + } + + // Validate value + if ($type === 'percentage' && ($value < 1 || $value > 100)) { + return Response::text(json_encode([ + 'error' => 'Percentage value must be between 1 and 100.', + ])); + } + + try { + $coupon = Coupon::create([ + 'code' => $code, + 'name' => $name, + 'type' => $type, + 'value' => $value, + 'duration' => $duration, + 'max_uses' => $maxUses, + 'max_uses_per_workspace' => 1, + 'valid_until' => $validUntil ? \Carbon\Carbon::parse($validUntil) : null, + 'is_active' => true, + 'applies_to' => 'all', + ]); + + return Response::text(json_encode([ + 'success' => true, + 'coupon' => [ + 'id' => $coupon->id, + 'code' => $coupon->code, + 'name' => $coupon->name, + 'type' => $coupon->type, + 'value' => (float) $coupon->value, + 'duration' => $coupon->duration, + 'max_uses' => $coupon->max_uses, + 'valid_until' => $coupon->valid_until?->toDateString(), + 'is_active' => $coupon->is_active, + ], + ], JSON_PRETTY_PRINT)); + } catch (\Exception $e) { + return Response::text(json_encode([ + 'error' => 'Failed to create coupon: '.$e->getMessage(), + ])); + } + } + + public function schema(JsonSchema $schema): array + { + return [ + 'code' => $schema->string('Unique coupon code (uppercase letters, numbers, hyphens, underscores)')->required(), + 'name' => $schema->string('Display name for the coupon')->required(), + 'type' => $schema->string('Discount type: percentage or fixed_amount (default: percentage)'), + 'value' => $schema->number('Discount value (percentage 1-100 or fixed amount)')->required(), + 'duration' => $schema->string('How long discount applies: once, repeating, or forever (default: once)'), + 'max_uses' => $schema->integer('Maximum total uses (null for unlimited)'), + 'valid_until' => $schema->string('Expiry date in YYYY-MM-DD format'), + ]; + } +} diff --git a/src/php/src/Mcp/Tools/Commerce/GetBillingStatus.php b/src/php/src/Mcp/Tools/Commerce/GetBillingStatus.php new file mode 100644 index 0000000..31e313d --- /dev/null +++ b/src/php/src/Mcp/Tools/Commerce/GetBillingStatus.php @@ -0,0 +1,77 @@ +getWorkspace(); + $workspaceId = $workspace->id; + + // Get active subscription + $subscription = Subscription::with('workspacePackage.package') + ->where('workspace_id', $workspaceId) + ->whereIn('status', ['active', 'trialing', 'past_due']) + ->first(); + + // Get workspace packages + $packages = $workspace->workspacePackages() + ->with('package') + ->whereIn('status', ['active', 'trial']) + ->get(); + + $status = [ + 'workspace' => [ + 'id' => $workspace->id, + 'name' => $workspace->name, + ], + 'subscription' => $subscription ? [ + 'id' => $subscription->id, + 'status' => $subscription->status, + 'gateway' => $subscription->gateway, + 'billing_cycle' => $subscription->billing_cycle, + 'current_period_start' => $subscription->current_period_start?->toIso8601String(), + 'current_period_end' => $subscription->current_period_end?->toIso8601String(), + 'days_until_renewal' => $subscription->daysUntilRenewal(), + 'cancel_at_period_end' => $subscription->cancel_at_period_end, + 'on_trial' => $subscription->onTrial(), + 'trial_ends_at' => $subscription->trial_ends_at?->toIso8601String(), + ] : null, + 'packages' => $packages->map(fn ($wp) => [ + 'code' => $wp->package?->code, + 'name' => $wp->package?->name, + 'status' => $wp->status, + 'expires_at' => $wp->expires_at?->toIso8601String(), + ])->values()->all(), + ]; + + return Response::text(json_encode($status, JSON_PRETTY_PRINT)); + } + + public function schema(JsonSchema $schema): array + { + // No parameters needed - workspace comes from authentication context + return []; + } +} diff --git a/src/php/src/Mcp/Tools/Commerce/ListInvoices.php b/src/php/src/Mcp/Tools/Commerce/ListInvoices.php new file mode 100644 index 0000000..4f9a6e7 --- /dev/null +++ b/src/php/src/Mcp/Tools/Commerce/ListInvoices.php @@ -0,0 +1,76 @@ +getWorkspaceId(); + + $status = $request->input('status'); // paid, pending, overdue, void + $limit = min($request->input('limit', 10), 50); + + $query = Invoice::with('order') + ->where('workspace_id', $workspaceId) + ->latest(); + + if ($status) { + $query->where('status', $status); + } + + $invoices = $query->limit($limit)->get(); + + $result = [ + 'workspace_id' => $workspaceId, + 'count' => $invoices->count(), + 'invoices' => $invoices->map(fn ($invoice) => [ + 'id' => $invoice->id, + 'invoice_number' => $invoice->invoice_number, + 'status' => $invoice->status, + 'subtotal' => (float) $invoice->subtotal, + 'discount_amount' => (float) $invoice->discount_amount, + 'tax_amount' => (float) $invoice->tax_amount, + 'total' => (float) $invoice->total, + 'amount_paid' => (float) $invoice->amount_paid, + 'amount_due' => (float) $invoice->amount_due, + 'currency' => $invoice->currency, + 'issue_date' => $invoice->issue_date?->toDateString(), + 'due_date' => $invoice->due_date?->toDateString(), + 'paid_at' => $invoice->paid_at?->toIso8601String(), + 'is_overdue' => $invoice->isOverdue(), + 'order_number' => $invoice->order?->order_number, + ])->all(), + ]; + + return Response::text(json_encode($result, JSON_PRETTY_PRINT)); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'status' => $schema->string('Filter by status: paid, pending, overdue, void'), + 'limit' => $schema->integer('Maximum number of invoices to return (default 10, max 50)'), + ]; + } +} diff --git a/src/php/src/Mcp/Tools/Commerce/UpgradePlan.php b/src/php/src/Mcp/Tools/Commerce/UpgradePlan.php new file mode 100644 index 0000000..9c95228 --- /dev/null +++ b/src/php/src/Mcp/Tools/Commerce/UpgradePlan.php @@ -0,0 +1,120 @@ +getWorkspace(); + $workspaceId = $workspace->id; + + $newPackageCode = $request->input('package_code'); + $preview = $request->input('preview', true); + $immediate = $request->input('immediate', true); + + $newPackage = Package::where('code', $newPackageCode)->first(); + + if (! $newPackage) { + return Response::text(json_encode([ + 'error' => 'Package not found', + 'available_packages' => Package::where('is_active', true) + ->where('is_public', true) + ->pluck('code') + ->all(), + ])); + } + + // Get active subscription + $subscription = Subscription::with('workspacePackage.package') + ->where('workspace_id', $workspaceId) + ->whereIn('status', ['active', 'trialing']) + ->first(); + + if (! $subscription) { + return Response::text(json_encode([ + 'error' => 'No active subscription found for this workspace', + ])); + } + + $subscriptionService = app(SubscriptionService::class); + + try { + if ($preview) { + // Preview the proration + $proration = $subscriptionService->previewPlanChange($subscription, $newPackage); + + return Response::text(json_encode([ + 'preview' => true, + 'current_package' => $subscription->workspacePackage?->package?->code, + 'new_package' => $newPackage->code, + 'proration' => [ + 'is_upgrade' => $proration->isUpgrade(), + 'is_downgrade' => $proration->isDowngrade(), + 'current_plan_price' => $proration->currentPlanPrice, + 'new_plan_price' => $proration->newPlanPrice, + 'credit_amount' => $proration->creditAmount, + 'prorated_new_cost' => $proration->proratedNewPlanCost, + 'net_amount' => $proration->netAmount, + 'requires_payment' => $proration->requiresPayment(), + 'days_remaining' => $proration->daysRemaining, + 'currency' => $proration->currency, + ], + ], JSON_PRETTY_PRINT)); + } + + // Execute the plan change + $result = $subscriptionService->changePlan( + $subscription, + $newPackage, + prorate: true, + immediate: $immediate + ); + + return Response::text(json_encode([ + 'success' => true, + 'immediate' => $result['immediate'], + 'current_package' => $subscription->workspacePackage?->package?->code, + 'new_package' => $newPackage->code, + 'proration' => $result['proration']?->toArray(), + 'subscription_status' => $result['subscription']->status, + ], JSON_PRETTY_PRINT)); + + } catch (\Exception $e) { + return Response::text(json_encode([ + 'error' => $e->getMessage(), + ])); + } + } + + public function schema(JsonSchema $schema): array + { + return [ + 'package_code' => $schema->string('The code of the new package (e.g., agency, enterprise)')->required(), + 'preview' => $schema->boolean('If true, only preview the change without executing (default: true)'), + 'immediate' => $schema->boolean('If true, apply change immediately; if false, schedule for period end (default: true)'), + ]; + } +} diff --git a/src/php/src/Mcp/Tools/Concerns/RequiresWorkspaceContext.php b/src/php/src/Mcp/Tools/Concerns/RequiresWorkspaceContext.php new file mode 100644 index 0000000..c1cb247 --- /dev/null +++ b/src/php/src/Mcp/Tools/Concerns/RequiresWorkspaceContext.php @@ -0,0 +1,135 @@ +name + ? $this->name + : class_basename(static::class); + } + + /** + * Get the workspace context, throwing if not available. + * + * @throws MissingWorkspaceContextException + */ + protected function getWorkspaceContext(): WorkspaceContext + { + if ($this->workspaceContext) { + return $this->workspaceContext; + } + + throw new MissingWorkspaceContextException($this->getToolName()); + } + + /** + * Get the workspace ID from context. + * + * @throws MissingWorkspaceContextException + */ + protected function getWorkspaceId(): int + { + return $this->getWorkspaceContext()->workspaceId; + } + + /** + * Get the workspace model from context. + * + * @throws MissingWorkspaceContextException + */ + protected function getWorkspace(): Workspace + { + return $this->getWorkspaceContext()->getWorkspace(); + } + + /** + * Set the workspace context for this tool execution. + */ + public function setWorkspaceContext(WorkspaceContext $context): void + { + $this->workspaceContext = $context; + } + + /** + * Set workspace context from a workspace model. + */ + public function setWorkspace(Workspace $workspace): void + { + $this->workspaceContext = WorkspaceContext::fromWorkspace($workspace); + } + + /** + * Set workspace context from a workspace ID. + */ + public function setWorkspaceId(int $workspaceId): void + { + $this->workspaceContext = WorkspaceContext::fromId($workspaceId); + } + + /** + * Check if workspace context is available. + */ + protected function hasWorkspaceContext(): bool + { + return $this->workspaceContext !== null; + } + + /** + * Validate that a resource belongs to the current workspace. + * + * @throws \RuntimeException If the resource doesn't belong to this workspace + * @throws MissingWorkspaceContextException If no workspace context + */ + protected function validateResourceOwnership(int $resourceWorkspaceId, string $resourceType = 'resource'): void + { + $this->getWorkspaceContext()->validateOwnership($resourceWorkspaceId, $resourceType); + } + + /** + * Require workspace context, throwing with a custom message if not available. + * + * @throws MissingWorkspaceContextException + */ + protected function requireWorkspaceContext(string $operation = 'this operation'): WorkspaceContext + { + if (! $this->workspaceContext) { + throw new MissingWorkspaceContextException( + $this->getToolName(), + sprintf( + "Workspace context is required for %s in tool '%s'. Authenticate with an API key or user session.", + $operation, + $this->getToolName() + ) + ); + } + + return $this->workspaceContext; + } +} diff --git a/src/php/src/Mcp/Tools/Concerns/ValidatesDependencies.php b/src/php/src/Mcp/Tools/Concerns/ValidatesDependencies.php new file mode 100644 index 0000000..01b7663 --- /dev/null +++ b/src/php/src/Mcp/Tools/Concerns/ValidatesDependencies.php @@ -0,0 +1,123 @@ + + */ + public function dependencies(): array + { + return []; + } + + /** + * Validate that all dependencies are met. + * + * @param array $context The execution context + * @param array $args The tool arguments + * + * @throws MissingDependencyException If dependencies are not met + */ + protected function validateDependencies(array $context = [], array $args = []): void + { + $sessionId = $context['session_id'] ?? 'anonymous'; + + app(ToolDependencyService::class)->validateDependencies( + sessionId: $sessionId, + toolName: $this->name(), + context: $context, + args: $args, + ); + } + + /** + * Check if all dependencies are met without throwing. + * + * @param array $context The execution context + * @param array $args The tool arguments + */ + protected function dependenciesMet(array $context = [], array $args = []): bool + { + $sessionId = $context['session_id'] ?? 'anonymous'; + + return app(ToolDependencyService::class)->checkDependencies( + sessionId: $sessionId, + toolName: $this->name(), + context: $context, + args: $args, + ); + } + + /** + * Get list of unmet dependencies. + * + * @param array $context The execution context + * @param array $args The tool arguments + * @return array + */ + protected function getMissingDependencies(array $context = [], array $args = []): array + { + $sessionId = $context['session_id'] ?? 'anonymous'; + + return app(ToolDependencyService::class)->getMissingDependencies( + sessionId: $sessionId, + toolName: $this->name(), + context: $context, + args: $args, + ); + } + + /** + * Record this tool call for dependency tracking. + * + * @param array $context The execution context + * @param array $args The tool arguments + */ + protected function recordToolCall(array $context = [], array $args = []): void + { + $sessionId = $context['session_id'] ?? 'anonymous'; + + app(ToolDependencyService::class)->recordToolCall( + sessionId: $sessionId, + toolName: $this->name(), + args: $args, + ); + } + + /** + * Create a dependency error response. + */ + protected function dependencyError(MissingDependencyException $e): array + { + return [ + 'error' => 'dependency_not_met', + 'message' => $e->getMessage(), + 'missing' => array_map( + fn (ToolDependency $dep) => [ + 'type' => $dep->type->value, + 'key' => $dep->key, + 'description' => $dep->description, + ], + $e->missingDependencies + ), + 'suggested_order' => $e->suggestedOrder, + ]; + } +} diff --git a/src/php/src/Mcp/Tools/ContentTools.php b/src/php/src/Mcp/Tools/ContentTools.php new file mode 100644 index 0000000..1ae32f5 --- /dev/null +++ b/src/php/src/Mcp/Tools/ContentTools.php @@ -0,0 +1,633 @@ +get('action'); + $workspaceSlug = $request->get('workspace'); + + // Resolve workspace + $workspace = $this->resolveWorkspace($workspaceSlug); + if (! $workspace && in_array($action, ['list', 'read', 'create', 'update', 'delete'])) { + return Response::text(json_encode([ + 'error' => 'Workspace is required. Provide a workspace slug.', + ])); + } + + return match ($action) { + 'list' => $this->listContent($workspace, $request), + 'read' => $this->readContent($workspace, $request), + 'create' => $this->createContent($workspace, $request), + 'update' => $this->updateContent($workspace, $request), + 'delete' => $this->deleteContent($workspace, $request), + 'taxonomies' => $this->listTaxonomies($workspace, $request), + default => Response::text(json_encode([ + 'error' => 'Invalid action. Available: list, read, create, update, delete, taxonomies', + ])), + }; + } + + /** + * Resolve workspace from slug. + */ + protected function resolveWorkspace(?string $slug): ?Workspace + { + if (! $slug) { + return null; + } + + return Workspace::where('slug', $slug) + ->orWhere('id', $slug) + ->first(); + } + + /** + * Check entitlements for content operations. + */ + protected function checkEntitlement(Workspace $workspace, string $action): ?array + { + $entitlements = app(EntitlementService::class); + + // Check if workspace has content MCP access + $result = $entitlements->can($workspace, 'content.mcp_access'); + + if ($result->isDenied()) { + return ['error' => $result->reason ?? 'Content MCP access not available in your plan.']; + } + + // For create operations, check content limits + if ($action === 'create') { + $limitResult = $entitlements->can($workspace, 'content.items'); + if ($limitResult->isDenied()) { + return ['error' => $limitResult->reason ?? 'Content item limit reached.']; + } + } + + return null; + } + + /** + * List content items for a workspace. + */ + protected function listContent(Workspace $workspace, Request $request): Response + { + $query = ContentItem::forWorkspace($workspace->id) + ->native() + ->with(['author', 'taxonomies']); + + // Filter by type (post/page) + if ($type = $request->get('type')) { + $query->where('type', $type); + } + + // Filter by status + if ($status = $request->get('status')) { + if ($status === 'published') { + $query->published(); + } elseif ($status === 'scheduled') { + $query->scheduled(); + } else { + $query->where('status', $status); + } + } + + // Search + if ($search = $request->get('search')) { + $query->where(function ($q) use ($search) { + $q->where('title', 'like', "%{$search}%") + ->orWhere('content_html', 'like', "%{$search}%") + ->orWhere('excerpt', 'like', "%{$search}%"); + }); + } + + // Pagination + $limit = min($request->get('limit', 20), 100); + $offset = $request->get('offset', 0); + + $total = $query->count(); + $items = $query->orderByDesc('updated_at') + ->skip($offset) + ->take($limit) + ->get(); + + $result = [ + 'items' => $items->map(fn (ContentItem $item) => [ + 'id' => $item->id, + 'slug' => $item->slug, + 'title' => $item->title, + 'type' => $item->type, + 'status' => $item->status, + 'excerpt' => Str::limit($item->excerpt, 200), + 'author' => $item->author?->name, + 'categories' => $item->categories->pluck('name')->all(), + 'tags' => $item->tags->pluck('name')->all(), + 'word_count' => str_word_count(strip_tags($item->content_html ?? '')), + 'publish_at' => $item->publish_at?->toIso8601String(), + 'created_at' => $item->created_at->toIso8601String(), + 'updated_at' => $item->updated_at->toIso8601String(), + ]), + 'total' => $total, + 'limit' => $limit, + 'offset' => $offset, + ]; + + return Response::text(json_encode($result, JSON_PRETTY_PRINT)); + } + + /** + * Read full content of an item. + */ + protected function readContent(Workspace $workspace, Request $request): Response + { + $identifier = $request->get('identifier'); + + if (! $identifier) { + return Response::text(json_encode(['error' => 'identifier (slug or ID) is required'])); + } + + $query = ContentItem::forWorkspace($workspace->id)->native(); + + // Find by ID, slug, or wp_id + if (is_numeric($identifier)) { + $item = $query->where('id', $identifier) + ->orWhere('wp_id', $identifier) + ->first(); + } else { + $item = $query->where('slug', $identifier)->first(); + } + + if (! $item) { + return Response::text(json_encode(['error' => 'Content not found'])); + } + + // Load relationships + $item->load(['author', 'taxonomies', 'revisions' => fn ($q) => $q->latest()->limit(5)]); + + // Return as markdown with frontmatter for AI context + $format = $request->get('format', 'json'); + + if ($format === 'markdown') { + $markdown = $this->contentToMarkdown($item); + + return Response::text($markdown); + } + + $result = [ + 'id' => $item->id, + 'slug' => $item->slug, + 'title' => $item->title, + 'type' => $item->type, + 'status' => $item->status, + 'excerpt' => $item->excerpt, + 'content_html' => $item->content_html, + 'content_markdown' => $item->content_markdown, + 'author' => [ + 'id' => $item->author?->id, + 'name' => $item->author?->name, + ], + 'categories' => $item->categories->map(fn ($t) => [ + 'id' => $t->id, + 'slug' => $t->slug, + 'name' => $t->name, + ])->all(), + 'tags' => $item->tags->map(fn ($t) => [ + 'id' => $t->id, + 'slug' => $t->slug, + 'name' => $t->name, + ])->all(), + 'seo_meta' => $item->seo_meta, + 'publish_at' => $item->publish_at?->toIso8601String(), + 'revision_count' => $item->revision_count, + 'recent_revisions' => $item->revisions->map(fn ($r) => [ + 'id' => $r->id, + 'revision_number' => $r->revision_number, + 'change_type' => $r->change_type, + 'created_at' => $r->created_at->toIso8601String(), + ])->all(), + 'created_at' => $item->created_at->toIso8601String(), + 'updated_at' => $item->updated_at->toIso8601String(), + ]; + + return Response::text(json_encode($result, JSON_PRETTY_PRINT)); + } + + /** + * Create new content. + */ + protected function createContent(Workspace $workspace, Request $request): Response + { + // Check entitlements + $entitlementError = $this->checkEntitlement($workspace, 'create'); + if ($entitlementError) { + return Response::text(json_encode($entitlementError)); + } + + // Validate required fields + $title = $request->get('title'); + if (! $title) { + return Response::text(json_encode(['error' => 'title is required'])); + } + + $type = $request->get('type', 'post'); + if (! in_array($type, ['post', 'page'])) { + return Response::text(json_encode(['error' => 'type must be post or page'])); + } + + $status = $request->get('status', 'draft'); + if (! in_array($status, ['draft', 'publish', 'future', 'private'])) { + return Response::text(json_encode(['error' => 'status must be draft, publish, future, or private'])); + } + + // Generate slug + $slug = $request->get('slug') ?: Str::slug($title); + $baseSlug = $slug; + $counter = 1; + + // Ensure unique slug within workspace + while (ContentItem::forWorkspace($workspace->id)->where('slug', $slug)->exists()) { + $slug = $baseSlug.'-'.$counter++; + } + + // Parse markdown content if provided + $content = $request->get('content', ''); + $contentHtml = $request->get('content_html'); + $contentMarkdown = $request->get('content_markdown', $content); + + // Convert markdown to HTML if only markdown provided + if ($contentMarkdown && ! $contentHtml) { + $contentHtml = Str::markdown($contentMarkdown); + } + + // Handle scheduling + $publishAt = null; + if ($status === 'future') { + $publishAt = $request->get('publish_at'); + if (! $publishAt) { + return Response::text(json_encode(['error' => 'publish_at is required for scheduled content'])); + } + $publishAt = \Carbon\Carbon::parse($publishAt); + } + + // Create content item + $item = ContentItem::create([ + 'workspace_id' => $workspace->id, + 'content_type' => ContentType::NATIVE, + 'type' => $type, + 'status' => $status, + 'slug' => $slug, + 'title' => $title, + 'excerpt' => $request->get('excerpt'), + 'content_html' => $contentHtml, + 'content_markdown' => $contentMarkdown, + 'seo_meta' => $request->get('seo_meta'), + 'publish_at' => $publishAt, + 'last_edited_by' => Auth::id(), + ]); + + // Handle categories + if ($categories = $request->get('categories')) { + $categoryIds = $this->resolveOrCreateTaxonomies($workspace, $categories, 'category'); + $item->taxonomies()->attach($categoryIds); + } + + // Handle tags + if ($tags = $request->get('tags')) { + $tagIds = $this->resolveOrCreateTaxonomies($workspace, $tags, 'tag'); + $item->taxonomies()->attach($tagIds); + } + + // Create initial revision + $item->createRevision(Auth::user(), ContentRevision::CHANGE_EDIT, 'Created via MCP'); + + // Record usage + $entitlements = app(EntitlementService::class); + $entitlements->recordUsage($workspace, 'content.items', 1, Auth::user(), [ + 'source' => 'mcp', + 'content_id' => $item->id, + ]); + + return Response::text(json_encode([ + 'ok' => true, + 'item' => [ + 'id' => $item->id, + 'slug' => $item->slug, + 'title' => $item->title, + 'type' => $item->type, + 'status' => $item->status, + 'url' => $this->getContentUrl($workspace, $item), + ], + ], JSON_PRETTY_PRINT)); + } + + /** + * Update existing content. + */ + protected function updateContent(Workspace $workspace, Request $request): Response + { + $identifier = $request->get('identifier'); + + if (! $identifier) { + return Response::text(json_encode(['error' => 'identifier (slug or ID) is required'])); + } + + $query = ContentItem::forWorkspace($workspace->id)->native(); + + if (is_numeric($identifier)) { + $item = $query->find($identifier); + } else { + $item = $query->where('slug', $identifier)->first(); + } + + if (! $item) { + return Response::text(json_encode(['error' => 'Content not found'])); + } + + // Build update data + $updateData = []; + + if ($request->has('title')) { + $updateData['title'] = $request->get('title'); + } + + if ($request->has('excerpt')) { + $updateData['excerpt'] = $request->get('excerpt'); + } + + if ($request->has('content') || $request->has('content_markdown')) { + $contentMarkdown = $request->get('content_markdown') ?? $request->get('content'); + $updateData['content_markdown'] = $contentMarkdown; + $updateData['content_html'] = $request->get('content_html') ?? Str::markdown($contentMarkdown); + } + + if ($request->has('content_html') && ! $request->has('content_markdown')) { + $updateData['content_html'] = $request->get('content_html'); + } + + if ($request->has('status')) { + $status = $request->get('status'); + if (! in_array($status, ['draft', 'publish', 'future', 'private'])) { + return Response::text(json_encode(['error' => 'status must be draft, publish, future, or private'])); + } + $updateData['status'] = $status; + + if ($status === 'future' && $request->has('publish_at')) { + $updateData['publish_at'] = \Carbon\Carbon::parse($request->get('publish_at')); + } + } + + if ($request->has('seo_meta')) { + $updateData['seo_meta'] = $request->get('seo_meta'); + } + + if ($request->has('slug')) { + $newSlug = $request->get('slug'); + if ($newSlug !== $item->slug) { + // Check uniqueness + if (ContentItem::forWorkspace($workspace->id)->where('slug', $newSlug)->where('id', '!=', $item->id)->exists()) { + return Response::text(json_encode(['error' => 'Slug already exists'])); + } + $updateData['slug'] = $newSlug; + } + } + + $updateData['last_edited_by'] = Auth::id(); + + // Update item + $item->update($updateData); + + // Handle categories + if ($request->has('categories')) { + $categoryIds = $this->resolveOrCreateTaxonomies($workspace, $request->get('categories'), 'category'); + $item->categories()->sync($categoryIds); + } + + // Handle tags + if ($request->has('tags')) { + $tagIds = $this->resolveOrCreateTaxonomies($workspace, $request->get('tags'), 'tag'); + $item->tags()->sync($tagIds); + } + + // Create revision + $changeSummary = $request->get('change_summary', 'Updated via MCP'); + $item->createRevision(Auth::user(), ContentRevision::CHANGE_EDIT, $changeSummary); + + $item->refresh()->load(['author', 'taxonomies']); + + return Response::text(json_encode([ + 'ok' => true, + 'item' => [ + 'id' => $item->id, + 'slug' => $item->slug, + 'title' => $item->title, + 'type' => $item->type, + 'status' => $item->status, + 'revision_count' => $item->revision_count, + 'url' => $this->getContentUrl($workspace, $item), + ], + ], JSON_PRETTY_PRINT)); + } + + /** + * Delete content (soft delete). + */ + protected function deleteContent(Workspace $workspace, Request $request): Response + { + $identifier = $request->get('identifier'); + + if (! $identifier) { + return Response::text(json_encode(['error' => 'identifier (slug or ID) is required'])); + } + + $query = ContentItem::forWorkspace($workspace->id)->native(); + + if (is_numeric($identifier)) { + $item = $query->find($identifier); + } else { + $item = $query->where('slug', $identifier)->first(); + } + + if (! $item) { + return Response::text(json_encode(['error' => 'Content not found'])); + } + + // Create final revision before delete + $item->createRevision(Auth::user(), ContentRevision::CHANGE_EDIT, 'Deleted via MCP'); + + // Soft delete + $item->delete(); + + return Response::text(json_encode([ + 'ok' => true, + 'deleted' => [ + 'id' => $item->id, + 'slug' => $item->slug, + 'title' => $item->title, + ], + ], JSON_PRETTY_PRINT)); + } + + /** + * List taxonomies (categories and tags). + */ + protected function listTaxonomies(Workspace $workspace, Request $request): Response + { + $type = $request->get('type'); // category or tag + + $query = ContentTaxonomy::where('workspace_id', $workspace->id); + + if ($type) { + $query->where('type', $type); + } + + $taxonomies = $query->orderBy('name')->get(); + + $result = [ + 'taxonomies' => $taxonomies->map(fn ($t) => [ + 'id' => $t->id, + 'type' => $t->type, + 'slug' => $t->slug, + 'name' => $t->name, + 'description' => $t->description, + ])->all(), + 'total' => $taxonomies->count(), + ]; + + return Response::text(json_encode($result, JSON_PRETTY_PRINT)); + } + + /** + * Resolve or create taxonomies from slugs/names. + */ + protected function resolveOrCreateTaxonomies(Workspace $workspace, array $items, string $type): array + { + $ids = []; + + foreach ($items as $item) { + $taxonomy = ContentTaxonomy::where('workspace_id', $workspace->id) + ->where('type', $type) + ->where(function ($q) use ($item) { + $q->where('slug', $item) + ->orWhere('name', $item); + }) + ->first(); + + if (! $taxonomy) { + // Create new taxonomy + $taxonomy = ContentTaxonomy::create([ + 'workspace_id' => $workspace->id, + 'type' => $type, + 'slug' => Str::slug($item), + 'name' => $item, + ]); + } + + $ids[] = $taxonomy->id; + } + + return $ids; + } + + /** + * Convert content item to markdown with frontmatter. + */ + protected function contentToMarkdown(ContentItem $item): string + { + $frontmatter = [ + 'title' => $item->title, + 'slug' => $item->slug, + 'type' => $item->type, + 'status' => $item->status, + 'author' => $item->author?->name, + 'categories' => $item->categories->pluck('name')->all(), + 'tags' => $item->tags->pluck('name')->all(), + 'created_at' => $item->created_at->toIso8601String(), + 'updated_at' => $item->updated_at->toIso8601String(), + ]; + + if ($item->publish_at) { + $frontmatter['publish_at'] = $item->publish_at->toIso8601String(); + } + + if ($item->seo_meta) { + $frontmatter['seo'] = $item->seo_meta; + } + + $yaml = "---\n"; + foreach ($frontmatter as $key => $value) { + if (is_array($value)) { + $yaml .= "{$key}: ".json_encode($value)."\n"; + } else { + $yaml .= "{$key}: {$value}\n"; + } + } + $yaml .= "---\n\n"; + + // Prefer markdown content, fall back to stripping HTML + $content = $item->content_markdown ?? strip_tags($item->content_html ?? ''); + + return $yaml.$content; + } + + /** + * Get the public URL for content. + */ + protected function getContentUrl(Workspace $workspace, ContentItem $item): string + { + $domain = $workspace->domain ?? config('app.url'); + $path = $item->type === 'post' ? "/blog/{$item->slug}" : "/{$item->slug}"; + + return "https://{$domain}{$path}"; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'action' => $schema->string('Action: list, read, create, update, delete, taxonomies'), + 'workspace' => $schema->string('Workspace slug (required for most actions)')->nullable(), + 'identifier' => $schema->string('Content slug or ID (for read, update, delete)')->nullable(), + 'type' => $schema->string('Content type: post or page (for list filter or create)')->nullable(), + 'status' => $schema->string('Content status: draft, publish, future, private')->nullable(), + 'search' => $schema->string('Search term for list action')->nullable(), + 'limit' => $schema->integer('Max items to return (default 20, max 100)')->nullable(), + 'offset' => $schema->integer('Offset for pagination')->nullable(), + 'format' => $schema->string('Output format: json or markdown (for read action)')->nullable(), + 'title' => $schema->string('Content title (for create/update)')->nullable(), + 'slug' => $schema->string('URL slug (for create/update)')->nullable(), + 'excerpt' => $schema->string('Content excerpt/summary')->nullable(), + 'content' => $schema->string('Content body as markdown (for create/update)')->nullable(), + 'content_html' => $schema->string('Content body as HTML (optional, auto-generated from markdown)')->nullable(), + 'content_markdown' => $schema->string('Content body as markdown (alias for content)')->nullable(), + 'categories' => $schema->array('Array of category slugs or names')->nullable(), + 'tags' => $schema->array('Array of tag strings')->nullable(), + 'seo_meta' => $schema->array('SEO metadata: {title, description, keywords}')->nullable(), + 'publish_at' => $schema->string('ISO datetime for scheduled publishing (status=future)')->nullable(), + 'change_summary' => $schema->string('Summary of changes for revision history (update action)')->nullable(), + ]; + } +} diff --git a/src/php/src/Mcp/Tools/GetStats.php b/src/php/src/Mcp/Tools/GetStats.php new file mode 100644 index 0000000..42ae06d --- /dev/null +++ b/src/php/src/Mcp/Tools/GetStats.php @@ -0,0 +1,30 @@ + 6, + 'active_users' => 128, + 'page_views_30d' => 12500, + 'server_load' => '23%', + ]; + + return Response::text(json_encode($stats, JSON_PRETTY_PRINT)); + } + + public function schema(JsonSchema $schema): array + { + return []; + } +} diff --git a/src/php/src/Mcp/Tools/ListRoutes.php b/src/php/src/Mcp/Tools/ListRoutes.php new file mode 100644 index 0000000..7afa3ff --- /dev/null +++ b/src/php/src/Mcp/Tools/ListRoutes.php @@ -0,0 +1,32 @@ +getRoutes()) + ->map(fn ($route) => [ + 'uri' => $route->uri(), + 'methods' => $route->methods(), + 'name' => $route->getName(), + ]) + ->values() + ->toArray(); + + return Response::text(json_encode($routes, JSON_PRETTY_PRINT)); + } + + public function schema(JsonSchema $schema): array + { + return []; + } +} diff --git a/src/php/src/Mcp/Tools/ListSites.php b/src/php/src/Mcp/Tools/ListSites.php new file mode 100644 index 0000000..bd4b627 --- /dev/null +++ b/src/php/src/Mcp/Tools/ListSites.php @@ -0,0 +1,32 @@ + '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'], + ['name' => 'TrustHost', 'domain' => 'trust.host.uk.com', 'type' => 'WordPress'], + ['name' => 'NotifyHost', 'domain' => 'notify.host.uk.com', 'type' => 'Go'], + ['name' => 'MailHost', 'domain' => 'hostmail.cc', 'type' => 'MailCow'], + ]; + + return Response::text(json_encode($sites, JSON_PRETTY_PRINT)); + } + + public function schema(JsonSchema $schema): array + { + return []; + } +} diff --git a/src/php/src/Mcp/Tools/ListTables.php b/src/php/src/Mcp/Tools/ListTables.php new file mode 100644 index 0000000..ce3accb --- /dev/null +++ b/src/php/src/Mcp/Tools/ListTables.php @@ -0,0 +1,28 @@ +map(fn ($table) => array_values((array) $table)[0]) + ->toArray(); + + return Response::text(json_encode($tables, JSON_PRETTY_PRINT)); + } + + public function schema(JsonSchema $schema): array + { + return []; + } +} diff --git a/src/php/src/Mcp/Tools/QueryDatabase.php b/src/php/src/Mcp/Tools/QueryDatabase.php new file mode 100644 index 0000000..4a31144 --- /dev/null +++ b/src/php/src/Mcp/Tools/QueryDatabase.php @@ -0,0 +1,417 @@ +validator = $this->createValidator(); + $this->auditService = $auditService ?? app(QueryAuditService::class); + $this->executionService = $executionService ?? app(QueryExecutionService::class); + } + + public function handle(Request $request): Response + { + $query = $request->input('query'); + $explain = $request->input('explain', false); + + // Extract context from request for audit logging + $workspaceId = $this->getWorkspaceId($request); + $userId = $this->getUserId($request); + $userIp = $this->getUserIp($request); + $sessionId = $request->input('session_id'); + + if (empty($query)) { + return $this->errorResponse('Query is required'); + } + + // Validate the query - log blocked queries + try { + $this->validator->validate($query); + } catch (ForbiddenQueryException $e) { + $this->auditService->recordBlocked( + query: $query, + bindings: [], + reason: $e->reason, + workspaceId: $workspaceId, + userId: $userId, + userIp: $userIp, + context: ['session_id' => $sessionId] + ); + + return $this->errorResponse($e->getMessage()); + } + + // Check for blocked tables + $blockedTable = $this->checkBlockedTables($query); + if ($blockedTable !== null) { + $this->auditService->recordBlocked( + query: $query, + bindings: [], + reason: "Access to blocked table: {$blockedTable}", + workspaceId: $workspaceId, + userId: $userId, + userIp: $userIp, + context: ['session_id' => $sessionId, 'blocked_table' => $blockedTable] + ); + + return $this->errorResponse( + sprintf("Access to table '%s' is not permitted", $blockedTable) + ); + } + + try { + $connection = $this->getConnection(); + + // If explain is requested, run EXPLAIN first + if ($explain) { + return $this->handleExplain($connection, $query, $workspaceId, $userId, $userIp, $sessionId); + } + + // Execute query with tier-based limits, timeout, and audit logging + $result = $this->executionService->execute( + query: $query, + connection: $connection, + workspaceId: $workspaceId, + userId: $userId, + userIp: $userIp, + context: [ + 'session_id' => $sessionId, + 'explain_requested' => false, + ] + ); + + // Build response with data and metadata + $response = [ + 'data' => $result['data'], + 'meta' => $result['meta'], + ]; + + // Add warning if results were truncated + if ($result['meta']['truncated']) { + $response['warning'] = $result['meta']['warning']; + } + + return Response::text(json_encode($response, JSON_PRETTY_PRINT)); + } catch (QueryTimeoutException $e) { + return $this->errorResponse( + 'Query timed out: '.$e->getMessage(). + ' Consider adding more specific filters or indexes.' + ); + } catch (\Exception $e) { + // Log the actual error for debugging but return sanitised message + report($e); + + return $this->errorResponse('Query execution failed: '.$this->sanitiseErrorMessage($e->getMessage())); + } + } + + public function schema(JsonSchema $schema): array + { + return [ + 'query' => $schema->string('SQL SELECT query to execute. Only read-only SELECT queries are permitted.'), + 'explain' => $schema->boolean('If true, runs EXPLAIN on the query instead of executing it. Useful for query optimisation and debugging.')->default(false), + ]; + } + + /** + * Create the SQL validator with configuration. + */ + private function createValidator(): SqlQueryValidator + { + $useWhitelist = Config::get('mcp.database.use_whitelist', true); + $customPatterns = Config::get('mcp.database.whitelist_patterns', []); + + $validator = new SqlQueryValidator(null, $useWhitelist); + + foreach ($customPatterns as $pattern) { + $validator->addWhitelistPattern($pattern); + } + + return $validator; + } + + /** + * Get the database connection to use. + * + * @throws \RuntimeException If the configured connection is invalid + */ + private function getConnection(): ?string + { + $connection = Config::get('mcp.database.connection'); + + // If configured connection doesn't exist, throw exception + if ($connection && ! Config::has("database.connections.{$connection}")) { + throw new \RuntimeException( + "Invalid MCP database connection '{$connection}' configured. ". + "Please ensure 'database.connections.{$connection}' exists in your database configuration." + ); + } + + return $connection; + } + + /** + * Check if the query references any blocked tables. + */ + private function checkBlockedTables(string $query): ?string + { + $blockedTables = Config::get('mcp.database.blocked_tables', []); + + foreach ($blockedTables as $table) { + // Check for table references in various formats + $patterns = [ + '/\bFROM\s+`?'.preg_quote($table, '/').'`?\b/i', + '/\bJOIN\s+`?'.preg_quote($table, '/').'`?\b/i', + '/\b'.preg_quote($table, '/').'\./i', // table.column format + ]; + + foreach ($patterns as $pattern) { + if (preg_match($pattern, $query)) { + return $table; + } + } + } + + return null; + } + + /** + * Extract workspace ID from request context. + */ + private function getWorkspaceId(Request $request): ?int + { + // Try to get from request context or metadata + $workspaceId = $request->input('workspace_id'); + if ($workspaceId !== null) { + return (int) $workspaceId; + } + + // Try from auth context + if (function_exists('workspace') && workspace()) { + return workspace()->id; + } + + return null; + } + + /** + * Extract user ID from request context. + */ + private function getUserId(Request $request): ?int + { + // Try to get from request context + $userId = $request->input('user_id'); + if ($userId !== null) { + return (int) $userId; + } + + // Try from auth + if (auth()->check()) { + return auth()->id(); + } + + return null; + } + + /** + * Extract user IP from request context. + */ + private function getUserIp(Request $request): ?string + { + // Try from request metadata + $ip = $request->input('user_ip'); + if ($ip !== null) { + return $ip; + } + + // Try from HTTP request + if (request()) { + return request()->ip(); + } + + return null; + } + + /** + * Sanitise database error messages to avoid leaking sensitive information. + */ + private function sanitiseErrorMessage(string $message): string + { + // Remove specific database paths, credentials, etc. + $message = preg_replace('/\/[^\s]+/', '[path]', $message); + $message = preg_replace('/at \d+\.\d+\.\d+\.\d+/', 'at [ip]', $message); + + // Truncate long messages + if (strlen($message) > 200) { + $message = substr($message, 0, 200).'...'; + } + + return $message; + } + + /** + * Handle EXPLAIN query execution. + */ + private function handleExplain( + ?string $connection, + string $query, + ?int $workspaceId = null, + ?int $userId = null, + ?string $userIp = null, + ?string $sessionId = null + ): Response { + $startTime = microtime(true); + + try { + // Run EXPLAIN on the query + $explainResults = DB::connection($connection)->select("EXPLAIN {$query}"); + $durationMs = (int) ((microtime(true) - $startTime) * 1000); + + // Also try to get extended information if MySQL/MariaDB + $warnings = []; + try { + $warnings = DB::connection($connection)->select('SHOW WARNINGS'); + } catch (\Exception $e) { + // SHOW WARNINGS may not be available on all databases + } + + $response = [ + 'explain' => $explainResults, + 'query' => $query, + ]; + + if (! empty($warnings)) { + $response['warnings'] = $warnings; + } + + // Add helpful interpretation + $response['interpretation'] = $this->interpretExplain($explainResults); + + // Log the EXPLAIN query + $this->auditService->recordSuccess( + query: "EXPLAIN {$query}", + bindings: [], + durationMs: $durationMs, + rowCount: count($explainResults), + workspaceId: $workspaceId, + userId: $userId, + userIp: $userIp, + context: ['session_id' => $sessionId, 'explain_requested' => true] + ); + + return Response::text(json_encode($response, JSON_PRETTY_PRINT)); + } catch (\Exception $e) { + $durationMs = (int) ((microtime(true) - $startTime) * 1000); + + $this->auditService->recordError( + query: "EXPLAIN {$query}", + bindings: [], + errorMessage: $e->getMessage(), + durationMs: $durationMs, + workspaceId: $workspaceId, + userId: $userId, + userIp: $userIp, + context: ['session_id' => $sessionId, 'explain_requested' => true] + ); + + report($e); + + return $this->errorResponse('EXPLAIN failed: '.$this->sanitiseErrorMessage($e->getMessage())); + } + } + + /** + * Provide human-readable interpretation of EXPLAIN results. + */ + private function interpretExplain(array $explainResults): array + { + $interpretation = []; + + foreach ($explainResults as $row) { + $rowAnalysis = []; + + // Convert stdClass to array for easier access + $rowArray = (array) $row; + + // Check for full table scan + if (isset($rowArray['type']) && $rowArray['type'] === 'ALL') { + $rowAnalysis[] = 'WARNING: Full table scan detected. Consider adding an index.'; + } + + // Check for filesort + if (isset($rowArray['Extra']) && str_contains($rowArray['Extra'], 'Using filesort')) { + $rowAnalysis[] = 'INFO: Using filesort. Query may benefit from an index on ORDER BY columns.'; + } + + // Check for temporary table + if (isset($rowArray['Extra']) && str_contains($rowArray['Extra'], 'Using temporary')) { + $rowAnalysis[] = 'INFO: Using temporary table. Consider optimizing the query.'; + } + + // Check rows examined + if (isset($rowArray['rows']) && $rowArray['rows'] > 10000) { + $rowAnalysis[] = sprintf('WARNING: High row count (%d rows). Query may be slow.', $rowArray['rows']); + } + + // Check if index is used + if (isset($rowArray['key']) && $rowArray['key'] !== null) { + $rowAnalysis[] = sprintf('GOOD: Using index: %s', $rowArray['key']); + } + + if (! empty($rowAnalysis)) { + $interpretation[] = [ + 'table' => $rowArray['table'] ?? 'unknown', + 'analysis' => $rowAnalysis, + ]; + } + } + + return $interpretation; + } + + /** + * Create an error response. + */ + private function errorResponse(string $message): Response + { + return Response::text(json_encode(['error' => $message])); + } +} diff --git a/src/php/src/Mcp/View/Blade/admin/analytics/dashboard.blade.php b/src/php/src/Mcp/View/Blade/admin/analytics/dashboard.blade.php new file mode 100644 index 0000000..10a44b0 --- /dev/null +++ b/src/php/src/Mcp/View/Blade/admin/analytics/dashboard.blade.php @@ -0,0 +1,233 @@ +
+ +
+
+ Tool Usage Analytics + Monitor MCP tool usage patterns, performance, and errors +
+
+ + 7 Days + 14 Days + 30 Days + + Refresh +
+
+ + +
+ @include('mcp::admin.analytics.partials.stats-card', [ + 'label' => 'Total Calls', + 'value' => number_format($this->overview['total_calls']), + 'color' => 'default', + ]) + + @include('mcp::admin.analytics.partials.stats-card', [ + 'label' => 'Error Rate', + 'value' => $this->overview['error_rate'] . '%', + 'color' => $this->overview['error_rate'] > 10 ? 'red' : ($this->overview['error_rate'] > 5 ? 'yellow' : 'green'), + ]) + + @include('mcp::admin.analytics.partials.stats-card', [ + 'label' => 'Avg Response', + 'value' => $this->formatDuration($this->overview['avg_duration_ms']), + 'color' => $this->overview['avg_duration_ms'] > 5000 ? 'yellow' : 'default', + ]) + + @include('mcp::admin.analytics.partials.stats-card', [ + 'label' => 'Total Errors', + 'value' => number_format($this->overview['total_errors']), + 'color' => $this->overview['total_errors'] > 0 ? 'red' : 'default', + ]) + + @include('mcp::admin.analytics.partials.stats-card', [ + 'label' => 'Unique Tools', + 'value' => $this->overview['unique_tools'], + 'color' => 'default', + ]) +
+ + +
+ +
+ + @if($tab === 'overview') +
+ +
+
+ Top 10 Most Used Tools +
+
+ @if($this->popularTools->isEmpty()) +
No tool usage data available
+ @else +
+ @php $maxCalls = $this->popularTools->first()->totalCalls ?: 1; @endphp + @foreach($this->popularTools as $tool) +
+
+ {{ $tool->toolName }} +
+
+
+
+
+
+
+
+ {{ number_format($tool->totalCalls) }} +
+
+ {{ $tool->errorRate }}% +
+
+ @endforeach +
+ @endif +
+
+ + +
+
+ Tools with Highest Error Rates +
+
+ @if($this->errorProneTools->isEmpty()) +
All tools are healthy - no significant errors
+ @else +
+ @foreach($this->errorProneTools as $tool) +
+
+ + {{ $tool->toolName }} + +
+ {{ number_format($tool->errorCount) }} errors / {{ number_format($tool->totalCalls) }} calls +
+
+ + {{ $tool->errorRate }}% errors + +
+ @endforeach +
+ @endif +
+
+
+ @endif + + @if($tab === 'tools') + +
+
+ All Tools + {{ $this->sortedTools->count() }} tools +
+
+ @include('mcp::admin.analytics.partials.tool-table', ['tools' => $this->sortedTools]) +
+
+ @endif + + @if($tab === 'errors') + +
+
+ Error Analysis +
+
+ @if($this->errorProneTools->isEmpty()) +
+
+ All tools are healthy - no significant errors detected +
+ @else +
+ @foreach($this->errorProneTools as $tool) +
+
+ + {{ $tool->toolName }} + + + {{ $tool->errorRate }}% Error Rate + +
+
+
+ Total Calls: + {{ number_format($tool->totalCalls) }} +
+
+ Errors: + {{ number_format($tool->errorCount) }} +
+
+ Avg Duration: + {{ $this->formatDuration($tool->avgDurationMs) }} +
+
+ Max Duration: + {{ $this->formatDuration($tool->maxDurationMs) }} +
+
+
+ @endforeach +
+ @endif +
+
+ @endif + + @if($tab === 'combinations') + +
+
+ Popular Tool Combinations + Tools frequently used together in the same session +
+
+ @if($this->toolCombinations->isEmpty()) +
No tool combination data available yet
+ @else +
+ @foreach($this->toolCombinations as $combo) +
+
+ {{ $combo['tool_a'] }} + + + {{ $combo['tool_b'] }} +
+ + {{ number_format($combo['occurrences']) }} times + +
+ @endforeach +
+ @endif +
+
+ @endif +
diff --git a/src/php/src/Mcp/View/Blade/admin/analytics/partials/stats-card.blade.php b/src/php/src/Mcp/View/Blade/admin/analytics/partials/stats-card.blade.php new file mode 100644 index 0000000..c873cf3 --- /dev/null +++ b/src/php/src/Mcp/View/Blade/admin/analytics/partials/stats-card.blade.php @@ -0,0 +1,32 @@ +@props([ + 'label', + 'value', + 'color' => 'default', + 'subtext' => null, +]) + +@php + $colorClasses = match($color) { + 'red' => 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800', + 'yellow' => 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800', + 'green' => 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800', + 'blue' => 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800', + default => 'bg-white dark:bg-zinc-800 border-zinc-200 dark:border-zinc-700', + }; + + $valueClasses = match($color) { + 'red' => 'text-red-600 dark:text-red-400', + 'yellow' => 'text-yellow-600 dark:text-yellow-400', + 'green' => 'text-green-600 dark:text-green-400', + 'blue' => 'text-blue-600 dark:text-blue-400', + default => '', + }; +@endphp + +
+ {{ $label }} + {{ $value }} + @if($subtext) + {{ $subtext }} + @endif +
diff --git a/src/php/src/Mcp/View/Blade/admin/analytics/partials/tool-table.blade.php b/src/php/src/Mcp/View/Blade/admin/analytics/partials/tool-table.blade.php new file mode 100644 index 0000000..a03c517 --- /dev/null +++ b/src/php/src/Mcp/View/Blade/admin/analytics/partials/tool-table.blade.php @@ -0,0 +1,100 @@ +@props(['tools']) + + + + + + + + + + + + + + + @forelse($tools as $tool) + + + + + + + + + + @empty + + + + @endforelse + +
+
+ Tool Name + @if($sortColumn === 'toolName') + {{ $sortDirection === 'asc' ? '▲' : '▼' }} + @endif +
+
+
+ Total Calls + @if($sortColumn === 'totalCalls') + {{ $sortDirection === 'asc' ? '▲' : '▼' }} + @endif +
+
+
+ Errors + @if($sortColumn === 'errorCount') + {{ $sortDirection === 'asc' ? '▲' : '▼' }} + @endif +
+
+
+ Error Rate + @if($sortColumn === 'errorRate') + {{ $sortDirection === 'asc' ? '▲' : '▼' }} + @endif +
+
+
+ Avg Duration + @if($sortColumn === 'avgDurationMs') + {{ $sortDirection === 'asc' ? '▲' : '▼' }} + @endif +
+
+ Min / Max + + Actions +
+ + {{ $tool->toolName }} + + + {{ number_format($tool->totalCalls) }} + + {{ number_format($tool->errorCount) }} + + + {{ $tool->errorRate }}% + + + {{ $this->formatDuration($tool->avgDurationMs) }} + + {{ $this->formatDuration($tool->minDurationMs) }} / {{ $this->formatDuration($tool->maxDurationMs) }} + + + View Details + +
+ No tool usage data available +
diff --git a/src/php/src/Mcp/View/Blade/admin/analytics/tool-detail.blade.php b/src/php/src/Mcp/View/Blade/admin/analytics/tool-detail.blade.php new file mode 100644 index 0000000..3166aaa --- /dev/null +++ b/src/php/src/Mcp/View/Blade/admin/analytics/tool-detail.blade.php @@ -0,0 +1,183 @@ +
+ +
+
+ + {{ $toolName }} + Detailed usage analytics for this tool +
+
+ + 7 Days + 14 Days + 30 Days + + Refresh +
+
+ + +
+
+ Total Calls + {{ number_format($this->stats->totalCalls) }} +
+ +
+ Error Rate + + {{ $this->stats->errorRate }}% + +
+ +
+ Total Errors + + {{ number_format($this->stats->errorCount) }} + +
+ +
+ Avg Duration + {{ $this->formatDuration($this->stats->avgDurationMs) }} +
+ +
+ Min Duration + {{ $this->formatDuration($this->stats->minDurationMs) }} +
+ +
+ Max Duration + {{ $this->formatDuration($this->stats->maxDurationMs) }} +
+
+ + +
+
+ Usage Trend +
+
+ @if(empty($this->trends) || array_sum(array_column($this->trends, 'calls')) === 0) +
No usage data available for this period
+ @else +
+ @php + $maxCalls = max(array_column($this->trends, 'calls')) ?: 1; + @endphp + @foreach($this->trends as $day) +
+ {{ $day['date_formatted'] }} +
+
+ @php + $callsWidth = ($day['calls'] / $maxCalls) * 100; + $errorsWidth = $day['calls'] > 0 ? ($day['errors'] / $day['calls']) * $callsWidth : 0; + $successWidth = $callsWidth - $errorsWidth; + @endphp +
+
+
+
+
+ {{ $day['calls'] }} +
+
+ @if($day['calls'] > 0) + + {{ round($day['error_rate'], 1) }}% + + @else + - + @endif +
+
+ @endforeach +
+ +
+
+
+ Successful +
+
+
+ Errors +
+
+ @endif +
+
+ + +
+
+ Response Time Distribution +
+
+
+
+
Fastest
+
{{ $this->formatDuration($this->stats->minDurationMs) }}
+
+
+
Average
+
{{ $this->formatDuration($this->stats->avgDurationMs) }}
+
+
+
Slowest
+
{{ $this->formatDuration($this->stats->maxDurationMs) }}
+
+
+
+
+ + +
+
+ Daily Breakdown +
+
+ + + + + + + + + + + + @forelse($this->trends as $day) + @if($day['calls'] > 0) + + + + + + + + @endif + @empty + + + + @endforelse + +
DateCallsErrorsError RateAvg Duration
{{ $day['date'] }}{{ number_format($day['calls']) }}{{ number_format($day['errors']) }} + + {{ round($day['error_rate'], 1) }}% + + {{ $this->formatDuration($day['avg_duration_ms']) }}
+ No data available for this period +
+
+
+
diff --git a/src/php/src/Mcp/View/Blade/admin/api-key-manager.blade.php b/src/php/src/Mcp/View/Blade/admin/api-key-manager.blade.php new file mode 100644 index 0000000..7226a73 --- /dev/null +++ b/src/php/src/Mcp/View/Blade/admin/api-key-manager.blade.php @@ -0,0 +1,268 @@ +
+ + @if(session('message')) +
+

{{ session('message') }}

+
+ @endif + + +
+
+

+ {{ __('mcp::mcp.keys.title') }} +

+

+ {{ __('mcp::mcp.keys.description') }} +

+
+ + {{ __('mcp::mcp.keys.actions.create') }} + +
+ + +
+ @if($keys->isEmpty()) +
+
+ +
+

{{ __('mcp::mcp.keys.empty.title') }}

+

+ {{ __('mcp::mcp.keys.empty.description') }} +

+ + {{ __('mcp::mcp.keys.actions.create_first') }} + +
+ @else + + + + + + + + + + + + + @foreach($keys as $key) + + + + + + + + + @endforeach + +
+ {{ __('mcp::mcp.keys.table.name') }} + + {{ __('mcp::mcp.keys.table.key') }} + + {{ __('mcp::mcp.keys.table.scopes') }} + + {{ __('mcp::mcp.keys.table.last_used') }} + + {{ __('mcp::mcp.keys.table.expires') }} + + {{ __('mcp::mcp.keys.table.actions') }} +
+ {{ $key->name }} + + + {{ $key->prefix }}_**** + + +
+ @foreach($key->scopes ?? [] as $scope) + + {{ $scope }} + + @endforeach +
+
+ {{ $key->last_used_at?->diffForHumans() ?? __('mcp::mcp.keys.status.never') }} + + @if($key->expires_at) + @if($key->expires_at->isPast()) + {{ __('mcp::mcp.keys.status.expired') }} + @else + {{ $key->expires_at->diffForHumans() }} + @endif + @else + {{ __('mcp::mcp.keys.status.never') }} + @endif + + + {{ __('mcp::mcp.keys.actions.revoke') }} + +
+ @endif +
+ + +
+ +
+

+ + {{ __('mcp::mcp.keys.auth.title') }} +

+

+ {{ __('mcp::mcp.keys.auth.description') }} +

+
+
+

{{ __('mcp::mcp.keys.auth.header_recommended') }}

+
Authorization: Bearer hk_abc123_****
+
+
+

{{ __('mcp::mcp.keys.auth.header_api_key') }}

+
X-API-Key: hk_abc123_****
+
+
+
+ + +
+

+ + {{ __('mcp::mcp.keys.example.title') }} +

+

+ {{ __('mcp::mcp.keys.example.description') }} +

+
curl -X POST https://mcp.host.uk.com/api/v1/tools/call \
+  -H "Authorization: Bearer YOUR_API_KEY" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "server": "commerce",
+    "tool": "product_list",
+    "arguments": {}
+  }'
+
+
+ + + +
+

{{ __('mcp::mcp.keys.create_modal.title') }}

+ +
+ +
+ {{ __('mcp::mcp.keys.create_modal.name_label') }} + + @error('newKeyName') +

{{ $message }}

+ @enderror +
+ + +
+ {{ __('mcp::mcp.keys.create_modal.permissions_label') }} +
+ + + +
+
+ + +
+ {{ __('mcp::mcp.keys.create_modal.expiry_label') }} + + + + + + +
+
+ +
+ {{ __('mcp::mcp.keys.create_modal.cancel') }} + {{ __('mcp::mcp.keys.create_modal.create') }} +
+
+
+ + + +
+
+
+ +
+

{{ __('mcp::mcp.keys.new_key_modal.title') }}

+
+ +
+

+ {{ __('mcp::mcp.keys.new_key_modal.warning') }} {{ __('mcp::mcp.keys.new_key_modal.warning_detail') }} +

+
+ +
+
{{ $newPlainKey }}
+ +
+ +
+ {{ __('mcp::mcp.keys.new_key_modal.done') }} +
+
+
+
diff --git a/src/php/src/Mcp/View/Blade/admin/audit-log-viewer.blade.php b/src/php/src/Mcp/View/Blade/admin/audit-log-viewer.blade.php new file mode 100644 index 0000000..dbac118 --- /dev/null +++ b/src/php/src/Mcp/View/Blade/admin/audit-log-viewer.blade.php @@ -0,0 +1,400 @@ +{{-- +MCP Audit Log Viewer. + +Displays immutable audit trail for MCP tool executions. +Includes integrity verification and compliance export features. +--}} + +
+ {{-- Header --}} +
+
+ {{ __('MCP Audit Log') }} + Immutable audit trail for tool executions with hash chain integrity +
+
+ + Verify Integrity + + + Export + +
+
+ + {{-- Stats Cards --}} +
+
+
Total Entries
+
+ {{ number_format($this->stats['total']) }} +
+
+
+
Success Rate
+
+ {{ $this->stats['success_rate'] }}% +
+
+
+
Failed Calls
+
+ {{ number_format($this->stats['failed']) }} +
+
+
+
Sensitive Calls
+
+ {{ number_format($this->stats['sensitive_calls']) }} +
+
+
+ + {{-- Filters --}} +
+
+ +
+ + All tools + @foreach ($this->tools as $toolName) + {{ $toolName }} + @endforeach + + + All workspaces + @foreach ($this->workspaces as $ws) + {{ $ws->name }} + @endforeach + + + All statuses + Success + Failed + + + All sensitivity + Sensitive only + Normal only + + + + @if($search || $tool || $workspace || $status || $sensitivity || $dateFrom || $dateTo) + Clear + @endif +
+ + {{-- Audit Log Table --}} + + + ID + Time + Tool + Workspace + Status + Sensitivity + Actor + Duration + + + + + @forelse ($this->entries as $entry) + + + #{{ $entry->id }} + + + {{ $entry->created_at->format('M j, Y H:i:s') }} + + +
{{ $entry->tool_name }}
+
{{ $entry->server_id }}
+
+ + @if($entry->workspace) + {{ $entry->workspace->name }} + @else + - + @endif + + + + {{ $entry->success ? 'Success' : 'Failed' }} + + + + @if($entry->is_sensitive) + + Sensitive + + @else + - + @endif + + + {{ $entry->getActorDisplay() }} + @if($entry->actor_ip) +
{{ $entry->actor_ip }}
+ @endif +
+ + {{ $entry->getDurationForHumans() }} + + + + View + + +
+ @empty + + +
+
+ +
+ No audit entries found + Audit logs will appear here as tools are executed. +
+
+
+ @endforelse +
+
+ + @if($this->entries->hasPages()) +
+ {{ $this->entries->links() }} +
+ @endif + + {{-- Entry Detail Modal --}} + @if($this->selectedEntry) + +
+
+ Audit Entry #{{ $this->selectedEntry->id }} + +
+ + {{-- Integrity Status --}} + @php + $integrity = $this->selectedEntry->getIntegrityStatus(); + @endphp +
+
+ + + {{ $integrity['valid'] ? 'Integrity Verified' : 'Integrity Issues Detected' }} + +
+ @if(!$integrity['valid']) +
    + @foreach($integrity['issues'] as $issue) +
  • {{ $issue }}
  • + @endforeach +
+ @endif +
+ + {{-- Entry Details --}} +
+
+
Tool
+
{{ $this->selectedEntry->tool_name }}
+
+
+
Server
+
{{ $this->selectedEntry->server_id }}
+
+
+
Timestamp
+
{{ $this->selectedEntry->created_at->format('Y-m-d H:i:s.u') }}
+
+
+
Duration
+
{{ $this->selectedEntry->getDurationForHumans() }}
+
+
+
Status
+
+ + {{ $this->selectedEntry->success ? 'Success' : 'Failed' }} + +
+
+
+
Actor
+
{{ $this->selectedEntry->getActorDisplay() }}
+
+
+ + @if($this->selectedEntry->is_sensitive) +
+
+ + Sensitive Tool +
+

+ {{ $this->selectedEntry->sensitivity_reason ?? 'This tool is flagged as sensitive.' }} +

+
+ @endif + + @if($this->selectedEntry->error_message) +
+
Error
+
+ @if($this->selectedEntry->error_code) +
+ {{ $this->selectedEntry->error_code }} +
+ @endif +
+ {{ $this->selectedEntry->error_message }} +
+
+
+ @endif + + @if($this->selectedEntry->input_params) +
+
Input Parameters
+
{{ json_encode($this->selectedEntry->input_params, JSON_PRETTY_PRINT) }}
+
+ @endif + + @if($this->selectedEntry->output_summary) +
+
Output Summary
+
{{ json_encode($this->selectedEntry->output_summary, JSON_PRETTY_PRINT) }}
+
+ @endif + + {{-- Hash Chain Info --}} +
+
Hash Chain
+
+
+ Entry Hash: + {{ $this->selectedEntry->entry_hash }} +
+
+ Previous Hash: + {{ $this->selectedEntry->previous_hash ?? '(first entry)' }} +
+
+
+
+
+ @endif + + {{-- Integrity Verification Modal --}} + @if($showIntegrityModal && $integrityStatus) + +
+
+ Integrity Verification + +
+ +
+
+ +
+
+ {{ $integrityStatus['valid'] ? 'Audit Log Verified' : 'Integrity Issues Detected' }} +
+
+ {{ number_format($integrityStatus['verified']) }} of {{ number_format($integrityStatus['total']) }} entries verified +
+
+
+
+ + @if(!$integrityStatus['valid'] && !empty($integrityStatus['issues'])) +
+
Issues Found:
+
+ @foreach($integrityStatus['issues'] as $issue) +
+
+ Entry #{{ $issue['id'] }}: {{ $issue['type'] }} +
+
+ {{ $issue['message'] }} +
+
+ @endforeach +
+
+ @endif + +
+ + Close + +
+
+
+ @endif + + {{-- Export Modal --}} + @if($showExportModal) + +
+
+ Export Audit Log + +
+ +
+

+ Export the audit log with current filters applied. The export includes integrity verification metadata. +

+ +
+ Export Format + + JSON (with integrity metadata) + CSV (data only) + +
+ +
+
Current Filters:
+
    + @if($tool) +
  • Tool: {{ $tool }}
  • + @endif + @if($workspace) +
  • Workspace: {{ $this->workspaces->firstWhere('id', $workspace)?->name }}
  • + @endif + @if($dateFrom || $dateTo) +
  • Date: {{ $dateFrom ?: 'start' }} to {{ $dateTo ?: 'now' }}
  • + @endif + @if($sensitivity === 'sensitive') +
  • Sensitive only
  • + @endif + @if(!$tool && !$workspace && !$dateFrom && !$dateTo && !$sensitivity) +
  • All entries
  • + @endif +
+
+
+ +
+ + Cancel + + + Download + +
+
+
+ @endif +
diff --git a/src/php/src/Mcp/View/Blade/admin/mcp-playground.blade.php b/src/php/src/Mcp/View/Blade/admin/mcp-playground.blade.php new file mode 100644 index 0000000..d5f5191 --- /dev/null +++ b/src/php/src/Mcp/View/Blade/admin/mcp-playground.blade.php @@ -0,0 +1,502 @@ +
+ {{-- Header --}} +
+
+
+

MCP Playground

+

+ Interactive tool testing with documentation and examples +

+
+
+ +
+
+
+ + {{-- Error Display --}} + @if($error) +
+
+ + + +

{{ $error }}

+
+
+ @endif + +
+ {{-- Left Sidebar: Tool Browser --}} +
+
+ {{-- Server Selection --}} +
+ + +
+ + @if($selectedServer) + {{-- Search --}} +
+
+ + + + +
+
+ + {{-- Category Filter --}} + @if($categories->isNotEmpty()) +
+ +
+ + @foreach($categories as $category) + + @endforeach +
+
+ @endif + + {{-- Tools List --}} +
+ @forelse($toolsByCategory as $category => $categoryTools) +
+

{{ $category }}

+
+ @foreach($categoryTools as $tool) + + @endforeach + @empty +
+

No tools found

+
+ @endforelse +
+ @else +
+ + + +

Select a server to browse tools

+
+ @endif +
+
+ + {{-- Center: Tool Details & Input Form --}} +
+ {{-- API Key Authentication --}} +
+

+ + + + Authentication +

+
+
+ + +

Paste your API key to execute requests live

+
+
+ + @if($keyStatus === 'valid') + + + + + Valid + + @elseif($keyStatus === 'invalid') + + + + + Invalid key + + @elseif($keyStatus === 'expired') + + + + + Expired + + @endif +
+ @if($keyInfo) +
+
+
+ Name: + {{ $keyInfo['name'] }} +
+
+ Workspace: + {{ $keyInfo['workspace'] }} +
+
+
+ @endif +
+
+ + {{-- Tool Form --}} + @if($currentTool) +
+
+
+
+

{{ $currentTool['name'] }}

+

{{ $currentTool['description'] }}

+
+ + {{ $currentTool['category'] }} + +
+
+ + @php + $properties = $currentTool['inputSchema']['properties'] ?? []; + $required = $currentTool['inputSchema']['required'] ?? []; + @endphp + + @if(count($properties) > 0) +
+
+

Parameters

+ +
+ + @foreach($properties as $name => $schema) + @php + $isRequired = in_array($name, $required) || ($schema['required'] ?? false); + $type = is_array($schema['type'] ?? 'string') ? ($schema['type'][0] ?? 'string') : ($schema['type'] ?? 'string'); + $description = $schema['description'] ?? ''; + @endphp + +
+ + + @if(isset($schema['enum'])) + + @elseif($type === 'boolean') + + @elseif($type === 'integer' || $type === 'number') + + @elseif($type === 'array' || $type === 'object') + + @else + + @endif + + @if($description) +

{{ $description }}

+ @endif +
+ @endforeach +
+ @else +

This tool has no parameters.

+ @endif + +
+ +
+
+ @else +
+ + + +

Select a tool

+

+ Choose a tool from the sidebar to view its documentation and test it +

+
+ @endif +
+ + {{-- Right: Response Viewer --}} +
+
+
+

Response

+ @if($executionTime > 0) + {{ $executionTime }}ms + @endif +
+ +
+ @if($lastResponse) +
+ +
+ + @if(isset($lastResponse['error'])) +
+

{{ $lastResponse['error'] }}

+
+ @endif + +
+
{{ json_encode($lastResponse, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) }}
+
+ + @if(isset($lastResponse['executed']) && !$lastResponse['executed']) +
+

+ This is a preview. Add a valid API key to execute requests live. +

+
+ @endif + @else +
+ + + +

Response will appear here

+
+ @endif +
+ + {{-- API Reference --}} +
+

API Reference

+
+
+ Endpoint + /api/v1/mcp/tools/call +
+
+ Method + POST +
+
+ Auth + Bearer token +
+
+
+
+
+
+ + {{-- History Panel (Collapsible Bottom) --}} +
+
+
+

+ + + + Conversation History +

+ @if(count($conversationHistory) > 0) + + @endif +
+ + @if(count($conversationHistory) > 0) +
+ @foreach($conversationHistory as $index => $entry) +
+
+
+
+ @if($entry['success'] ?? true) + + Success + + @else + + Failed + + @endif + {{ $entry['tool'] }} + on + {{ $entry['server'] }} +
+
+ {{ \Carbon\Carbon::parse($entry['timestamp'])->diffForHumans() }} + @if(isset($entry['duration_ms'])) + {{ $entry['duration_ms'] }}ms + @endif +
+
+
+ + +
+
+
+ @endforeach +
+ @else +
+

No history yet. Execute a tool to see it here.

+
+ @endif +
+
+
diff --git a/src/php/src/Mcp/View/Blade/admin/playground.blade.php b/src/php/src/Mcp/View/Blade/admin/playground.blade.php new file mode 100644 index 0000000..1077ee5 --- /dev/null +++ b/src/php/src/Mcp/View/Blade/admin/playground.blade.php @@ -0,0 +1,281 @@ +
+
+

{{ __('mcp::mcp.playground.title') }}

+

+ {{ __('mcp::mcp.playground.description') }} +

+
+ + {{-- Error Display --}} + @if($error) +
+
+ +

{{ $error }}

+
+
+ @endif + +
+ +
+ +
+

{{ __('mcp::mcp.playground.auth.title') }}

+ +
+
+ +
+ +
+ + {{ __('mcp::mcp.playground.auth.validate') }} + + + @if($keyStatus === 'valid') + + + {{ __('mcp::mcp.playground.auth.status.valid') }} + + @elseif($keyStatus === 'invalid') + + + {{ __('mcp::mcp.playground.auth.status.invalid') }} + + @elseif($keyStatus === 'expired') + + + {{ __('mcp::mcp.playground.auth.status.expired') }} + + @elseif($keyStatus === 'empty') + + {{ __('mcp::mcp.playground.auth.status.empty') }} + + @endif +
+ + @if($keyInfo) +
+
+
+ {{ __('mcp::mcp.playground.auth.key_info.name') }}: + {{ $keyInfo['name'] }} +
+
+ {{ __('mcp::mcp.playground.auth.key_info.workspace') }}: + {{ $keyInfo['workspace'] }} +
+
+ {{ __('mcp::mcp.playground.auth.key_info.scopes') }}: + {{ implode(', ', $keyInfo['scopes'] ?? []) }} +
+
+ {{ __('mcp::mcp.playground.auth.key_info.last_used') }}: + {{ $keyInfo['last_used'] }} +
+
+
+ @elseif(!$isAuthenticated && !$apiKey) +
+

+ {{ __('mcp::mcp.playground.auth.sign_in_prompt') }} + {{ __('mcp::mcp.playground.auth.sign_in_description') }} +

+
+ @endif +
+
+ + +
+

{{ __('mcp::mcp.playground.tools.title') }}

+ +
+ + @foreach($servers as $server) + {{ $server['name'] }} + @endforeach + + + @if($selectedServer && count($tools) > 0) + + @foreach($tools as $tool) + {{ $tool['name'] }} + @endforeach + + @endif +
+
+ + + @if($toolSchema) +
+
+

{{ $toolSchema['name'] }}

+

{{ $toolSchema['description'] ?? $toolSchema['purpose'] ?? '' }}

+
+ + @php + $params = $toolSchema['inputSchema']['properties'] ?? $toolSchema['parameters'] ?? []; + $required = $toolSchema['inputSchema']['required'] ?? []; + @endphp + + @if(count($params) > 0) +
+

{{ __('mcp::mcp.playground.tools.arguments') }}

+ + @foreach($params as $name => $schema) +
+ @php + $paramRequired = in_array($name, $required) || ($schema['required'] ?? false); + $paramType = is_array($schema['type'] ?? 'string') ? ($schema['type'][0] ?? 'string') : ($schema['type'] ?? 'string'); + @endphp + + @if(isset($schema['enum'])) + + @foreach($schema['enum'] as $option) + {{ $option }} + @endforeach + + @elseif($paramType === 'boolean') + + true + false + + @elseif($paramType === 'integer' || $paramType === 'number') + + @else + + @endif +
+ @endforeach +
+ @else +

{{ __('mcp::mcp.playground.tools.no_arguments') }}

+ @endif + +
+ + + @if($keyStatus === 'valid') + {{ __('mcp::mcp.playground.tools.execute') }} + @else + {{ __('mcp::mcp.playground.tools.generate') }} + @endif + + {{ __('mcp::mcp.playground.tools.executing') }} + +
+
+ @endif +
+ + +
+
+

{{ __('mcp::mcp.playground.response.title') }}

+ + @if($response) +
+
+ +
+
{{ $response }}
+
+ @else +
+ +

{{ __('mcp::mcp.playground.response.empty') }}

+
+ @endif +
+ + +
+

{{ __('mcp::mcp.playground.reference.title') }}

+
+
+ {{ __('mcp::mcp.playground.reference.endpoint') }}: + {{ config('app.url') }}/api/v1/mcp/tools/call +
+
+ {{ __('mcp::mcp.playground.reference.method') }}: + POST +
+
+ {{ __('mcp::mcp.playground.reference.auth') }}: + @if($keyStatus === 'valid') + Bearer {{ Str::limit($apiKey, 20, '...') }} + @else + Bearer <your-api-key> + @endif +
+
+ {{ __('mcp::mcp.playground.reference.content_type') }}: + application/json +
+
+ + @if($isAuthenticated) +
+ + {{ __('mcp::mcp.playground.reference.manage_keys') }} + +
+ @endif +
+
+
+
+ +@script + +@endscript diff --git a/src/php/src/Mcp/View/Blade/admin/quota-usage.blade.php b/src/php/src/Mcp/View/Blade/admin/quota-usage.blade.php new file mode 100644 index 0000000..90f27fe --- /dev/null +++ b/src/php/src/Mcp/View/Blade/admin/quota-usage.blade.php @@ -0,0 +1,186 @@ +
+ {{-- Header --}} +
+
+

MCP Usage Quota

+

+ Current billing period resets {{ $this->resetDate }} +

+
+ +
+ + {{-- Current Usage Cards --}} +
+ {{-- Tool Calls Card --}} +
+
+
+
+ +
+
+

Tool Calls

+

Monthly usage

+
+
+
+ + @if($quotaLimits['tool_calls_unlimited'] ?? false) +
+ + {{ number_format($currentUsage['tool_calls_count'] ?? 0) }} + + Unlimited +
+ @else +
+
+ + {{ number_format($currentUsage['tool_calls_count'] ?? 0) }} + + + of {{ number_format($quotaLimits['tool_calls_limit'] ?? 0) }} + +
+
+
+
+

+ {{ number_format($remaining['tool_calls'] ?? 0) }} remaining +

+
+ @endif +
+ + {{-- Tokens Card --}} +
+
+
+
+ +
+
+

Tokens

+

Monthly consumption

+
+
+
+ + @if($quotaLimits['tokens_unlimited'] ?? false) +
+ + {{ number_format($currentUsage['total_tokens'] ?? 0) }} + + Unlimited +
+
+
+ Input: + + {{ number_format($currentUsage['input_tokens'] ?? 0) }} + +
+
+ Output: + + {{ number_format($currentUsage['output_tokens'] ?? 0) }} + +
+
+ @else +
+
+ + {{ number_format($currentUsage['total_tokens'] ?? 0) }} + + + of {{ number_format($quotaLimits['tokens_limit'] ?? 0) }} + +
+
+
+
+
+

+ {{ number_format($remaining['tokens'] ?? 0) }} remaining +

+
+ + In: {{ number_format($currentUsage['input_tokens'] ?? 0) }} + + + Out: {{ number_format($currentUsage['output_tokens'] ?? 0) }} + +
+
+
+ @endif +
+
+ + {{-- Usage History --}} + @if($usageHistory->count() > 0) +
+

Usage History

+
+ + + + + + + + + + + + @foreach($usageHistory as $record) + + + + + + + + @endforeach + +
MonthTool CallsInput TokensOutput TokensTotal Tokens
+ {{ $record->month_label }} + + {{ number_format($record->tool_calls_count) }} + + {{ number_format($record->input_tokens) }} + + {{ number_format($record->output_tokens) }} + + {{ number_format($record->total_tokens) }} +
+
+
+ @endif + + {{-- Upgrade Prompt (shown when near limit) --}} + @if(($this->toolCallsPercentage >= 80 || $this->tokensPercentage >= 80) && !($quotaLimits['tool_calls_unlimited'] ?? false)) +
+
+ +
+

Approaching usage limit

+

+ You're nearing your monthly MCP quota. Consider upgrading your plan for higher limits. +

+
+
+
+ @endif +
diff --git a/src/php/src/Mcp/View/Blade/admin/request-log.blade.php b/src/php/src/Mcp/View/Blade/admin/request-log.blade.php new file mode 100644 index 0000000..9086b55 --- /dev/null +++ b/src/php/src/Mcp/View/Blade/admin/request-log.blade.php @@ -0,0 +1,153 @@ +
+
+

{{ __('mcp::mcp.logs.title') }}

+

+ {{ __('mcp::mcp.logs.description') }} +

+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+ +
+
+ @forelse($requests as $request) + + @empty +
+ {{ __('mcp::mcp.logs.empty') }} +
+ @endforelse +
+ + @if($requests->hasPages()) +
+ {{ $requests->links() }} +
+ @endif +
+ + +
+ @if($selectedRequest) +
+

{{ __('mcp::mcp.logs.detail.title') }}

+ +
+ +
+ +
+ + + {{ $selectedRequest->response_status }} + {{ $selectedRequest->isSuccessful() ? __('mcp::mcp.logs.status_ok') : __('mcp::mcp.logs.status_error') }} + +
+ + +
+ +
{{ json_encode($selectedRequest->request_body, JSON_PRETTY_PRINT) }}
+
+ + +
+ +
{{ json_encode($selectedRequest->response_body, JSON_PRETTY_PRINT) }}
+
+ + @if($selectedRequest->error_message) +
+ +
{{ $selectedRequest->error_message }}
+
+ @endif + + +
+ +
{{ $selectedRequest->toCurl() }}
+
+ + +
+
{{ __('mcp::mcp.logs.detail.metadata.request_id') }}: {{ $selectedRequest->request_id }}
+
{{ __('mcp::mcp.logs.detail.metadata.duration') }}: {{ $selectedRequest->duration_for_humans }}
+
{{ __('mcp::mcp.logs.detail.metadata.ip') }}: {{ $selectedRequest->ip_address ?? __('mcp::mcp.common.na') }}
+
{{ __('mcp::mcp.logs.detail.metadata.time') }}: {{ $selectedRequest->created_at->format('Y-m-d H:i:s') }}
+
+
+ @else +
+ +

{{ __('mcp::mcp.logs.empty_detail') }}

+
+ @endif +
+
+
diff --git a/src/php/src/Mcp/View/Blade/admin/tool-version-manager.blade.php b/src/php/src/Mcp/View/Blade/admin/tool-version-manager.blade.php new file mode 100644 index 0000000..c0323e0 --- /dev/null +++ b/src/php/src/Mcp/View/Blade/admin/tool-version-manager.blade.php @@ -0,0 +1,537 @@ +{{-- +MCP Tool Version Manager. + +Admin interface for managing tool version lifecycles, +viewing schema changes between versions, and setting deprecation schedules. +--}} + +
+ {{-- Header --}} +
+
+ {{ __('Tool Versions') }} + Manage MCP tool version lifecycles and backwards compatibility +
+
+ + Register Version + +
+
+ + {{-- Stats Cards --}} +
+
+
Total Versions
+
+ {{ number_format($this->stats['total_versions']) }} +
+
+
+
Unique Tools
+
+ {{ number_format($this->stats['total_tools']) }} +
+
+
+
Servers
+
+ {{ number_format($this->stats['servers']) }} +
+
+
+
Deprecated
+
+ {{ number_format($this->stats['deprecated_count']) }} +
+
+
+
Sunset
+
+ {{ number_format($this->stats['sunset_count']) }} +
+
+
+ + {{-- Filters --}} +
+
+ +
+ + All servers + @foreach ($this->servers as $serverId) + {{ $serverId }} + @endforeach + + + All statuses + Latest + Active (non-latest) + Deprecated + Sunset + + @if($search || $server || $status) + Clear + @endif +
+ + {{-- Versions Table --}} + + + Tool + Server + Version + Status + Deprecated + Sunset + Created + + + + + @forelse ($this->versions as $version) + + +
{{ $version->tool_name }}
+ @if($version->description) +
{{ $version->description }}
+ @endif +
+ + {{ $version->server_id }} + + + + {{ $version->version }} + + + + + {{ ucfirst($version->status) }} + + + + @if($version->deprecated_at) + {{ $version->deprecated_at->format('M j, Y') }} + @else + - + @endif + + + @if($version->sunset_at) + + {{ $version->sunset_at->format('M j, Y') }} + + @else + - + @endif + + + {{ $version->created_at->format('M j, Y') }} + + + + + + + View Details + + @if(!$version->is_latest && !$version->is_sunset) + + Mark as Latest + + @endif + @if(!$version->is_deprecated && !$version->is_sunset) + + Deprecate + + @endif + + + +
+ @empty + + +
+
+ +
+ No tool versions found + Register tool versions to enable backwards compatibility. +
+
+
+ @endforelse +
+
+ + @if($this->versions->hasPages()) +
+ {{ $this->versions->links() }} +
+ @endif + + {{-- Version Detail Modal --}} + @if($showVersionDetail && $this->selectedVersion) + +
+
+
+ {{ $this->selectedVersion->tool_name }} +
+ + {{ $this->selectedVersion->version }} + + + {{ ucfirst($this->selectedVersion->status) }} + +
+
+ +
+ + {{-- Metadata --}} +
+
+
Server
+
{{ $this->selectedVersion->server_id }}
+
+
+
Created
+
{{ $this->selectedVersion->created_at->format('Y-m-d H:i:s') }}
+
+ @if($this->selectedVersion->deprecated_at) +
+
Deprecated
+
+ {{ $this->selectedVersion->deprecated_at->format('Y-m-d') }} +
+
+ @endif + @if($this->selectedVersion->sunset_at) +
+
Sunset
+
+ {{ $this->selectedVersion->sunset_at->format('Y-m-d') }} +
+
+ @endif +
+ + @if($this->selectedVersion->description) +
+
Description
+
{{ $this->selectedVersion->description }}
+
+ @endif + + @if($this->selectedVersion->changelog) +
+
Changelog
+
+ {!! nl2br(e($this->selectedVersion->changelog)) !!} +
+
+ @endif + + @if($this->selectedVersion->migration_notes) +
+
+ + Migration Notes +
+
+ {!! nl2br(e($this->selectedVersion->migration_notes)) !!} +
+
+ @endif + + {{-- Input Schema --}} + @if($this->selectedVersion->input_schema) +
+
Input Schema
+
{{ $this->formatSchema($this->selectedVersion->input_schema) }}
+
+ @endif + + {{-- Output Schema --}} + @if($this->selectedVersion->output_schema) +
+
Output Schema
+
{{ $this->formatSchema($this->selectedVersion->output_schema) }}
+
+ @endif + + {{-- Version History --}} + @if($this->versionHistory->count() > 1) +
+
Version History
+
+ @foreach($this->versionHistory as $index => $historyVersion) +
+
+ + {{ $historyVersion->version }} + + + {{ ucfirst($historyVersion->status) }} + + + {{ $historyVersion->created_at->format('M j, Y') }} + +
+ @if($historyVersion->id !== $this->selectedVersion->id && $index < $this->versionHistory->count() - 1) + @php $nextVersion = $this->versionHistory[$index + 1] @endphp + + Compare + + @endif +
+ @endforeach +
+
+ @endif +
+
+ @endif + + {{-- Compare Schemas Modal --}} + @if($showCompareModal && $this->schemaComparison) + +
+
+ Schema Comparison + +
+ +
+
+ + {{ $this->schemaComparison['from']->version }} + +
+ +
+ + {{ $this->schemaComparison['to']->version }} + +
+
+ + @php $changes = $this->schemaComparison['changes'] @endphp + + @if(empty($changes['added']) && empty($changes['removed']) && empty($changes['changed'])) +
+
+ + No schema changes between versions +
+
+ @else +
+ @if(!empty($changes['added'])) +
+
+ Added Properties ({{ count($changes['added']) }}) +
+
    + @foreach($changes['added'] as $prop) +
  • {{ $prop }}
  • + @endforeach +
+
+ @endif + + @if(!empty($changes['removed'])) +
+
+ Removed Properties ({{ count($changes['removed']) }}) +
+
    + @foreach($changes['removed'] as $prop) +
  • {{ $prop }}
  • + @endforeach +
+
+ @endif + + @if(!empty($changes['changed'])) +
+
+ Changed Properties ({{ count($changes['changed']) }}) +
+
+ @foreach($changes['changed'] as $prop => $change) +
+ {{ $prop }} +
+
+
Before:
+
{{ json_encode($change['from'], JSON_PRETTY_PRINT) }}
+
+
+
After:
+
{{ json_encode($change['to'], JSON_PRETTY_PRINT) }}
+
+
+
+ @endforeach +
+
+ @endif +
+ @endif + +
+ Close +
+
+
+ @endif + + {{-- Deprecate Modal --}} + @if($showDeprecateModal) + @php $deprecateVersion = \Core\Mcp\Models\McpToolVersion::find($deprecateVersionId) @endphp + @if($deprecateVersion) + +
+
+ Deprecate Version + +
+ +
+
+ + {{ $deprecateVersion->tool_name }} v{{ $deprecateVersion->version }} +
+

+ Deprecated versions will show warnings to agents but remain usable until sunset. +

+
+ +
+ Sunset Date (optional) + + + After this date, the version will be blocked and return errors. + +
+ +
+ Cancel + + Deprecate Version + +
+
+
+ @endif + @endif + + {{-- Register Version Modal --}} + @if($showRegisterModal) + +
+
+ Register Tool Version + +
+ +
+
+
+ Server ID + + @error('registerServer') {{ $message }} @enderror +
+
+ Tool Name + + @error('registerTool') {{ $message }} @enderror +
+
+ +
+
+ Version (semver) + + @error('registerVersion') {{ $message }} @enderror +
+
+ +
+
+ +
+ Description + + @error('registerDescription') {{ $message }} @enderror +
+ +
+ Changelog + + @error('registerChangelog') {{ $message }} @enderror +
+ +
+ Migration Notes + + @error('registerMigrationNotes') {{ $message }} @enderror +
+ +
+ Input Schema (JSON) + + @error('registerInputSchema') {{ $message }} @enderror +
+ +
+ Cancel + Register Version +
+
+
+
+ @endif +
diff --git a/src/php/src/Mcp/View/Modal/Admin/ApiKeyManager.php b/src/php/src/Mcp/View/Modal/Admin/ApiKeyManager.php new file mode 100644 index 0000000..749449b --- /dev/null +++ b/src/php/src/Mcp/View/Modal/Admin/ApiKeyManager.php @@ -0,0 +1,112 @@ +workspace = $workspace; + } + + public function openCreateModal(): void + { + $this->showCreateModal = true; + $this->newKeyName = ''; + $this->newKeyScopes = ['read', 'write']; + $this->newKeyExpiry = 'never'; + } + + public function closeCreateModal(): void + { + $this->showCreateModal = false; + } + + public function createKey(): void + { + $this->validate([ + 'newKeyName' => 'required|string|max:100', + ]); + + $expiresAt = match ($this->newKeyExpiry) { + '30days' => now()->addDays(30), + '90days' => now()->addDays(90), + '1year' => now()->addYear(), + default => null, + }; + + $result = ApiKey::generate( + workspaceId: $this->workspace->id, + userId: auth()->id(), + name: $this->newKeyName, + scopes: $this->newKeyScopes, + expiresAt: $expiresAt, + ); + + $this->newPlainKey = $result['plain_key']; + $this->showCreateModal = false; + $this->showNewKeyModal = true; + + session()->flash('message', 'API key created successfully.'); + } + + public function closeNewKeyModal(): void + { + $this->newPlainKey = null; + $this->showNewKeyModal = false; + } + + public function revokeKey(int $keyId): void + { + $key = $this->workspace->apiKeys()->findOrFail($keyId); + $key->revoke(); + + session()->flash('message', 'API key revoked.'); + } + + public function toggleScope(string $scope): void + { + if (in_array($scope, $this->newKeyScopes)) { + $this->newKeyScopes = array_values(array_diff($this->newKeyScopes, [$scope])); + } else { + $this->newKeyScopes[] = $scope; + } + } + + public function render() + { + return view('mcp::admin.api-key-manager', [ + 'keys' => $this->workspace->apiKeys()->orderByDesc('created_at')->get(), + ]); + } +} diff --git a/src/php/src/Mcp/View/Modal/Admin/AuditLogViewer.php b/src/php/src/Mcp/View/Modal/Admin/AuditLogViewer.php new file mode 100644 index 0000000..7002ec5 --- /dev/null +++ b/src/php/src/Mcp/View/Modal/Admin/AuditLogViewer.php @@ -0,0 +1,249 @@ +checkHadesAccess(); + } + + #[Computed] + public function entries(): LengthAwarePaginator + { + $query = McpAuditLog::query() + ->with('workspace') + ->orderByDesc('id'); + + if ($this->search) { + $query->where(function ($q) { + $q->where('tool_name', 'like', "%{$this->search}%") + ->orWhere('server_id', 'like', "%{$this->search}%") + ->orWhere('session_id', 'like', "%{$this->search}%") + ->orWhere('error_message', 'like', "%{$this->search}%"); + }); + } + + if ($this->tool) { + $query->where('tool_name', $this->tool); + } + + if ($this->workspace) { + $query->where('workspace_id', $this->workspace); + } + + if ($this->status === 'success') { + $query->where('success', true); + } elseif ($this->status === 'failed') { + $query->where('success', false); + } + + if ($this->sensitivity === 'sensitive') { + $query->where('is_sensitive', true); + } elseif ($this->sensitivity === 'normal') { + $query->where('is_sensitive', false); + } + + if ($this->dateFrom) { + $query->where('created_at', '>=', Carbon::parse($this->dateFrom)->startOfDay()); + } + + if ($this->dateTo) { + $query->where('created_at', '<=', Carbon::parse($this->dateTo)->endOfDay()); + } + + return $query->paginate($this->perPage); + } + + #[Computed] + public function workspaces(): Collection + { + return Workspace::orderBy('name')->get(['id', 'name']); + } + + #[Computed] + public function tools(): Collection + { + return McpAuditLog::query() + ->select('tool_name') + ->distinct() + ->orderBy('tool_name') + ->pluck('tool_name'); + } + + #[Computed] + public function selectedEntry(): ?McpAuditLog + { + if (! $this->selectedEntryId) { + return null; + } + + return McpAuditLog::with('workspace')->find($this->selectedEntryId); + } + + #[Computed] + public function stats(): array + { + return app(AuditLogService::class)->getStats( + workspaceId: $this->workspace ? (int) $this->workspace : null, + days: 30 + ); + } + + public function viewEntry(int $id): void + { + $this->selectedEntryId = $id; + } + + public function closeEntryDetail(): void + { + $this->selectedEntryId = null; + } + + public function verifyIntegrity(): void + { + $this->integrityStatus = app(AuditLogService::class)->verifyChain(); + $this->showIntegrityModal = true; + } + + public function closeIntegrityModal(): void + { + $this->showIntegrityModal = false; + $this->integrityStatus = null; + } + + public function openExportModal(): void + { + $this->showExportModal = true; + } + + public function closeExportModal(): void + { + $this->showExportModal = false; + } + + public function export(): StreamedResponse + { + $auditLogService = app(AuditLogService::class); + + $workspaceId = $this->workspace ? (int) $this->workspace : null; + $from = $this->dateFrom ? Carbon::parse($this->dateFrom) : null; + $to = $this->dateTo ? Carbon::parse($this->dateTo) : null; + $tool = $this->tool ?: null; + $sensitiveOnly = $this->sensitivity === 'sensitive'; + + if ($this->exportFormat === 'csv') { + $content = $auditLogService->exportToCsv($workspaceId, $from, $to, $tool, $sensitiveOnly); + $filename = 'mcp-audit-log-'.now()->format('Y-m-d-His').'.csv'; + $contentType = 'text/csv'; + } else { + $content = $auditLogService->exportToJson($workspaceId, $from, $to, $tool, $sensitiveOnly); + $filename = 'mcp-audit-log-'.now()->format('Y-m-d-His').'.json'; + $contentType = 'application/json'; + } + + return response()->streamDownload(function () use ($content) { + echo $content; + }, $filename, [ + 'Content-Type' => $contentType, + ]); + } + + public function clearFilters(): void + { + $this->search = ''; + $this->tool = ''; + $this->workspace = ''; + $this->status = ''; + $this->sensitivity = ''; + $this->dateFrom = ''; + $this->dateTo = ''; + $this->resetPage(); + } + + public function getStatusBadgeClass(bool $success): string + { + return $success + ? 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300' + : 'bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300'; + } + + public function getSensitivityBadgeClass(bool $isSensitive): string + { + return $isSensitive + ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300' + : 'bg-zinc-100 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300'; + } + + private function checkHadesAccess(): void + { + if (! auth()->user()?->isHades()) { + abort(403, 'Hades access required'); + } + } + + public function render() + { + return view('mcp::admin.audit-log-viewer'); + } +} diff --git a/src/php/src/Mcp/View/Modal/Admin/McpPlayground.php b/src/php/src/Mcp/View/Modal/Admin/McpPlayground.php new file mode 100644 index 0000000..1941e88 --- /dev/null +++ b/src/php/src/Mcp/View/Modal/Admin/McpPlayground.php @@ -0,0 +1,539 @@ +loadConversationHistory(); + + // Auto-select first server if available + $servers = $this->getServers(); + if ($servers->isNotEmpty()) { + $this->selectedServer = $servers->first()['id']; + } + } + + /** + * Handle server selection change. + */ + public function updatedSelectedServer(): void + { + $this->selectedTool = null; + $this->toolInput = []; + $this->lastResponse = null; + $this->error = null; + $this->searchQuery = ''; + $this->selectedCategory = ''; + } + + /** + * Handle tool selection change. + */ + public function updatedSelectedTool(): void + { + $this->toolInput = []; + $this->lastResponse = null; + $this->error = null; + + if ($this->selectedTool) { + $this->loadExampleInputs(); + } + } + + /** + * Handle API key change. + */ + public function updatedApiKey(): void + { + $this->keyStatus = null; + $this->keyInfo = null; + } + + /** + * Validate the API key. + */ + public function validateKey(): void + { + $this->keyStatus = null; + $this->keyInfo = null; + + if (empty($this->apiKey)) { + $this->keyStatus = 'empty'; + + return; + } + + $key = ApiKey::findByPlainKey($this->apiKey); + + if (! $key) { + $this->keyStatus = 'invalid'; + + return; + } + + if ($key->isExpired()) { + $this->keyStatus = 'expired'; + + return; + } + + $this->keyStatus = 'valid'; + $this->keyInfo = [ + 'name' => $key->name, + 'scopes' => $key->scopes ?? [], + 'workspace' => $key->workspace?->name ?? 'Unknown', + 'last_used' => $key->last_used_at?->diffForHumans() ?? 'Never', + ]; + } + + /** + * Select a tool by name. + */ + public function selectTool(string $toolName): void + { + $this->selectedTool = $toolName; + $this->updatedSelectedTool(); + } + + /** + * Load example inputs for the selected tool. + */ + public function loadExampleInputs(): void + { + if (! $this->selectedTool) { + return; + } + + $tool = $this->getRegistry()->getTool($this->selectedServer, $this->selectedTool); + + if (! $tool) { + return; + } + + // Load example inputs + $examples = $tool['examples'] ?? []; + + // Also populate from schema defaults if no examples + if (empty($examples) && isset($tool['inputSchema']['properties'])) { + foreach ($tool['inputSchema']['properties'] as $name => $schema) { + if (isset($schema['default'])) { + $examples[$name] = $schema['default']; + } + } + } + + $this->toolInput = $examples; + } + + /** + * Execute the selected tool. + */ + public function execute(): void + { + if (! $this->selectedServer || ! $this->selectedTool) { + $this->error = 'Please select a server and tool.'; + + return; + } + + // Rate limiting: 10 executions per minute + $rateLimitKey = 'mcp-playground:'.$this->getRateLimitKey(); + if (RateLimiter::tooManyAttempts($rateLimitKey, 10)) { + $this->error = 'Too many requests. Please wait before trying again.'; + + return; + } + RateLimiter::hit($rateLimitKey, 60); + + $this->isExecuting = true; + $this->lastResponse = null; + $this->error = null; + + try { + $startTime = microtime(true); + + // Filter empty values from input + $args = array_filter($this->toolInput, fn ($v) => $v !== '' && $v !== null); + + // Type conversion for arguments + $args = $this->convertArgumentTypes($args); + + // Execute the tool + if ($this->keyStatus === 'valid') { + $result = $this->executeViaApi($args); + } else { + $result = $this->generateRequestPreview($args); + } + + $this->executionTime = (int) round((microtime(true) - $startTime) * 1000); + $this->lastResponse = $result; + + // Add to conversation history + $this->addToHistory([ + 'server' => $this->selectedServer, + 'tool' => $this->selectedTool, + 'input' => $args, + 'output' => $result, + 'success' => ! isset($result['error']), + 'duration_ms' => $this->executionTime, + 'timestamp' => now()->toIso8601String(), + ]); + + } catch (\Throwable $e) { + $this->error = $e->getMessage(); + $this->lastResponse = ['error' => $e->getMessage()]; + } finally { + $this->isExecuting = false; + } + } + + /** + * Re-run a historical execution. + */ + public function rerunFromHistory(int $index): void + { + if (! isset($this->conversationHistory[$index])) { + return; + } + + $entry = $this->conversationHistory[$index]; + + $this->selectedServer = $entry['server']; + $this->selectedTool = $entry['tool']; + $this->toolInput = $entry['input'] ?? []; + + $this->execute(); + } + + /** + * View a historical execution result. + */ + public function viewFromHistory(int $index): void + { + if (! isset($this->conversationHistory[$index])) { + return; + } + + $entry = $this->conversationHistory[$index]; + + $this->selectedServer = $entry['server']; + $this->selectedTool = $entry['tool']; + $this->toolInput = $entry['input'] ?? []; + $this->lastResponse = $entry['output'] ?? null; + $this->executionTime = $entry['duration_ms'] ?? 0; + } + + /** + * Clear conversation history. + */ + public function clearHistory(): void + { + $this->conversationHistory = []; + Session::forget(self::HISTORY_SESSION_KEY); + } + + /** + * Get available servers. + */ + #[Computed] + public function getServers(): \Illuminate\Support\Collection + { + return $this->getRegistry()->getServers(); + } + + /** + * Get tools for the selected server. + */ + #[Computed] + public function getTools(): \Illuminate\Support\Collection + { + if (empty($this->selectedServer)) { + return collect(); + } + + $tools = $this->getRegistry()->getToolsForServer($this->selectedServer); + + // Apply search filter + if (! empty($this->searchQuery)) { + $query = strtolower($this->searchQuery); + $tools = $tools->filter(function ($tool) use ($query) { + return str_contains(strtolower($tool['name']), $query) + || str_contains(strtolower($tool['description']), $query); + }); + } + + // Apply category filter + if (! empty($this->selectedCategory)) { + $tools = $tools->filter(fn ($tool) => $tool['category'] === $this->selectedCategory); + } + + return $tools->values(); + } + + /** + * Get tools grouped by category. + */ + #[Computed] + public function getToolsByCategory(): \Illuminate\Support\Collection + { + return $this->getTools()->groupBy('category')->sortKeys(); + } + + /** + * Get available categories. + */ + #[Computed] + public function getCategories(): \Illuminate\Support\Collection + { + if (empty($this->selectedServer)) { + return collect(); + } + + return $this->getRegistry() + ->getToolsForServer($this->selectedServer) + ->pluck('category') + ->unique() + ->sort() + ->values(); + } + + /** + * Get the current tool schema. + */ + #[Computed] + public function getCurrentTool(): ?array + { + if (! $this->selectedTool) { + return null; + } + + return $this->getRegistry()->getTool($this->selectedServer, $this->selectedTool); + } + + /** + * Check if user is authenticated. + */ + public function isAuthenticated(): bool + { + return auth()->check(); + } + + public function render() + { + return view('mcp::admin.mcp-playground', [ + 'servers' => $this->getServers(), + 'tools' => $this->getTools(), + 'toolsByCategory' => $this->getToolsByCategory(), + 'categories' => $this->getCategories(), + 'currentTool' => $this->getCurrentTool(), + 'isAuthenticated' => $this->isAuthenticated(), + ]); + } + + /** + * Get the tool registry service. + */ + protected function getRegistry(): ToolRegistry + { + return app(ToolRegistry::class); + } + + /** + * Get rate limit key based on user or IP. + */ + protected function getRateLimitKey(): string + { + if (auth()->check()) { + return 'user:'.auth()->id(); + } + + return 'ip:'.request()->ip(); + } + + /** + * Convert argument types based on their values. + */ + protected function convertArgumentTypes(array $args): array + { + foreach ($args as $key => $value) { + if (is_numeric($value)) { + $args[$key] = str_contains((string) $value, '.') ? (float) $value : (int) $value; + } + if ($value === 'true') { + $args[$key] = true; + } + if ($value === 'false') { + $args[$key] = false; + } + } + + return $args; + } + + /** + * Execute tool via HTTP API. + */ + protected function executeViaApi(array $args): array + { + $payload = [ + 'server' => $this->selectedServer, + 'tool' => $this->selectedTool, + 'arguments' => $args, + ]; + + $response = Http::withToken($this->apiKey) + ->timeout(30) + ->post(config('app.url').'/api/v1/mcp/tools/call', $payload); + + return [ + 'status' => $response->status(), + 'response' => $response->json(), + 'executed' => true, + ]; + } + + /** + * Generate a request preview without executing. + */ + protected function generateRequestPreview(array $args): array + { + $payload = [ + 'server' => $this->selectedServer, + 'tool' => $this->selectedTool, + 'arguments' => $args, + ]; + + return [ + 'request' => $payload, + 'note' => 'Add a valid API key to execute this request live.', + 'curl' => sprintf( + "curl -X POST %s/api/v1/mcp/tools/call \\\n -H \"Authorization: Bearer YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '%s'", + config('app.url'), + json_encode($payload, JSON_UNESCAPED_SLASHES) + ), + 'executed' => false, + ]; + } + + /** + * Load conversation history from session. + */ + protected function loadConversationHistory(): void + { + $this->conversationHistory = Session::get(self::HISTORY_SESSION_KEY, []); + } + + /** + * Add an entry to conversation history. + */ + protected function addToHistory(array $entry): void + { + // Prepend new entry + array_unshift($this->conversationHistory, $entry); + + // Keep only last N entries + $this->conversationHistory = array_slice($this->conversationHistory, 0, self::MAX_HISTORY_ENTRIES); + + // Save to session + Session::put(self::HISTORY_SESSION_KEY, $this->conversationHistory); + } +} diff --git a/src/php/src/Mcp/View/Modal/Admin/Playground.php b/src/php/src/Mcp/View/Modal/Admin/Playground.php new file mode 100644 index 0000000..12b3d66 --- /dev/null +++ b/src/php/src/Mcp/View/Modal/Admin/Playground.php @@ -0,0 +1,263 @@ +loadServers(); + } + + public function loadServers(): void + { + try { + $registry = $this->loadRegistry(); + $this->servers = collect($registry['servers'] ?? []) + ->map(fn ($ref) => $this->loadServerSummary($ref['id'])) + ->filter() + ->values() + ->toArray(); + } catch (\Throwable $e) { + $this->error = 'Failed to load servers'; + $this->servers = []; + } + } + + public function updatedSelectedServer(): void + { + $this->error = null; + $this->selectedTool = ''; + $this->toolSchema = null; + $this->arguments = []; + $this->response = ''; + + if (! $this->selectedServer) { + $this->tools = []; + + return; + } + + try { + $server = $this->loadServerFull($this->selectedServer); + $this->tools = $server['tools'] ?? []; + } catch (\Throwable $e) { + $this->error = 'Failed to load server tools'; + $this->tools = []; + } + } + + public function updatedSelectedTool(): void + { + $this->error = null; + $this->arguments = []; + $this->response = ''; + + if (! $this->selectedTool) { + $this->toolSchema = null; + + return; + } + + try { + $this->toolSchema = collect($this->tools)->firstWhere('name', $this->selectedTool); + + // Pre-fill arguments with defaults + $params = $this->toolSchema['inputSchema']['properties'] ?? []; + foreach ($params as $name => $schema) { + $this->arguments[$name] = $schema['default'] ?? ''; + } + } catch (\Throwable $e) { + $this->error = 'Failed to load tool schema'; + $this->toolSchema = null; + } + } + + public function updatedApiKey(): void + { + // Clear key status when key changes + $this->keyStatus = null; + $this->keyInfo = null; + } + + public function validateKey(): void + { + $this->keyStatus = null; + $this->keyInfo = null; + + if (empty($this->apiKey)) { + $this->keyStatus = 'empty'; + + return; + } + + $key = ApiKey::findByPlainKey($this->apiKey); + + if (! $key) { + $this->keyStatus = 'invalid'; + + return; + } + + if ($key->isExpired()) { + $this->keyStatus = 'expired'; + + return; + } + + $this->keyStatus = 'valid'; + $this->keyInfo = [ + 'name' => $key->name, + 'scopes' => $key->scopes, + 'server_scopes' => $key->getAllowedServers(), + 'workspace' => $key->workspace?->name ?? 'Unknown', + 'last_used' => $key->last_used_at?->diffForHumans() ?? 'Never', + ]; + } + + public function isAuthenticated(): bool + { + return auth()->check(); + } + + public function execute(): void + { + if (! $this->selectedServer || ! $this->selectedTool) { + return; + } + + $this->loading = true; + $this->response = ''; + $this->error = null; + + try { + // Filter out empty arguments + $args = array_filter($this->arguments, fn ($v) => $v !== '' && $v !== null); + + // Convert numeric strings to numbers where appropriate + foreach ($args as $key => $value) { + if (is_numeric($value)) { + $args[$key] = str_contains($value, '.') ? (float) $value : (int) $value; + } + if ($value === 'true') { + $args[$key] = true; + } + if ($value === 'false') { + $args[$key] = false; + } + } + + $payload = [ + 'server' => $this->selectedServer, + 'tool' => $this->selectedTool, + 'arguments' => $args, + ]; + + // If we have an API key, make a real request + if (! empty($this->apiKey) && $this->keyStatus === 'valid') { + $response = Http::withToken($this->apiKey) + ->timeout(30) + ->post(config('app.url').'/api/v1/mcp/tools/call', $payload); + + $this->response = json_encode([ + 'status' => $response->status(), + 'response' => $response->json(), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + return; + } + + // Otherwise, just show request format + $this->response = json_encode([ + 'request' => $payload, + 'note' => 'Add an API key above to execute this request live.', + 'curl' => sprintf( + "curl -X POST %s/api/v1/mcp/tools/call \\\n -H \"Authorization: Bearer YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '%s'", + config('app.url'), + json_encode($payload, JSON_UNESCAPED_SLASHES) + ), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } catch (\Throwable $e) { + $this->response = json_encode([ + 'error' => $e->getMessage(), + ], JSON_PRETTY_PRINT); + } finally { + $this->loading = false; + } + } + + public function render() + { + $isAuthenticated = $this->isAuthenticated(); + $workspace = $isAuthenticated ? auth()->user()?->defaultHostWorkspace() : null; + + return view('mcp::admin.playground', [ + 'isAuthenticated' => $isAuthenticated, + 'workspace' => $workspace, + ]); + } + + protected function loadRegistry(): array + { + $path = resource_path('mcp/registry.yaml'); + + return file_exists($path) ? Yaml::parseFile($path) : ['servers' => []]; + } + + protected function loadServerFull(string $id): ?array + { + $path = resource_path("mcp/servers/{$id}.yaml"); + + return file_exists($path) ? Yaml::parseFile($path) : null; + } + + protected function loadServerSummary(string $id): ?array + { + $server = $this->loadServerFull($id); + if (! $server) { + return null; + } + + return [ + 'id' => $server['id'], + 'name' => $server['name'], + 'tagline' => $server['tagline'] ?? '', + ]; + } +} diff --git a/src/php/src/Mcp/View/Modal/Admin/QuotaUsage.php b/src/php/src/Mcp/View/Modal/Admin/QuotaUsage.php new file mode 100644 index 0000000..1c40f52 --- /dev/null +++ b/src/php/src/Mcp/View/Modal/Admin/QuotaUsage.php @@ -0,0 +1,93 @@ +workspaceId = $workspaceId ?? auth()->user()?->defaultHostWorkspace()?->id; + $this->usageHistory = collect(); + $this->loadQuotaData(); + } + + public function loadQuotaData(): void + { + if (! $this->workspaceId) { + return; + } + + $quotaService = app(McpQuotaService::class); + $workspace = Workspace::find($this->workspaceId); + + if (! $workspace) { + return; + } + + $this->currentUsage = $quotaService->getCurrentUsage($workspace); + $this->quotaLimits = $quotaService->getQuotaLimits($workspace); + $this->remaining = $quotaService->getRemainingQuota($workspace); + $this->usageHistory = $quotaService->getUsageHistory($workspace, 6); + } + + public function getToolCallsPercentageProperty(): float + { + if ($this->quotaLimits['tool_calls_unlimited'] ?? false) { + return 0; + } + + $limit = $this->quotaLimits['tool_calls_limit'] ?? 0; + if ($limit === 0) { + return 0; + } + + return min(100, round(($this->currentUsage['tool_calls_count'] ?? 0) / $limit * 100, 1)); + } + + public function getTokensPercentageProperty(): float + { + if ($this->quotaLimits['tokens_unlimited'] ?? false) { + return 0; + } + + $limit = $this->quotaLimits['tokens_limit'] ?? 0; + if ($limit === 0) { + return 0; + } + + return min(100, round(($this->currentUsage['total_tokens'] ?? 0) / $limit * 100, 1)); + } + + public function getResetDateProperty(): string + { + return now()->endOfMonth()->format('j F Y'); + } + + public function render() + { + return view('mcp::admin.quota-usage'); + } +} diff --git a/src/php/src/Mcp/View/Modal/Admin/RequestLog.php b/src/php/src/Mcp/View/Modal/Admin/RequestLog.php new file mode 100644 index 0000000..147266c --- /dev/null +++ b/src/php/src/Mcp/View/Modal/Admin/RequestLog.php @@ -0,0 +1,86 @@ +resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function selectRequest(int $id): void + { + $this->selectedRequestId = $id; + $this->selectedRequest = McpApiRequest::find($id); + } + + public function closeDetail(): void + { + $this->selectedRequestId = null; + $this->selectedRequest = null; + } + + public function render() + { + $workspace = auth()->user()?->defaultHostWorkspace(); + + $query = McpApiRequest::query() + ->orderByDesc('created_at'); + + if ($workspace) { + $query->forWorkspace($workspace->id); + } + + if ($this->serverFilter) { + $query->forServer($this->serverFilter); + } + + if ($this->statusFilter === 'success') { + $query->successful(); + } elseif ($this->statusFilter === 'failed') { + $query->failed(); + } + + $requests = $query->paginate(20); + + // Get unique servers for filter dropdown + $servers = McpApiRequest::query() + ->when($workspace, fn ($q) => $q->forWorkspace($workspace->id)) + ->distinct() + ->pluck('server_id') + ->filter() + ->values(); + + return view('mcp::admin.request-log', [ + 'requests' => $requests, + 'servers' => $servers, + ]); + } +} diff --git a/src/php/src/Mcp/View/Modal/Admin/ToolAnalyticsDashboard.php b/src/php/src/Mcp/View/Modal/Admin/ToolAnalyticsDashboard.php new file mode 100644 index 0000000..5e8c3ea --- /dev/null +++ b/src/php/src/Mcp/View/Modal/Admin/ToolAnalyticsDashboard.php @@ -0,0 +1,249 @@ +analyticsService = $analyticsService; + } + + /** + * Set the number of days to display. + */ + public function setDays(int $days): void + { + $this->days = max(1, min(90, $days)); + } + + /** + * Set the active tab. + */ + public function setTab(string $tab): void + { + $this->tab = $tab; + } + + /** + * Set the sort column and direction. + */ + public function sort(string $column): void + { + if ($this->sortColumn === $column) { + $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc'; + } else { + $this->sortColumn = $column; + $this->sortDirection = 'desc'; + } + } + + /** + * Set the workspace filter. + */ + public function setWorkspace(?string $workspaceId): void + { + $this->workspaceId = $workspaceId; + } + + /** + * Get the date range. + */ + protected function getDateRange(): array + { + return [ + 'from' => now()->subDays($this->days - 1)->startOfDay(), + 'to' => now()->endOfDay(), + ]; + } + + /** + * Get overview statistics. + */ + public function getOverviewProperty(): array + { + $range = $this->getDateRange(); + $stats = $this->getAllToolsProperty(); + + $totalCalls = $stats->sum(fn (ToolStats $s) => $s->totalCalls); + $totalErrors = $stats->sum(fn (ToolStats $s) => $s->errorCount); + $avgDuration = $totalCalls > 0 + ? $stats->sum(fn (ToolStats $s) => $s->avgDurationMs * $s->totalCalls) / $totalCalls + : 0; + + return [ + 'total_calls' => $totalCalls, + 'total_errors' => $totalErrors, + 'error_rate' => $totalCalls > 0 ? round(($totalErrors / $totalCalls) * 100, 2) : 0, + 'avg_duration_ms' => round($avgDuration, 2), + 'unique_tools' => $stats->count(), + ]; + } + + /** + * Get all tool statistics. + */ + public function getAllToolsProperty(): Collection + { + $range = $this->getDateRange(); + + return app(ToolAnalyticsService::class)->getAllToolStats($range['from'], $range['to']); + } + + /** + * Get sorted tool statistics for the table. + */ + public function getSortedToolsProperty(): Collection + { + $tools = $this->getAllToolsProperty(); + + return $tools->sortBy( + fn (ToolStats $s) => match ($this->sortColumn) { + 'toolName' => $s->toolName, + 'totalCalls' => $s->totalCalls, + 'errorCount' => $s->errorCount, + 'errorRate' => $s->errorRate, + 'avgDurationMs' => $s->avgDurationMs, + default => $s->totalCalls, + }, + SORT_REGULAR, + $this->sortDirection === 'desc' + )->values(); + } + + /** + * Get the most popular tools. + */ + public function getPopularToolsProperty(): Collection + { + $range = $this->getDateRange(); + + return app(ToolAnalyticsService::class)->getPopularTools(10, $range['from'], $range['to']); + } + + /** + * Get tools with high error rates. + */ + public function getErrorProneToolsProperty(): Collection + { + $range = $this->getDateRange(); + + return app(ToolAnalyticsService::class)->getErrorProneTools(10, $range['from'], $range['to']); + } + + /** + * Get tool combinations. + */ + public function getToolCombinationsProperty(): Collection + { + $range = $this->getDateRange(); + + return app(ToolAnalyticsService::class)->getToolCombinations(10, $range['from'], $range['to']); + } + + /** + * Get daily trends for charting. + */ + public function getDailyTrendsProperty(): array + { + $range = $this->getDateRange(); + $allStats = $this->getAllToolsProperty(); + + // Aggregate daily data + $dailyData = []; + for ($i = $this->days - 1; $i >= 0; $i--) { + $date = now()->subDays($i); + $dailyData[] = [ + 'date' => $date->toDateString(), + 'date_formatted' => $date->format('M j'), + 'calls' => 0, // Would need per-day aggregation + 'errors' => 0, + ]; + } + + return $dailyData; + } + + /** + * Get chart data for the top tools bar chart. + */ + public function getTopToolsChartDataProperty(): array + { + $tools = $this->getPopularToolsProperty()->take(10); + + return [ + 'labels' => $tools->pluck('toolName')->toArray(), + 'data' => $tools->pluck('totalCalls')->toArray(), + 'colors' => $tools->map(fn (ToolStats $t) => $t->errorRate > 10 ? '#ef4444' : '#3b82f6')->toArray(), + ]; + } + + /** + * Format duration for display. + */ + public function formatDuration(float $ms): string + { + if ($ms === 0.0) { + return '-'; + } + + if ($ms < 1000) { + return round($ms).'ms'; + } + + return round($ms / 1000, 2).'s'; + } + + public function render() + { + return view('mcp::admin.analytics.dashboard'); + } +} diff --git a/src/php/src/Mcp/View/Modal/Admin/ToolAnalyticsDetail.php b/src/php/src/Mcp/View/Modal/Admin/ToolAnalyticsDetail.php new file mode 100644 index 0000000..7353724 --- /dev/null +++ b/src/php/src/Mcp/View/Modal/Admin/ToolAnalyticsDetail.php @@ -0,0 +1,109 @@ +toolName = $name; + } + + public function boot(ToolAnalyticsService $analyticsService): void + { + $this->analyticsService = $analyticsService; + } + + /** + * Set the number of days to display. + */ + public function setDays(int $days): void + { + $this->days = max(1, min(90, $days)); + } + + /** + * Get the tool statistics. + */ + public function getStatsProperty(): ToolStats + { + $from = now()->subDays($this->days - 1)->startOfDay(); + $to = now()->endOfDay(); + + return app(ToolAnalyticsService::class)->getToolStats($this->toolName, $from, $to); + } + + /** + * Get usage trends for the tool. + */ + public function getTrendsProperty(): array + { + return app(ToolAnalyticsService::class)->getUsageTrends($this->toolName, $this->days); + } + + /** + * Get chart data for the usage trend line chart. + */ + public function getTrendChartDataProperty(): array + { + $trends = $this->getTrendsProperty(); + + return [ + 'labels' => array_column($trends, 'date_formatted'), + 'calls' => array_column($trends, 'calls'), + 'errors' => array_column($trends, 'errors'), + 'avgDuration' => array_column($trends, 'avg_duration_ms'), + ]; + } + + /** + * Format duration for display. + */ + public function formatDuration(float $ms): string + { + if ($ms === 0.0) { + return '-'; + } + + if ($ms < 1000) { + return round($ms).'ms'; + } + + return round($ms / 1000, 2).'s'; + } + + public function render() + { + return view('mcp::admin.analytics.tool-detail'); + } +} diff --git a/src/php/src/Mcp/View/Modal/Admin/ToolVersionManager.php b/src/php/src/Mcp/View/Modal/Admin/ToolVersionManager.php new file mode 100644 index 0000000..74a862e --- /dev/null +++ b/src/php/src/Mcp/View/Modal/Admin/ToolVersionManager.php @@ -0,0 +1,349 @@ +checkHadesAccess(); + } + + #[Computed] + public function versions(): LengthAwarePaginator + { + $query = McpToolVersion::query() + ->orderByDesc('created_at'); + + if ($this->search) { + $query->where(function ($q) { + $q->where('tool_name', 'like', "%{$this->search}%") + ->orWhere('server_id', 'like', "%{$this->search}%") + ->orWhere('version', 'like', "%{$this->search}%") + ->orWhere('description', 'like', "%{$this->search}%"); + }); + } + + if ($this->server) { + $query->forServer($this->server); + } + + if ($this->status === 'latest') { + $query->latest(); + } elseif ($this->status === 'deprecated') { + $query->deprecated(); + } elseif ($this->status === 'sunset') { + $query->sunset(); + } elseif ($this->status === 'active') { + $query->active()->where('is_latest', false); + } + + return $query->paginate($this->perPage); + } + + #[Computed] + public function servers(): Collection + { + return app(ToolVersionService::class)->getServersWithVersions(); + } + + #[Computed] + public function stats(): array + { + return app(ToolVersionService::class)->getStats(); + } + + #[Computed] + public function selectedVersion(): ?McpToolVersion + { + if (! $this->selectedVersionId) { + return null; + } + + return McpToolVersion::find($this->selectedVersionId); + } + + #[Computed] + public function versionHistory(): Collection + { + if (! $this->selectedVersion) { + return collect(); + } + + return app(ToolVersionService::class)->getVersionHistory( + $this->selectedVersion->server_id, + $this->selectedVersion->tool_name + ); + } + + #[Computed] + public function schemaComparison(): ?array + { + if (! $this->compareFromId || ! $this->compareToId) { + return null; + } + + $from = McpToolVersion::find($this->compareFromId); + $to = McpToolVersion::find($this->compareToId); + + if (! $from || ! $to) { + return null; + } + + return [ + 'from' => $from, + 'to' => $to, + 'changes' => $from->compareSchemaWith($to), + ]; + } + + // ------------------------------------------------------------------------- + // Actions + // ------------------------------------------------------------------------- + + public function viewVersion(int $id): void + { + $this->selectedVersionId = $id; + $this->showVersionDetail = true; + } + + public function closeVersionDetail(): void + { + $this->showVersionDetail = false; + $this->selectedVersionId = null; + } + + public function openCompareModal(int $fromId, int $toId): void + { + $this->compareFromId = $fromId; + $this->compareToId = $toId; + $this->showCompareModal = true; + } + + public function closeCompareModal(): void + { + $this->showCompareModal = false; + $this->compareFromId = null; + $this->compareToId = null; + } + + public function openDeprecateModal(int $versionId): void + { + $this->deprecateVersionId = $versionId; + $this->deprecateSunsetDate = ''; + $this->showDeprecateModal = true; + } + + public function closeDeprecateModal(): void + { + $this->showDeprecateModal = false; + $this->deprecateVersionId = null; + $this->deprecateSunsetDate = ''; + } + + public function deprecateVersion(): void + { + $version = McpToolVersion::find($this->deprecateVersionId); + if (! $version) { + return; + } + + $sunsetAt = $this->deprecateSunsetDate + ? Carbon::parse($this->deprecateSunsetDate) + : null; + + app(ToolVersionService::class)->deprecateVersion( + $version->server_id, + $version->tool_name, + $version->version, + $sunsetAt + ); + + $this->closeDeprecateModal(); + $this->dispatch('version-deprecated'); + } + + public function markAsLatest(int $versionId): void + { + $version = McpToolVersion::find($versionId); + if (! $version) { + return; + } + + $version->markAsLatest(); + $this->dispatch('version-marked-latest'); + } + + public function openRegisterModal(): void + { + $this->resetRegisterForm(); + $this->showRegisterModal = true; + } + + public function closeRegisterModal(): void + { + $this->showRegisterModal = false; + $this->resetRegisterForm(); + } + + public function registerVersion(): void + { + $this->validate([ + 'registerServer' => 'required|string|max:64', + 'registerTool' => 'required|string|max:128', + 'registerVersion' => 'required|string|max:32|regex:/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?$/', + 'registerDescription' => 'nullable|string|max:1000', + 'registerChangelog' => 'nullable|string|max:5000', + 'registerMigrationNotes' => 'nullable|string|max:5000', + 'registerInputSchema' => 'nullable|string', + ]); + + $inputSchema = null; + if ($this->registerInputSchema) { + $inputSchema = json_decode($this->registerInputSchema, true); + if (json_last_error() !== JSON_ERROR_NONE) { + $this->addError('registerInputSchema', 'Invalid JSON'); + + return; + } + } + + app(ToolVersionService::class)->registerVersion( + serverId: $this->registerServer, + toolName: $this->registerTool, + version: $this->registerVersion, + inputSchema: $inputSchema, + description: $this->registerDescription ?: null, + options: [ + 'changelog' => $this->registerChangelog ?: null, + 'migration_notes' => $this->registerMigrationNotes ?: null, + 'mark_latest' => $this->registerMarkLatest, + ] + ); + + $this->closeRegisterModal(); + $this->dispatch('version-registered'); + } + + public function clearFilters(): void + { + $this->search = ''; + $this->server = ''; + $this->status = ''; + $this->resetPage(); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + public function getStatusBadgeColor(string $status): string + { + return match ($status) { + 'latest' => 'green', + 'active' => 'zinc', + 'deprecated' => 'amber', + 'sunset' => 'red', + default => 'zinc', + }; + } + + public function formatSchema(array $schema): string + { + return json_encode($schema, JSON_PRETTY_PRINT); + } + + private function resetRegisterForm(): void + { + $this->registerServer = ''; + $this->registerTool = ''; + $this->registerVersion = ''; + $this->registerDescription = ''; + $this->registerChangelog = ''; + $this->registerMigrationNotes = ''; + $this->registerInputSchema = ''; + $this->registerMarkLatest = false; + } + + private function checkHadesAccess(): void + { + if (! auth()->user()?->isHades()) { + abort(403, 'Hades access required'); + } + } + + public function render() + { + return view('mcp::admin.tool-version-manager'); + } +} diff --git a/src/php/src/Website/Mcp/Boot.php b/src/php/src/Website/Mcp/Boot.php new file mode 100644 index 0000000..5be5d94 --- /dev/null +++ b/src/php/src/Website/Mcp/Boot.php @@ -0,0 +1,55 @@ +loadViewsFrom(__DIR__.'/View/Blade', 'mcp'); + + // Register mcp layout into the layouts:: namespace + $layoutsPath = dirname(__DIR__, 2).'/Front/View/Blade/layouts'; + $this->loadViewsFrom($layoutsPath, 'layouts'); + Blade::anonymousComponentPath($layoutsPath, 'layouts'); + + $this->registerLivewireComponents(); + $this->registerRoutes(); + } + + protected function registerLivewireComponents(): void + { + Livewire::component('mcp.dashboard', View\Modal\Dashboard::class); + Livewire::component('mcp.api-key-manager', View\Modal\ApiKeyManager::class); + Livewire::component('mcp.api-explorer', View\Modal\ApiExplorer::class); + Livewire::component('mcp.mcp-metrics', View\Modal\McpMetrics::class); + Livewire::component('mcp.mcp-playground', View\Modal\McpPlayground::class); + Livewire::component('mcp.playground', View\Modal\Playground::class); + Livewire::component('mcp.request-log', View\Modal\RequestLog::class); + Livewire::component('mcp.unified-search', View\Modal\UnifiedSearch::class); + } + + protected function registerRoutes(): void + { + // HTML portal routes need web middleware (sessions, CSRF for Livewire) + Route::middleware('web')->group(__DIR__.'/Routes/web.php'); + } +} diff --git a/src/php/src/Website/Mcp/Controllers/McpRegistryController.php b/src/php/src/Website/Mcp/Controllers/McpRegistryController.php new file mode 100644 index 0000000..38c504c --- /dev/null +++ b/src/php/src/Website/Mcp/Controllers/McpRegistryController.php @@ -0,0 +1,482 @@ +environment('production') ? 600 : 0; + } + + /** + * Discovery endpoint: /.well-known/mcp-servers.json + * + * Returns the registry of all available MCP servers. + * This is the entry point for agent discovery. + */ + public function registry(Request $request) + { + $registry = $this->loadRegistry(); + + // Build server summaries for discovery + $servers = collect($registry['servers'] ?? []) + ->map(fn ($ref) => $this->loadServerSummary($ref['id'])) + ->filter() + ->values() + ->all(); + + $data = [ + 'servers' => $servers, + 'registry_version' => $registry['registry_version'] ?? '1.0', + 'organization' => $registry['organization'] ?? 'Host UK', + ]; + + // Always return JSON for .well-known + return response()->json($data); + } + + /** + * Server list page: /servers + * + * Shows all available servers (HTML) or returns JSON array. + */ + public function index(Request $request) + { + $registry = $this->loadRegistry(); + + $servers = collect($registry['servers'] ?? []) + ->map(fn ($ref) => $this->loadServerFull($ref['id'])) + ->filter() + ->values(); + + // Include planned servers for display + $plannedServers = collect($registry['planned_servers'] ?? []); + + if ($this->wantsJson($request)) { + return response()->json([ + 'servers' => $servers, + 'planned' => $plannedServers, + ]); + } + + return view('mcp::web.index', [ + 'servers' => $servers, + 'plannedServers' => $plannedServers, + ]); + } + + /** + * Server detail: /servers/{id} or /servers/{id}.json + * + * Returns full server definition with all tools, resources, workflows. + */ + public function show(Request $request, string $id) + { + // Remove .json extension if present + $id = preg_replace('/\.json$/', '', $id); + + $server = $this->loadServerFull($id); + + if (! $server) { + if ($this->wantsJson($request)) { + return response()->json(['error' => 'Server not found'], 404); + } + abort(404, 'Server not found'); + } + + if ($this->wantsJson($request)) { + return response()->json($server); + } + + return view('mcp::web.show', ['server' => $server]); + } + + /** + * Landing page: / + * + * MCP portal landing page for humans. + */ + public function landing(Request $request) + { + $registry = $this->loadRegistry(); + + $servers = collect($registry['servers'] ?? []) + ->map(fn ($ref) => $this->loadServerSummary($ref['id'])) + ->filter() + ->values(); + + $plannedServers = collect($registry['planned_servers'] ?? []); + + return view('mcp::web.landing', [ + 'servers' => $servers, + 'plannedServers' => $plannedServers, + 'organization' => $registry['organization'] ?? 'Host UK', + ]); + } + + /** + * Connection config generator: /connect + * + * Shows how to add MCP servers to Claude Code etc. + */ + public function connect(Request $request) + { + $registry = $this->loadRegistry(); + + $servers = collect($registry['servers'] ?? []) + ->map(fn ($ref) => $this->loadServerFull($ref['id'])) + ->filter() + ->values(); + + return view('mcp::web.connect', [ + 'servers' => $servers, + 'templates' => $registry['connection_templates'] ?? [], + 'workspace' => $request->attributes->get('mcp_workspace'), + ]); + } + + /** + * Dashboard: /dashboard + * + * Shows MCP usage for the authenticated workspace. + */ + public function dashboard(Request $request) + { + $workspace = $request->attributes->get('mcp_workspace'); + $entitlement = $request->attributes->get('mcp_entitlement'); + + // Get tool call stats for this workspace + $stats = $this->getWorkspaceStats($workspace); + + return view('mcp::web.dashboard', [ + 'workspace' => $workspace, + 'entitlement' => $entitlement, + 'stats' => $stats, + ]); + } + + /** + * API Keys management: /keys + * + * Manage API keys for MCP access. + */ + public function keys(Request $request) + { + $workspace = $request->attributes->get('mcp_workspace'); + + return view('mcp::web.keys', [ + 'workspace' => $workspace, + 'keys' => $workspace->apiKeys ?? collect(), + ]); + } + + /** + * Get MCP usage stats for a workspace. + */ + protected function getWorkspaceStats($workspace): array + { + $since = now()->subDays(30); + + // Use aggregate queries instead of loading all records into memory + $baseQuery = McpToolCall::where('created_at', '>=', $since); + + if ($workspace) { + $baseQuery->where('workspace_id', $workspace->id); + } + + $totalCalls = (clone $baseQuery)->count(); + $successfulCalls = (clone $baseQuery)->where('success', true)->count(); + + $byServer = (clone $baseQuery) + ->selectRaw('server_id, COUNT(*) as count') + ->groupBy('server_id') + ->orderByDesc('count') + ->limit(5) + ->pluck('count', 'server_id') + ->all(); + + $byDay = (clone $baseQuery) + ->selectRaw('DATE(created_at) as date, COUNT(*) as count') + ->groupBy('date') + ->orderBy('date') + ->pluck('count', 'date') + ->all(); + + return [ + 'total_calls' => $totalCalls, + 'successful_calls' => $successfulCalls, + 'by_server' => $byServer, + 'by_day' => $byDay, + ]; + } + + /** + * Usage analytics endpoint: /servers/{id}/analytics + * + * Shows tool usage stats for a specific server. + */ + public function analytics(Request $request, string $id) + { + $server = $this->loadServerFull($id); + + if (! $server) { + if ($this->wantsJson($request)) { + return response()->json(['error' => 'Server not found'], 404); + } + abort(404, 'Server not found'); + } + + // Validate days parameter - bound to reasonable range + $days = min(max($request->integer('days', 7), 1), 90); + + // Get tool call stats for this server + $stats = $this->getServerAnalytics($id, $days); + + if ($this->wantsJson($request)) { + return response()->json([ + 'server_id' => $id, + 'period_days' => $days, + 'stats' => $stats, + ]); + } + + return view('mcp::web.analytics', [ + 'server' => $server, + 'stats' => $stats, + 'days' => $days, + ]); + } + + /** + * OpenAPI specification. + * + * GET /openapi.json or /openapi.yaml + */ + public function openapi(Request $request) + { + $generator = new OpenApiGenerator; + $format = $request->query('format', 'json'); + + if ($format === 'yaml' || str_ends_with($request->path(), '.yaml')) { + return response($generator->toYaml()) + ->header('Content-Type', 'application/x-yaml'); + } + + return response()->json($generator->generate()); + } + + /** + * Get analytics for a specific server. + */ + protected function getServerAnalytics(string $serverId, int $days = 7): array + { + $since = now()->subDays($days); + + $baseQuery = McpToolCall::forServer($serverId) + ->where('created_at', '>=', $since); + + // Get aggregate stats without loading all records into memory + $totalCalls = (clone $baseQuery)->count(); + $successfulCalls = (clone $baseQuery)->where('success', true)->count(); + $failedCalls = $totalCalls - $successfulCalls; + $avgDuration = (clone $baseQuery)->avg('duration_ms') ?? 0; + + // Tool breakdown with aggregates + $byTool = (clone $baseQuery) + ->selectRaw('tool_name, COUNT(*) as calls, SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_count, AVG(duration_ms) as avg_duration') + ->groupBy('tool_name') + ->orderByDesc('calls') + ->limit(10) + ->get() + ->mapWithKeys(fn ($row) => [ + $row->tool_name => [ + 'calls' => (int) $row->calls, + 'success_rate' => $row->calls > 0 + ? round($row->success_count / $row->calls * 100, 1) + : 0, + 'avg_duration_ms' => round($row->avg_duration ?? 0), + ], + ]) + ->all(); + + // Daily breakdown + $byDay = (clone $baseQuery) + ->selectRaw('DATE(created_at) as date, COUNT(*) as count') + ->groupBy('date') + ->orderBy('date') + ->pluck('count', 'date') + ->all(); + + // Error breakdown + $errors = (clone $baseQuery) + ->where('success', false) + ->whereNotNull('error_code') + ->selectRaw('error_code, COUNT(*) as count') + ->groupBy('error_code') + ->orderByDesc('count') + ->limit(5) + ->pluck('count', 'error_code') + ->all(); + + return [ + 'total_calls' => $totalCalls, + 'successful_calls' => $successfulCalls, + 'failed_calls' => $failedCalls, + 'success_rate' => $totalCalls > 0 ? round($successfulCalls / $totalCalls * 100, 1) : 0, + 'avg_duration_ms' => round($avgDuration), + 'by_tool' => $byTool, + 'by_day' => $byDay, + 'errors' => $errors, + ]; + } + + /** + * Load the main registry file. + */ + protected function loadRegistry(): array + { + return Cache::remember('mcp:registry', $this->getCacheTtl(), function () { + $path = resource_path('mcp/registry.yaml'); + + if (! file_exists($path)) { + return ['servers' => [], 'planned_servers' => []]; + } + + return Yaml::parseFile($path); + }); + } + + /** + * Load a server's YAML file. + */ + protected function loadServerYaml(string $id): ?array + { + // Sanitise server ID to prevent path traversal attacks + $id = basename($id, '.yaml'); + + // Validate ID format (alphanumeric with hyphens only) + if (! preg_match('/^[a-z0-9-]+$/', $id)) { + return null; + } + + return Cache::remember("mcp:server:{$id}", $this->getCacheTtl(), function () use ($id) { + $path = resource_path("mcp/servers/{$id}.yaml"); + + if (! file_exists($path)) { + return null; + } + + return Yaml::parseFile($path); + }); + } + + /** + * Load server summary for registry discovery. + * + * Returns minimal info: id, name, description, use_when, connection type. + */ + protected function loadServerSummary(string $id): ?array + { + $server = $this->loadServerYaml($id); + + if (! $server) { + return null; + } + + return [ + 'id' => $server['id'], + 'name' => $server['name'], + 'description' => $server['description'] ?? $server['tagline'] ?? '', + 'tagline' => $server['tagline'] ?? '', + 'icon' => $server['icon'] ?? 'server', + 'status' => $server['status'] ?? 'available', + 'use_when' => $server['use_when'] ?? [], + 'connection' => [ + 'type' => $server['connection']['type'] ?? 'stdio', + ], + 'capabilities' => $this->extractCapabilities($server), + 'related_servers' => $server['related_servers'] ?? [], + ]; + } + + /** + * Load full server definition for detail view. + */ + protected function loadServerFull(string $id): ?array + { + $server = $this->loadServerYaml($id); + + if (! $server) { + return null; + } + + // Add computed fields + $server['tool_count'] = count($server['tools'] ?? []); + $server['resource_count'] = count($server['resources'] ?? []); + $server['workflow_count'] = count($server['workflows'] ?? []); + $server['capabilities'] = $this->extractCapabilities($server); + + return $server; + } + + /** + * Extract capability summary from server definition. + */ + protected function extractCapabilities(array $server): array + { + $caps = []; + + if (! empty($server['tools'])) { + $caps[] = 'tools'; + } + + if (! empty($server['resources'])) { + $caps[] = 'resources'; + } + + return $caps; + } + + /** + * Check if request wants JSON response. + */ + protected function wantsJson(Request $request): bool + { + // Explicit .json extension + if (str_ends_with($request->path(), '.json')) { + return true; + } + + // Accept header + if ($request->wantsJson()) { + return true; + } + + // Query param override + if ($request->query('format') === 'json') { + return true; + } + + return false; + } +} diff --git a/src/php/src/Website/Mcp/Routes/web.php b/src/php/src/Website/Mcp/Routes/web.php new file mode 100644 index 0000000..1240da3 --- /dev/null +++ b/src/php/src/Website/Mcp/Routes/web.php @@ -0,0 +1,46 @@ +name('mcp.')->group(function () { + // Agent discovery endpoint (always JSON, no auth) + Route::get('.well-known/mcp-servers.json', [McpRegistryController::class, 'registry']) + ->name('registry'); + + // ── Human-readable portal (optional auth) ──────────────────── + Route::get('/', [McpRegistryController::class, 'landing']) + ->middleware(McpAuthenticate::class.':optional') + ->name('landing'); + + Route::get('servers', [McpRegistryController::class, 'index']) + ->middleware(McpAuthenticate::class.':optional') + ->name('servers.index'); + + Route::get('servers/{id}', [McpRegistryController::class, 'show']) + ->middleware(McpAuthenticate::class.':optional') + ->name('servers.show') + ->where('id', '[a-z0-9-]+'); + + Route::get('connect', [McpRegistryController::class, 'connect']) + ->middleware(McpAuthenticate::class.':optional') + ->name('connect'); + + // OpenAPI spec + Route::get('openapi.json', [McpRegistryController::class, 'openapi'])->name('openapi.json'); + Route::get('openapi.yaml', [McpRegistryController::class, 'openapi'])->name('openapi.yaml'); +}); diff --git a/src/php/src/Website/Mcp/View/Blade/web/analytics.blade.php b/src/php/src/Website/Mcp/View/Blade/web/analytics.blade.php new file mode 100644 index 0000000..07771c8 --- /dev/null +++ b/src/php/src/Website/Mcp/View/Blade/web/analytics.blade.php @@ -0,0 +1,115 @@ + + {{ $server['name'] }} Analytics + +
+ + +

{{ $server['name'] }} Analytics

+

+ Tool usage statistics for the last {{ $days }} days. +

+
+ + +
+
+

Total Calls

+

{{ number_format($stats['total_calls']) }}

+
+
+

Success Rate

+

+ {{ $stats['success_rate'] }}% +

+
+
+

Successful

+

{{ number_format($stats['successful_calls']) }}

+
+
+

Failed

+

{{ number_format($stats['failed_calls']) }}

+
+
+ + + @if(!empty($stats['by_tool'])) +
+

Tool Usage

+
+ @foreach($stats['by_tool'] as $tool => $data) +
+
+ {{ $tool }} +
+
+ {{ $data['calls'] }} calls + + {{ $data['success_rate'] }}% success + + {{ $data['avg_duration_ms'] }}ms avg +
+
+ @endforeach +
+
+ @endif + + + @if(!empty($stats['by_day'])) +
+

Daily Activity

+
+ @foreach($stats['by_day'] as $date => $count) +
+ {{ $date }} +
+
+ @php + $maxCalls = max($stats['by_day']); + $width = $maxCalls > 0 ? ($count / $maxCalls) * 100 : 0; + @endphp +
+
+
+ {{ $count }} +
+ @endforeach +
+
+ @endif + + + @if(!empty($stats['errors'])) +
+

Error Breakdown

+
+ @foreach($stats['errors'] as $code => $count) +
+ {{ $code ?: 'Unknown' }} + {{ $count }} occurrences +
+ @endforeach +
+
+ @endif + + +
+ Time range: + + @foreach([7, 14, 30] as $range) + + {{ $range }} days + + @endforeach + +
+
diff --git a/src/php/src/Website/Mcp/View/Blade/web/api-explorer.blade.php b/src/php/src/Website/Mcp/View/Blade/web/api-explorer.blade.php new file mode 100644 index 0000000..cb87e4f --- /dev/null +++ b/src/php/src/Website/Mcp/View/Blade/web/api-explorer.blade.php @@ -0,0 +1,219 @@ +
+
+ +
+

API Explorer

+

Interactive documentation with code snippets in 11 languages

+
+ + +
+
+ + + +
+ +
+ + +
+

Enter your API key to enable live testing. Keys are not stored.

+
+
+
+ +
+ +
+
+
+

Endpoints

+
+
+ @foreach($endpoints as $index => $endpoint) + + @endforeach +
+
+
+ + +
+ +
+
+

Request

+
+
+
+ + +
+ + @if(in_array($method, ['POST', 'PUT', 'PATCH'])) +
+
+ + +
+ +
+ @endif + + +
+
+ + +
+
+
+

Code Snippet

+ +
+
+ + +
+
+ @foreach($languages as $lang) + + @endforeach +
+
+ + +
+
{{ $snippet }}
+
+
+ + + @if($error) +
+
+ + + +
+

Error

+

{{ $error }}

+
+
+
+ @endif + + @if($response) +
+
+
+

Response

+ + {{ $response['status'] }} + +
+ {{ $responseTime }}ms +
+
+
{{ json_encode($response['body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) }}
+
+
+ @endif +
+
+ + + +
+ + @script + + @endscript +
diff --git a/src/php/src/Website/Mcp/View/Blade/web/api-key-manager.blade.php b/src/php/src/Website/Mcp/View/Blade/web/api-key-manager.blade.php new file mode 100644 index 0000000..cdfcc3f --- /dev/null +++ b/src/php/src/Website/Mcp/View/Blade/web/api-key-manager.blade.php @@ -0,0 +1,269 @@ +
+ + @if(session('message')) +
+

{{ session('message') }}

+
+ @endif + + +
+
+

+ API Keys +

+

+ Create API keys to authenticate HTTP requests to MCP servers. +

+
+ + Create Key + +
+ + +
+ @if($keys->isEmpty()) +
+
+ +
+

No API Keys Yet

+

+ Create an API key to start making authenticated requests to MCP servers over HTTP. +

+ + Create Your First Key + +
+ @else + + + + + + + + + + + + + @foreach($keys as $key) + + + + + + + + + @endforeach + +
+ Name + + Key + + Scopes + + Last Used + + Expires + + Actions +
+ {{ $key->name }} + + + {{ $key->prefix }}_**** + + +
+ @foreach($key->scopes ?? [] as $scope) + + {{ $scope }} + + @endforeach +
+
+ {{ $key->last_used_at?->diffForHumans() ?? 'Never' }} + + @if($key->expires_at) + @if($key->expires_at->isPast()) + Expired + @else + {{ $key->expires_at->diffForHumans() }} + @endif + @else + Never + @endif + + + Revoke + +
+ @endif +
+ + +
+ +
+

+ + Authentication +

+

+ Include your API key in HTTP requests using one of these methods: +

+
+
+

Authorization Header (recommended)

+
Authorization: Bearer hk_abc123_****
+
+
+

X-API-Key Header

+
X-API-Key: hk_abc123_****
+
+
+
+ + +
+

+ + Example Request +

+

+ Call an MCP tool via HTTP POST: +

+ @php $mcpUrl = request()->getSchemeAndHttpHost(); @endphp +
curl -X POST {{ $mcpUrl }}/tools/call \
+  -H "Authorization: Bearer YOUR_API_KEY" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "server": "openbrain",
+    "tool": "brain_recall",
+    "arguments": { "query": "recent decisions" }
+  }'
+
+
+ + + +
+

Create API Key

+ +
+ +
+ Key Name + + @error('newKeyName') +

{{ $message }}

+ @enderror +
+ + +
+ Permissions +
+ + + +
+
+ + +
+ Expiration + + + + + + +
+
+ +
+ Cancel + Create Key +
+
+
+ + + +
+
+
+ +
+

API Key Created

+
+ +
+

+ Copy this key now. You won't be able to see it again. +

+
+ +
+
{{ $newPlainKey }}
+ +
+ +
+ Done +
+
+
+
diff --git a/src/php/src/Website/Mcp/View/Blade/web/connect.blade.php b/src/php/src/Website/Mcp/View/Blade/web/connect.blade.php new file mode 100644 index 0000000..419ff69 --- /dev/null +++ b/src/php/src/Website/Mcp/View/Blade/web/connect.blade.php @@ -0,0 +1,218 @@ + + Setup Guide + + @php + $mcpUrl = request()->getSchemeAndHttpHost(); + @endphp + +
+
+

Setup Guide

+

+ Connect AI agents to MCP servers via HTTP. +

+
+ + + + + +
+
+
+ +
+
+

HTTP API

+ + All platforms + +
+
+ +

+ Call MCP tools from any language, platform, or AI agent using standard HTTP requests. + Works with Claude Code, Cursor, custom agents, webhooks, and any HTTP client. +

+ +

1. Get your API key

+

+ Create an API key from your admin dashboard. Keys use the hk_ prefix. +

+ +

2. Discover available servers

+
curl {{ $mcpUrl }}/servers.json \
+  -H "Authorization: Bearer YOUR_API_KEY"
+ +

3. Call a tool

+
curl -X POST {{ $mcpUrl }}/tools/call \
+  -H "Authorization: Bearer YOUR_API_KEY" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "server": "openbrain",
+    "tool": "brain_recall",
+    "arguments": { "query": "authentication decisions" }
+  }'
+ +

4. Read a resource

+
curl {{ $mcpUrl }}/resources/plans://all \
+  -H "Authorization: Bearer YOUR_API_KEY"
+ +
+ +
+
+ GET /.well-known/mcp-servers.json + Agent discovery +
+
+ GET /servers + List all servers +
+
+ GET /servers/{id} + Server details + tools +
+
+ POST /tools/call + Execute a tool +
+
+ GET /resources/{uri} + Read a resource +
+
+
+
+ + +
+
+
+ +
+
+

Code Examples

+
+
+ +
+ +
+

Python

+
import requests
+
+resp = requests.post(
+    "{{ $mcpUrl }}/tools/call",
+    headers={"Authorization": "Bearer hk_your_key"},
+    json={
+        "server": "openbrain",
+        "tool": "brain_recall",
+        "arguments": {"query": "recent decisions"}
+    }
+)
+print(resp.json())
+
+ + +
+

JavaScript

+
const resp = await fetch("{{ $mcpUrl }}/tools/call", {
+  method: "POST",
+  headers: {
+    "Authorization": "Bearer hk_your_key",
+    "Content-Type": "application/json",
+  },
+  body: JSON.stringify({
+    server: "openbrain",
+    tool: "brain_recall",
+    arguments: { query: "recent decisions" },
+  }),
+});
+const data = await resp.json();
+
+
+
+ + +
+

Authentication

+ +
+
+

Authorization Header (Recommended)

+
Authorization: Bearer hk_abc123_your_key_here
+
+ +
+

X-API-Key Header

+
X-API-Key: hk_abc123_your_key_here
+
+
+ +
+

Server-scoped keys

+

+ API keys can be restricted to specific MCP servers. If you get a 403 error, + check your key's server scopes in your admin dashboard. +

+
+ +
+

Rate limiting

+

+ Requests are rate limited to 120 per minute. Rate limit headers + (X-RateLimit-Limit, X-RateLimit-Remaining) + are included in all responses. +

+
+
+ + +
+

Discovery

+

+ Agents discover available servers automatically via the well-known endpoint: +

+
curl {{ $mcpUrl }}/.well-known/mcp-servers.json
+

+ Returns the server registry with capabilities and connection details. + No authentication required for discovery. +

+
+ + +
+

Need help setting up?

+
+ + Browse Servers + + + OpenAPI Spec + +
+
+
+
diff --git a/src/php/src/Website/Mcp/View/Blade/web/dashboard.blade.php b/src/php/src/Website/Mcp/View/Blade/web/dashboard.blade.php new file mode 100644 index 0000000..f530059 --- /dev/null +++ b/src/php/src/Website/Mcp/View/Blade/web/dashboard.blade.php @@ -0,0 +1,283 @@ +
+ +
+
+ Upstream Intelligence + Track vendor updates and manage porting tasks +
+
+ Refresh +
+
+ + +
+
+ Vendors + {{ $this->stats['total_vendors'] }} +
+
+ Pending + {{ $this->stats['pending_todos'] }} +
+
+ Quick Wins + {{ $this->stats['quick_wins'] }} +
+
+ Security + {{ $this->stats['security_updates'] }} +
+
+ In Progress + {{ $this->stats['in_progress'] }} +
+
+ This Week + {{ $this->stats['recent_releases'] }} +
+
+ + +
+
+ Tracked Vendors +
+
+
+ @foreach($this->vendors as $vendor) +
+
+ {{ $vendor->getSourceTypeIcon() }} + {{ $vendor->name }} +
+
+
{{ $vendor->vendor_name }} · {{ $vendor->getSourceTypeLabel() }}
+
Version: {{ $vendor->current_version ?? 'Not set' }}
+
+ {{ $vendor->todos_count }} todos + {{ $vendor->releases_count }} releases +
+
+
+ @endforeach +
+
+
+ + +
+
+ + All Vendors + @foreach($this->vendors as $vendor) + {{ $vendor->name }} + @endforeach + + + + All Status + Pending + In Progress + Ported + Skipped + + + + All Types + Feature + Bugfix + Security + UI + Block + API + + + + All Effort + Low (<1hr) + Medium (1-4hr) + High (4+hr) + + + +
+
+ + +
+
+ Porting Tasks + {{ $this->todos->total() }} total +
+
+ + + Type + Title + Vendor + Priority + Effort + Status + Actions + + + @forelse($this->todos as $todo) + + + {{ $todo->getTypeIcon() }} + + +
+
{{ $todo->title }}
+ @if($todo->description) +
{{ Str::limit($todo->description, 80) }}
+ @endif +
+
+ + {{ $todo->vendor->name }} + + + + {{ $todo->priority }}/10 + + + + + {{ $todo->getEffortLabel() }} + + + + + {{ str_replace('_', ' ', $todo->status) }} + + + + @if($todo->status === 'pending') + Start + @elseif($todo->status === 'in_progress') +
+ Done + Skip +
+ @endif +
+
+ @empty + + + No todos found matching filters + + + @endforelse +
+
+
+ @if($this->todos->hasPages()) +
+ {{ $this->todos->links() }} +
+ @endif +
+ + +
+ +
+
+ Asset Library +
+ @if($this->assetStats['updates_available'] > 0) + {{ $this->assetStats['updates_available'] }} updates + @endif + {{ $this->assetStats['total'] }} assets +
+
+
+
+ @foreach($this->assets as $asset) +
+
+ {{ $asset->getTypeIcon() }} +
+
{{ $asset->name }}
+
+ @if($asset->package_name) + {{ $asset->package_name }} + @endif +
+
+
+
+ {{ $asset->getLicenseIcon() }} + @if($asset->installed_version) + + {{ $asset->installed_version }} + @if($asset->hasUpdate()) + → {{ $asset->latest_version }} + @endif + + @else + Not installed + @endif +
+
+ @endforeach +
+
+
+ + +
+
+ Pattern Library + {{ $this->assetStats['patterns'] }} patterns +
+
+
+ @foreach($this->patterns as $pattern) +
+
+ {{ $pattern->getCategoryIcon() }} + {{ $pattern->name }} +
+
{{ $pattern->description }}
+
+ {{ $pattern->language }} + @if($pattern->is_vetted) + Vetted + @endif +
+
+ @endforeach +
+
+
+
+ + +
+
+ Recent Activity +
+
+
+ @forelse($this->recentLogs as $log) +
+ {{ $log->getActionIcon() }} + {{ $log->created_at->diffForHumans() }} + {{ $log->getActionLabel() }} + · + {{ $log->vendor->name }} + @if($log->error_message) + Error + @endif +
+ @empty +
No recent activity
+ @endforelse +
+
+
+
diff --git a/src/php/src/Website/Mcp/View/Blade/web/index.blade.php b/src/php/src/Website/Mcp/View/Blade/web/index.blade.php new file mode 100644 index 0000000..6e25257 --- /dev/null +++ b/src/php/src/Website/Mcp/View/Blade/web/index.blade.php @@ -0,0 +1,132 @@ + + MCP Servers + +
+

MCP Servers

+

+ All available MCP servers for AI agent integration. +

+
+ + +
+ @forelse($servers as $server) +
+
+
+
+
+ @switch($server['id']) + @case('hosthub-agent') + + @break + @case('commerce') + + @break + @case('socialhost') + + @break + @case('biohost') + + @break + @case('supporthost') + + @break + @case('openbrain') + + @break + @case('analyticshost') + + @break + @case('eaas') + + @break + @default + + @endswitch +
+
+

+ {{ $server['name'] }} +

+

{{ $server['id'] }}

+
+
+ + {{ ucfirst($server['status'] ?? 'available') }} + +
+ +

+ {{ $server['tagline'] ?? $server['description'] ?? '' }} +

+ + +
+ + + {{ $server['tool_count'] ?? 0 }} tools + + + + {{ $server['resource_count'] ?? 0 }} resources + + @if(($server['workflow_count'] ?? 0) > 0) + + + {{ $server['workflow_count'] }} workflows + + @endif +
+
+ + +
+ @empty +
+ +

No MCP servers available.

+
+ @endforelse +
+ + + @if($plannedServers->isNotEmpty()) +
+

Planned Servers

+
+ @foreach($plannedServers as $server) +
+
+
+ @switch($server['id']) + @case('upstream') + + @break + @case('analyticshost') + + @break + @default + + @endswitch +
+

{{ $server['name'] }}

+
+

{{ $server['tagline'] ?? '' }}

+
+ @endforeach +
+
+ @endif +
diff --git a/src/php/src/Website/Mcp/View/Blade/web/keys.blade.php b/src/php/src/Website/Mcp/View/Blade/web/keys.blade.php new file mode 100644 index 0000000..92ff915 --- /dev/null +++ b/src/php/src/Website/Mcp/View/Blade/web/keys.blade.php @@ -0,0 +1,6 @@ + + API Keys + Manage API keys for MCP server access. + + + diff --git a/src/php/src/Website/Mcp/View/Blade/web/landing.blade.php b/src/php/src/Website/Mcp/View/Blade/web/landing.blade.php new file mode 100644 index 0000000..5da9bfe --- /dev/null +++ b/src/php/src/Website/Mcp/View/Blade/web/landing.blade.php @@ -0,0 +1,214 @@ + + MCP Portal + Connect AI agents to platform infrastructure via Model Context Protocol. Machine-readable, agent-optimised, human-friendly. + + +
+

+ MCP Ecosystem +

+

+ Connect AI agents to platform infrastructure via MCP.
+ Machine-readable • + Agent-optimised • + Human-friendly +

+
+ + Browse Servers + + + Setup Guide + +
+
+ + +
+

Developer Tools

+ +
+ + +
+

Available Servers

+ +
+ + + @if($plannedServers->isNotEmpty()) +
+

Coming Soon

+
+ @foreach($plannedServers as $server) +
+
+
+ @switch($server['id']) + @case('analyticshost') + + @break + @case('upstream') + + @break + @default + + @endswitch +
+ + Planned + +
+

+ {{ $server['name'] }} +

+

+ {{ $server['tagline'] ?? '' }} +

+
+ @endforeach +
+
+ @endif + + + @php + $mcpUrl = request()->getSchemeAndHttpHost(); + @endphp +
+

Quick Start

+

+ Call MCP tools via HTTP with your API key: +

+
curl -X POST {{ $mcpUrl }}/tools/call \
+  -H "Authorization: Bearer YOUR_API_KEY" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "server": "openbrain",
+    "tool": "brain_recall",
+    "arguments": { "query": "recent decisions" }
+  }'
+
+ + Full Setup Guide + + + OpenAPI Spec + +
+
+
diff --git a/src/php/src/Website/Mcp/View/Blade/web/mcp-metrics.blade.php b/src/php/src/Website/Mcp/View/Blade/web/mcp-metrics.blade.php new file mode 100644 index 0000000..fd550bc --- /dev/null +++ b/src/php/src/Website/Mcp/View/Blade/web/mcp-metrics.blade.php @@ -0,0 +1,309 @@ +
+ +
+
+ MCP Agent Metrics + Monitor tool usage, performance, and errors +
+
+ + 7 Days + 14 Days + 30 Days + + Refresh +
+
+ + +
+
+ Total Calls + {{ number_format($this->overview['total_calls']) }} + @if($this->overview['calls_trend_percent'] != 0) + + {{ $this->overview['calls_trend_percent'] > 0 ? '+' : '' }}{{ $this->overview['calls_trend_percent'] }}% + + @endif +
+
+ Success Rate + {{ $this->overview['success_rate'] }}% +
+
+ Successful + {{ number_format($this->overview['success_calls']) }} +
+
+ Errors + {{ number_format($this->overview['error_calls']) }} +
+
+ Avg Duration + {{ $this->overview['avg_duration_ms'] < 1000 ? $this->overview['avg_duration_ms'] . 'ms' : round($this->overview['avg_duration_ms'] / 1000, 2) . 's' }} +
+
+ Unique Tools + {{ $this->overview['unique_tools'] }} +
+
+ + +
+ +
+ + @if($activeTab === 'overview') +
+ +
+
+ Daily Call Volume +
+
+
+ @foreach($this->dailyTrend as $day) +
+ {{ $day['date_formatted'] }} +
+
+ @php + $maxCalls = collect($this->dailyTrend)->max('total_calls') ?: 1; + $successWidth = ($day['total_success'] / $maxCalls) * 100; + $errorWidth = ($day['total_errors'] / $maxCalls) * 100; + @endphp +
+
+
+
+
+ {{ $day['total_calls'] }} +
+
+ @endforeach +
+
+
+ + +
+
+ Top Tools +
+
+
+ @forelse($this->topTools as $tool) +
+
+ {{ $tool->tool_name }} + {{ $tool->server_id }} +
+
+ + {{ $tool->success_rate }}% + + {{ number_format($tool->total_calls) }} +
+
+ @empty +
No tool calls recorded yet
+ @endforelse +
+
+
+ + +
+
+ Server Breakdown +
+
+ @forelse($this->serverStats as $server) +
+
+ {{ $server->server_id }} + {{ $server->unique_tools }} tools +
+
+ {{ number_format($server->total_success) }} + {{ number_format($server->total_errors) }} + {{ number_format($server->total_calls) }} +
+
+ @empty +
No servers active yet
+ @endforelse +
+
+ + +
+
+ Plan Activity +
+
+ @forelse($this->planActivity as $plan) +
+
+ {{ $plan->plan_slug }} + {{ $plan->unique_tools }} tools +
+
+ + {{ $plan->success_rate }}% + + {{ number_format($plan->call_count) }} +
+
+ @empty +
No plan activity recorded
+ @endforelse +
+
+
+ @endif + + @if($activeTab === 'performance') +
+ +
+
+ Tool Performance (p50 / p95 / p99) +
+
+ + + + + + + + + + + + + + + @forelse($this->toolPerformance as $tool) + + + + + + + + + + + @empty + + + + @endforelse + +
ToolCallsMinAvgp50p95p99Max
{{ $tool['tool_name'] }}{{ number_format($tool['call_count']) }}{{ $tool['min_ms'] }}ms{{ round($tool['avg_ms']) }}ms{{ round($tool['p50_ms']) }}ms{{ round($tool['p95_ms']) }}ms{{ round($tool['p99_ms']) }}ms{{ $tool['max_ms'] }}ms
No performance data recorded yet
+
+
+ + +
+
+ Hourly Distribution (Last 24 Hours) +
+
+
+ @php $maxHourly = collect($this->hourlyDistribution)->max('call_count') ?: 1; @endphp + @foreach($this->hourlyDistribution as $hour) +
+
+ {{ $hour['hour_formatted'] }} +
+ @endforeach +
+
+
+
+ @endif + + @if($activeTab === 'errors') +
+
+ Error Breakdown +
+
+ + + + + + + + + + @forelse($this->errorBreakdown as $error) + + + + + + @empty + + + + @endforelse + +
ToolError CodeCount
{{ $error->tool_name }} + + {{ $error->error_code ?? 'unknown' }} + + {{ number_format($error->error_count) }}
No errors recorded - all systems healthy
+
+
+ @endif + + @if($activeTab === 'activity') +
+
+ Recent Activity +
+
+ @forelse($this->recentCalls as $call) +
+
+ +
+ {{ $call['tool_name'] }} + @if($call['plan_slug']) + @ {{ $call['plan_slug'] }} + @endif + @if(!$call['success'] && $call['error_message']) +
{{ Str::limit($call['error_message'], 80) }}
+ @endif +
+
+
+ {{ $call['duration'] }} + {{ $call['created_at'] }} +
+
+ @empty +
No activity recorded yet
+ @endforelse +
+
+ @endif +
diff --git a/src/php/src/Website/Mcp/View/Blade/web/mcp-playground.blade.php b/src/php/src/Website/Mcp/View/Blade/web/mcp-playground.blade.php new file mode 100644 index 0000000..df3ea74 --- /dev/null +++ b/src/php/src/Website/Mcp/View/Blade/web/mcp-playground.blade.php @@ -0,0 +1,180 @@ +
+
+ +
+

MCP Tool Playground

+

Test MCP tool calls with custom parameters

+
+ +
+ +
+
+

Request

+ + +
+ + +
+ + +
+ + + @if($selectedTool) + @php $currentTool = collect($tools)->firstWhere('name', $selectedTool); @endphp + @if($currentTool && !empty($currentTool['purpose'])) +

{{ $currentTool['purpose'] }}

+ @endif + @endif +
+ + +
+
+ + +
+ + @error('inputJson') +

{{ $message }}

+ @enderror +
+ + + +
+
+ + +
+
+
+

Response

+ @if($executionTime > 0) + {{ $executionTime }}ms + @endif +
+ + @if($lastError) +
+
+
+ + + +
+
+

Error

+

{{ $lastError }}

+
+
+
+ @endif + +
+
@if($lastResult){{ json_encode($lastResult, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) }}@else// Response will appear here...@endif
+
+
+
+
+ + + @if($selectedTool && !empty($tools)) + @php $currentTool = collect($tools)->firstWhere('name', $selectedTool); @endphp + @if($currentTool && !empty($currentTool['parameters'])) +
+
+

Parameter Reference

+
+ + + + + + + + + + + @foreach($currentTool['parameters'] as $paramName => $paramDef) + + + + + + + @endforeach + +
NameTypeRequiredDescription
{{ $paramName }}{{ is_array($paramDef) ? ($paramDef['type'] ?? 'string') : 'string' }} + @if(is_array($paramDef) && ($paramDef['required'] ?? false)) + Required + @else + Optional + @endif + {{ is_array($paramDef) ? ($paramDef['description'] ?? '-') : $paramDef }}
+
+
+
+ @endif + @endif + + + +
+
diff --git a/src/php/src/Website/Mcp/View/Blade/web/playground.blade.php b/src/php/src/Website/Mcp/View/Blade/web/playground.blade.php new file mode 100644 index 0000000..205a512 --- /dev/null +++ b/src/php/src/Website/Mcp/View/Blade/web/playground.blade.php @@ -0,0 +1,274 @@ +
+
+

Playground

+

+ Test MCP tools interactively and execute requests live. +

+
+ + {{-- Error Display --}} + @if($error) +
+
+ +

{{ $error }}

+
+
+ @endif + +
+ +
+ +
+

Authentication

+ +
+
+ +
+ +
+ + Validate Key + + + @if($keyStatus === 'valid') + + + Valid + + @elseif($keyStatus === 'invalid') + + + Invalid key + + @elseif($keyStatus === 'expired') + + + Expired + + @elseif($keyStatus === 'empty') + + Enter a key to validate + + @endif +
+ + @if($keyInfo) +
+
+
+ Name: + {{ $keyInfo['name'] }} +
+
+ Workspace: + {{ $keyInfo['workspace'] }} +
+
+ Scopes: + {{ implode(', ', $keyInfo['scopes'] ?? []) }} +
+
+ Last used: + {{ $keyInfo['last_used'] }} +
+
+
+ @elseif(!$isAuthenticated && !$apiKey) +
+

+ Sign in + to create API keys, or paste an existing key above. +

+
+ @endif +
+
+ + +
+

Select Tool

+ +
+ + @foreach($servers as $server) + {{ $server['name'] }} + @endforeach + + + @if($selectedServer && count($tools) > 0) + + @foreach($tools as $tool) + {{ $tool['name'] }} + @endforeach + + @endif +
+
+ + + @if($toolSchema) +
+
+

{{ $toolSchema['name'] }}

+

{{ $toolSchema['description'] ?? $toolSchema['purpose'] ?? '' }}

+
+ + @php + $params = $toolSchema['inputSchema']['properties'] ?? $toolSchema['parameters'] ?? []; + $required = $toolSchema['inputSchema']['required'] ?? []; + @endphp + + @if(count($params) > 0) +
+

Arguments

+ + @foreach($params as $name => $schema) +
+ @php + $paramRequired = in_array($name, $required) || ($schema['required'] ?? false); + $paramType = is_array($schema['type'] ?? 'string') ? ($schema['type'][0] ?? 'string') : ($schema['type'] ?? 'string'); + @endphp + + @if(isset($schema['enum'])) + + @foreach($schema['enum'] as $option) + {{ $option }} + @endforeach + + @elseif($paramType === 'boolean') + + true + false + + @elseif($paramType === 'integer' || $paramType === 'number') + + @else + + @endif +
+ @endforeach +
+ @else +

This tool has no arguments.

+ @endif + +
+ + + @if($keyStatus === 'valid') + Execute Request + @else + Generate Request + @endif + + Executing... + +
+
+ @endif +
+ + +
+
+

Response

+ + @if($response) +
+
+ +
+
{{ $response }}
+
+ @else +
+ +

Select a server and tool to get started.

+
+ @endif +
+ + +
+

API Reference

+
+
+ Endpoint: + {{ request()->getSchemeAndHttpHost() }}/tools/call +
+
+ Method: + POST +
+
+ Auth: + @if($keyStatus === 'valid') + Bearer {{ Str::limit($apiKey, 20, '...') }} + @else + Bearer <your-api-key> + @endif +
+
+ Content-Type: + application/json +
+
+ +
+
+
+
+ +@script + +@endscript diff --git a/src/php/src/Website/Mcp/View/Blade/web/request-log.blade.php b/src/php/src/Website/Mcp/View/Blade/web/request-log.blade.php new file mode 100644 index 0000000..fc6a27b --- /dev/null +++ b/src/php/src/Website/Mcp/View/Blade/web/request-log.blade.php @@ -0,0 +1,153 @@ +
+
+

Request Log

+

+ View API requests and generate curl commands to replay them. +

+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+ +
+
+ @forelse($requests as $request) + + @empty +
+ No requests found. +
+ @endforelse +
+ + @if($requests->hasPages()) +
+ {{ $requests->links() }} +
+ @endif +
+ + +
+ @if($selectedRequest) +
+

Request Detail

+ +
+ +
+ +
+ + + {{ $selectedRequest->response_status }} + {{ $selectedRequest->isSuccessful() ? 'OK' : 'Error' }} + +
+ + +
+ +
{{ json_encode($selectedRequest->request_body, JSON_PRETTY_PRINT) }}
+
+ + +
+ +
{{ json_encode($selectedRequest->response_body, JSON_PRETTY_PRINT) }}
+
+ + @if($selectedRequest->error_message) +
+ +
{{ $selectedRequest->error_message }}
+
+ @endif + + +
+ +
{{ $selectedRequest->toCurl() }}
+
+ + +
+
Request ID: {{ $selectedRequest->request_id }}
+
Duration: {{ $selectedRequest->duration_for_humans }}
+
IP: {{ $selectedRequest->ip_address ?? 'N/A' }}
+
Time: {{ $selectedRequest->created_at->format('Y-m-d H:i:s') }}
+
+
+ @else +
+ +

Select a request to view details and generate replay commands.

+
+ @endif +
+
+
diff --git a/src/php/src/Website/Mcp/View/Blade/web/show.blade.php b/src/php/src/Website/Mcp/View/Blade/web/show.blade.php new file mode 100644 index 0000000..c8aaad3 --- /dev/null +++ b/src/php/src/Website/Mcp/View/Blade/web/show.blade.php @@ -0,0 +1,243 @@ + + {{ $server['name'] }} + {{ $server['tagline'] ?? $server['description'] ?? '' }} + + +
+ + +
+
+
+ @switch($server['id']) + @case('hosthub-agent') + + @break + @case('commerce') + + @break + @case('socialhost') + + @break + @case('biohost') + + @break + @case('supporthost') + + @break + @case('openbrain') + + @break + @case('analyticshost') + + @break + @case('eaas') + + @break + @case('upstream') + + @break + @default + + @endswitch +
+
+

{{ $server['name'] }}

+

{{ $server['id'] }}

+
+
+ + {{ ucfirst($server['status'] ?? 'available') }} + +
+ +

+ {{ $server['tagline'] ?? '' }} +

+
+ + + @if(!empty($server['description'])) +
+

About

+
+ {!! nl2br(e($server['description'])) !!} +
+
+ @endif + + +
+ @if(!empty($server['use_when'])) +
+

+ + Use when +

+
    + @foreach($server['use_when'] as $item) +
  • • {{ $item }}
  • + @endforeach +
+
+ @endif + + @if(!empty($server['dont_use_when'])) +
+

+ + Don't use when +

+
    + @foreach($server['dont_use_when'] as $item) +
  • • {{ $item }}
  • + @endforeach +
+
+ @endif +
+ + + @php + $mcpUrl = request()->getSchemeAndHttpHost(); + @endphp +
+

Connection

+

+ Call tools on this server via HTTP: +

+
curl -X POST {{ $mcpUrl }}/tools/call \
+  -H "Authorization: Bearer YOUR_API_KEY" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "server": "{{ $server['id'] }}",
+    "tool": "{{ !empty($server['tools']) ? $server['tools'][0]['name'] : 'tool_name' }}",
+    "arguments": {}
+  }'
+

+ + Full setup guide → + +

+
+ + + @if(!empty($server['tools'])) +
+

+ Tools ({{ count($server['tools']) }}) +

+
+ @foreach($server['tools'] as $tool) +
+
+

+ {{ $tool['name'] }} +

+
+

+ {{ $tool['purpose'] ?? '' }} +

+ + @if(!empty($tool['example_prompts'])) +
+

Example prompts:

+
    + @foreach(array_slice($tool['example_prompts'], 0, 3) as $prompt) +
  • "{{ $prompt }}"
  • + @endforeach +
+
+ @endif + + @if(!empty($tool['parameters'])) +
+ + Parameters + +
+ @foreach($tool['parameters'] as $name => $param) +
+ {{ $name }} + @if(!empty($param['required'])) + * + @endif + + {{ is_array($param['type'] ?? '') ? implode('|', $param['type']) : ($param['type'] ?? 'string') }} + + @if(!empty($param['description'])) +

{{ $param['description'] }}

+ @endif +
+ @endforeach +
+
+ @endif +
+ @endforeach +
+
+ @endif + + + @if(!empty($server['resources'])) +
+

+ Resources ({{ count($server['resources']) }}) +

+
+ @foreach($server['resources'] as $resource) +
+ +
+

{{ $resource['uri'] }}

+

{{ $resource['purpose'] ?? $resource['name'] ?? '' }}

+
+
+ @endforeach +
+
+ @endif + + + @if(!empty($server['workflows'])) +
+

+ Workflows ({{ count($server['workflows']) }}) +

+
+ @foreach($server['workflows'] as $workflow) +
+

{{ $workflow['name'] }}

+

{{ $workflow['description'] ?? '' }}

+ @if(!empty($workflow['steps'])) +
    + @foreach($workflow['steps'] as $index => $step) +
  1. + {{ $step['action'] }} + @if(!empty($step['note'])) + — {{ $step['note'] }} + @endif +
  2. + @endforeach +
+ @endif +
+ @endforeach +
+
+ @endif + + + +
diff --git a/src/php/src/Website/Mcp/View/Blade/web/unified-search.blade.php b/src/php/src/Website/Mcp/View/Blade/web/unified-search.blade.php new file mode 100644 index 0000000..db7d1b3 --- /dev/null +++ b/src/php/src/Website/Mcp/View/Blade/web/unified-search.blade.php @@ -0,0 +1,202 @@ +
+
+ +
+

Search

+

Find tools, endpoints, patterns, and more across the system

+
+ + +
+
+
+
+ + + +
+ +
+
+ + +
+
+ Filter: + @foreach($this->types as $typeKey => $typeInfo) + + @endforeach + + @if(count($selectedTypes) > 0) + + @endif +
+
+
+ + + @if(strlen($query) >= 2) + + + + @if($this->results->count() > 0) +
+ Showing {{ $this->results->count() }} result{{ $this->results->count() !== 1 ? 's' : '' }} +
+ @endif + @else + +
+ + + +

Start searching

+

Type at least 2 characters to search across all system components.

+
+ @foreach($this->types as $typeKey => $typeInfo) + + {{ $typeInfo['name'] }} + + @endforeach +
+
+ @endif + + + +
+
diff --git a/src/php/src/Website/Mcp/View/Modal/ApiExplorer.php b/src/php/src/Website/Mcp/View/Modal/ApiExplorer.php new file mode 100644 index 0000000..60d79c6 --- /dev/null +++ b/src/php/src/Website/Mcp/View/Modal/ApiExplorer.php @@ -0,0 +1,271 @@ + 'List Workspaces', + 'method' => 'GET', + 'path' => '/api/v1/workspaces', + 'description' => 'Get all workspaces for the authenticated user', + 'body' => null, + ], + [ + 'name' => 'Create Workspace', + 'method' => 'POST', + 'path' => '/api/v1/workspaces', + 'description' => 'Create a new workspace', + 'body' => ['name' => 'My Workspace', 'description' => 'A new workspace'], + ], + [ + 'name' => 'Get Workspace', + 'method' => 'GET', + 'path' => '/api/v1/workspaces/{id}', + 'description' => 'Get a specific workspace by ID', + 'body' => null, + ], + [ + 'name' => 'Update Workspace', + 'method' => 'PATCH', + 'path' => '/api/v1/workspaces/{id}', + 'description' => 'Update workspace details', + 'body' => ['name' => 'Updated Workspace', 'settings' => ['timezone' => 'UTC']], + ], + [ + 'name' => 'List Namespaces', + 'method' => 'GET', + 'path' => '/api/v1/namespaces', + 'description' => 'Get all namespaces accessible to the user', + 'body' => null, + ], + [ + 'name' => 'Check Entitlement', + 'method' => 'POST', + 'path' => '/api/v1/namespaces/{id}/entitlements/check', + 'description' => 'Check if a namespace has access to a feature', + 'body' => ['feature' => 'storage', 'quantity' => 1073741824], + ], + [ + 'name' => 'List API Keys', + 'method' => 'GET', + 'path' => '/api/v1/api-keys', + 'description' => 'Get all API keys for the workspace', + 'body' => null, + ], + [ + 'name' => 'Create API Key', + 'method' => 'POST', + 'path' => '/api/v1/api-keys', + 'description' => 'Create a new API key', + 'body' => ['name' => 'Production Key', 'scopes' => ['read:all'], 'rate_limit_tier' => 'pro'], + ], + ]; + + protected ApiSnippetService $snippetService; + + public function boot(ApiSnippetService $snippetService): void + { + $this->snippetService = $snippetService; + } + + public function mount(): void + { + // Set base URL from current request (mcp domain) + $this->baseUrl = request()->getSchemeAndHttpHost(); + + // Pre-select first endpoint + if (! empty($this->endpoints)) { + $this->selectEndpoint(0); + } + } + + public function selectEndpoint(int $index): void + { + if (! isset($this->endpoints[$index])) { + return; + } + + $endpoint = $this->endpoints[$index]; + $this->selectedEndpoint = (string) $index; + $this->method = $endpoint['method']; + $this->path = $endpoint['path']; + $this->bodyJson = $endpoint['body'] + ? json_encode($endpoint['body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) + : '{}'; + $this->response = null; + $this->error = null; + } + + public function getCodeSnippet(): string + { + $headers = [ + 'Authorization' => 'Bearer '.($this->apiKey ?: 'YOUR_API_KEY'), + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]; + + $body = null; + if (in_array($this->method, ['POST', 'PUT', 'PATCH']) && $this->bodyJson !== '{}') { + $body = json_decode($this->bodyJson, true); + } + + return $this->snippetService->generate( + $this->selectedLanguage, + $this->method, + $this->path, + $headers, + $body, + $this->baseUrl + ); + } + + public function getAllSnippets(): array + { + $headers = [ + 'Authorization' => 'Bearer '.($this->apiKey ?: 'YOUR_API_KEY'), + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]; + + $body = null; + if (in_array($this->method, ['POST', 'PUT', 'PATCH']) && $this->bodyJson !== '{}') { + $body = json_decode($this->bodyJson, true); + } + + return $this->snippetService->generateAll( + $this->method, + $this->path, + $headers, + $body, + $this->baseUrl + ); + } + + public function copyToClipboard(): void + { + $this->dispatch('copy-to-clipboard', code: $this->getCodeSnippet()); + } + + public function sendRequest(): void + { + if (empty($this->apiKey)) { + $this->error = 'Please enter your API key to send requests'; + + return; + } + + $this->isLoading = true; + $this->response = null; + $this->error = null; + + try { + $startTime = microtime(true); + + $url = rtrim($this->baseUrl, '/').'/'.ltrim($this->path, '/'); + + $options = [ + 'http' => [ + 'method' => $this->method, + 'header' => [ + "Authorization: Bearer {$this->apiKey}", + 'Content-Type: application/json', + 'Accept: application/json', + ], + 'timeout' => 30, + 'ignore_errors' => true, + ], + ]; + + if (in_array($this->method, ['POST', 'PUT', 'PATCH']) && $this->bodyJson !== '{}') { + $options['http']['content'] = $this->bodyJson; + } + + $context = stream_context_create($options); + $result = @file_get_contents($url, false, $context); + + $this->responseTime = (int) round((microtime(true) - $startTime) * 1000); + + if ($result === false) { + $this->error = 'Request failed - check your API key and endpoint'; + + return; + } + + // Parse response headers + $statusCode = 200; + if (isset($http_response_header[0])) { + preg_match('/HTTP\/\d+\.?\d* (\d+)/', $http_response_header[0], $matches); + $statusCode = (int) ($matches[1] ?? 200); + } + + $this->response = [ + 'status' => $statusCode, + 'body' => json_decode($result, true) ?? $result, + 'headers' => $http_response_header ?? [], + ]; + + } catch (\Exception $e) { + $this->error = $e->getMessage(); + } finally { + $this->isLoading = false; + } + } + + public function formatBody(): void + { + try { + $decoded = json_decode($this->bodyJson, true); + if (json_last_error() === JSON_ERROR_NONE) { + $this->bodyJson = json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + } catch (\Exception $e) { + // Ignore + } + } + + public function render() + { + return view('mcp::web.api-explorer', [ + 'languages' => ApiSnippetService::getLanguages(), + 'snippet' => $this->getCodeSnippet(), + ]); + } +} diff --git a/src/php/src/Website/Mcp/View/Modal/ApiKeyManager.php b/src/php/src/Website/Mcp/View/Modal/ApiKeyManager.php new file mode 100644 index 0000000..a41114f --- /dev/null +++ b/src/php/src/Website/Mcp/View/Modal/ApiKeyManager.php @@ -0,0 +1,110 @@ +workspace = $workspace; + } + + public function openCreateModal(): void + { + $this->showCreateModal = true; + $this->newKeyName = ''; + $this->newKeyScopes = ['read', 'write']; + $this->newKeyExpiry = 'never'; + } + + public function closeCreateModal(): void + { + $this->showCreateModal = false; + } + + public function createKey(): void + { + $this->validate([ + 'newKeyName' => 'required|string|max:100', + ]); + + $expiresAt = match ($this->newKeyExpiry) { + '30days' => now()->addDays(30), + '90days' => now()->addDays(90), + '1year' => now()->addYear(), + default => null, + }; + + $result = ApiKey::generate( + workspaceId: $this->workspace->id, + userId: auth()->id(), + name: $this->newKeyName, + scopes: $this->newKeyScopes, + expiresAt: $expiresAt, + ); + + $this->newPlainKey = $result['plain_key']; + $this->showCreateModal = false; + $this->showNewKeyModal = true; + + session()->flash('message', 'API key created successfully.'); + } + + public function closeNewKeyModal(): void + { + $this->newPlainKey = null; + $this->showNewKeyModal = false; + } + + public function revokeKey(int $keyId): void + { + $key = $this->workspace->apiKeys()->findOrFail($keyId); + $key->revoke(); + + session()->flash('message', 'API key revoked.'); + } + + public function toggleScope(string $scope): void + { + if (in_array($scope, $this->newKeyScopes)) { + $this->newKeyScopes = array_values(array_diff($this->newKeyScopes, [$scope])); + } else { + $this->newKeyScopes[] = $scope; + } + } + + public function render() + { + return view('mcp::web.api-key-manager', [ + 'keys' => $this->workspace->apiKeys()->orderByDesc('created_at')->get(), + ]); + } +} diff --git a/src/php/src/Website/Mcp/View/Modal/Dashboard.php b/src/php/src/Website/Mcp/View/Modal/Dashboard.php new file mode 100644 index 0000000..1138fda --- /dev/null +++ b/src/php/src/Website/Mcp/View/Modal/Dashboard.php @@ -0,0 +1,188 @@ +resetPage(); + } + + public function updatingTypeFilter(): void + { + $this->resetPage(); + } + + public function updatingStatusFilter(): void + { + $this->resetPage(); + } + + public function getVendorsProperty() + { + try { + return Vendor::active()->withCount(['todos', 'releases'])->get(); + } catch (\Illuminate\Database\QueryException $e) { + return collect(); + } + } + + public function getStatsProperty(): array + { + try { + return [ + 'total_vendors' => Vendor::active()->count(), + 'pending_todos' => UpstreamTodo::pending()->count(), + 'quick_wins' => UpstreamTodo::quickWins()->count(), + 'security_updates' => UpstreamTodo::pending()->where('type', 'security')->count(), + 'recent_releases' => \Mod\Uptelligence\Models\VersionRelease::recent(7)->count(), + 'in_progress' => UpstreamTodo::inProgress()->count(), + ]; + } catch (\Illuminate\Database\QueryException $e) { + return [ + 'total_vendors' => 0, + 'pending_todos' => 0, + 'quick_wins' => 0, + 'security_updates' => 0, + 'recent_releases' => 0, + 'in_progress' => 0, + ]; + } + } + + public function getTodosProperty() + { + try { + $query = UpstreamTodo::with('vendor') + ->orderByDesc('priority') + ->orderBy('effort'); + + if ($this->vendorFilter) { + $query->where('vendor_id', $this->vendorFilter); + } + + if ($this->typeFilter) { + $query->where('type', $this->typeFilter); + } + + if ($this->statusFilter) { + $query->where('status', $this->statusFilter); + } + + if ($this->effortFilter) { + $query->where('effort', $this->effortFilter); + } + + if ($this->quickWinsOnly) { + $query->where('effort', 'low')->where('priority', '>=', 5); + } + + return $query->paginate(15); + } catch (\Illuminate\Database\QueryException $e) { + return new \Illuminate\Pagination\LengthAwarePaginator([], 0, 15); + } + } + + public function getRecentLogsProperty() + { + try { + return AnalysisLog::with('vendor') + ->latest() + ->limit(10) + ->get(); + } catch (\Illuminate\Database\QueryException $e) { + return collect(); + } + } + + public function getAssetsProperty() + { + try { + return Asset::active()->orderBy('type')->get(); + } catch (\Illuminate\Database\QueryException $e) { + return collect(); + } + } + + public function getPatternsProperty() + { + try { + return Pattern::active()->orderBy('category')->limit(6)->get(); + } catch (\Illuminate\Database\QueryException $e) { + return collect(); + } + } + + public function getAssetStatsProperty(): array + { + try { + return [ + 'total' => Asset::active()->count(), + 'updates_available' => Asset::active()->needsUpdate()->count(), + 'patterns' => Pattern::active()->count(), + ]; + } catch (\Illuminate\Database\QueryException $e) { + return [ + 'total' => 0, + 'updates_available' => 0, + 'patterns' => 0, + ]; + } + } + + public function markInProgress(int $todoId): void + { + $todo = UpstreamTodo::findOrFail($todoId); + $todo->markInProgress(); + } + + public function markPorted(int $todoId): void + { + $todo = UpstreamTodo::findOrFail($todoId); + $todo->markPorted(); + } + + public function markSkipped(int $todoId): void + { + $todo = UpstreamTodo::findOrFail($todoId); + $todo->markSkipped(); + } + + public function render() + { + return view('mcp::web.dashboard'); + } +} diff --git a/src/php/src/Website/Mcp/View/Modal/McpMetrics.php b/src/php/src/Website/Mcp/View/Modal/McpMetrics.php new file mode 100644 index 0000000..dc00b60 --- /dev/null +++ b/src/php/src/Website/Mcp/View/Modal/McpMetrics.php @@ -0,0 +1,90 @@ +metricsService = $metricsService; + } + + public function setDays(int $days): void + { + // Bound days to a reasonable range (1-90) + $this->days = min(max($days, 1), 90); + } + + public function setTab(string $tab): void + { + $this->activeTab = $tab; + } + + public function getOverviewProperty(): array + { + return app(McpMetricsService::class)->getOverview($this->days); + } + + public function getDailyTrendProperty(): array + { + return app(McpMetricsService::class)->getDailyTrend($this->days); + } + + public function getTopToolsProperty(): array + { + return app(McpMetricsService::class)->getTopTools($this->days, 10); + } + + public function getServerStatsProperty(): array + { + return app(McpMetricsService::class)->getServerStats($this->days); + } + + public function getRecentCallsProperty(): array + { + return app(McpMetricsService::class)->getRecentCalls(20); + } + + public function getErrorBreakdownProperty(): array + { + return app(McpMetricsService::class)->getErrorBreakdown($this->days); + } + + public function getToolPerformanceProperty(): array + { + return app(McpMetricsService::class)->getToolPerformance($this->days, 10); + } + + public function getHourlyDistributionProperty(): array + { + return app(McpMetricsService::class)->getHourlyDistribution(); + } + + public function getPlanActivityProperty(): array + { + return app(McpMetricsService::class)->getPlanActivity($this->days, 10); + } + + public function render() + { + return view('mcp::web.mcp-metrics'); + } +} diff --git a/src/php/src/Website/Mcp/View/Modal/McpPlayground.php b/src/php/src/Website/Mcp/View/Modal/McpPlayground.php new file mode 100644 index 0000000..fb8fbbc --- /dev/null +++ b/src/php/src/Website/Mcp/View/Modal/McpPlayground.php @@ -0,0 +1,358 @@ + 'required|string', + 'selectedTool' => 'required|string', + 'inputJson' => 'required|json', + ]; + + public function mount(): void + { + $this->loadServers(); + + if (! empty($this->servers)) { + $this->selectedServer = $this->servers[0]['id']; + $this->loadTools(); + } + } + + public function updatedSelectedServer(): void + { + $this->loadTools(); + $this->selectedTool = ''; + $this->inputJson = '{}'; + $this->lastResult = null; + $this->lastError = null; + } + + public function updatedSelectedTool(): void + { + // Pre-fill example parameters based on tool definition + $this->prefillParameters(); + $this->lastResult = null; + $this->lastError = null; + } + + public function execute(): void + { + $this->validate(); + + // Rate limit: 10 executions per minute per user/IP + $rateLimitKey = 'mcp-playground:'.$this->getRateLimitKey(); + if (RateLimiter::tooManyAttempts($rateLimitKey, 10)) { + $this->lastError = 'Too many requests. Please wait before trying again.'; + + return; + } + RateLimiter::hit($rateLimitKey, 60); + + $this->isExecuting = true; + $this->lastResult = null; + $this->lastError = null; + + try { + $params = json_decode($this->inputJson, true); + if (json_last_error() !== JSON_ERROR_NONE) { + $this->lastError = 'Invalid JSON: '.json_last_error_msg(); + + return; + } + + $startTime = microtime(true); + $result = $this->callTool($this->selectedServer, $this->selectedTool, $params); + $this->executionTime = (int) round((microtime(true) - $startTime) * 1000); + + if (isset($result['error'])) { + $this->lastError = $result['error']; + $this->lastResult = $result; + } else { + $this->lastResult = $result; + } + + } catch (\Exception $e) { + $this->lastError = $e->getMessage(); + } finally { + $this->isExecuting = false; + } + } + + /** + * Get rate limit key based on user or IP. + */ + protected function getRateLimitKey(): string + { + if (auth()->check()) { + return 'user:'.auth()->id(); + } + + return 'ip:'.request()->ip(); + } + + public function formatJson(): void + { + try { + $decoded = json_decode($this->inputJson, true); + if (json_last_error() === JSON_ERROR_NONE) { + $this->inputJson = json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + } catch (\Exception $e) { + // Ignore formatting errors + } + } + + protected function loadServers(): void + { + $registry = $this->loadRegistry(); + + $this->servers = collect($registry['servers'] ?? []) + ->map(fn ($ref) => $this->loadServerYaml($ref['id'])) + ->filter() + ->map(fn ($server) => [ + 'id' => $server['id'], + 'name' => $server['name'], + 'tagline' => $server['tagline'] ?? '', + 'tool_count' => count($server['tools'] ?? []), + ]) + ->values() + ->all(); + } + + protected function loadTools(): void + { + if (empty($this->selectedServer)) { + $this->tools = []; + + return; + } + + $server = $this->loadServerYaml($this->selectedServer); + + $this->tools = collect($server['tools'] ?? []) + ->map(fn ($tool) => [ + 'name' => $tool['name'], + 'purpose' => $tool['purpose'] ?? '', + 'parameters' => $tool['parameters'] ?? [], + ]) + ->values() + ->all(); + } + + protected function prefillParameters(): void + { + if (empty($this->selectedTool)) { + $this->inputJson = '{}'; + + return; + } + + $tool = collect($this->tools)->firstWhere('name', $this->selectedTool); + + if (! $tool || empty($tool['parameters'])) { + $this->inputJson = '{}'; + + return; + } + + // Build example params from parameter definitions + $params = []; + foreach ($tool['parameters'] as $paramName => $paramDef) { + if (is_array($paramDef)) { + $type = $paramDef['type'] ?? 'string'; + $default = $paramDef['default'] ?? null; + $required = $paramDef['required'] ?? false; + + if ($default !== null) { + $params[$paramName] = $default; + } elseif ($required) { + // Add placeholder + $params[$paramName] = match ($type) { + 'boolean' => false, + 'integer', 'number' => 0, + 'array' => [], + default => '', + }; + } + } + } + + $this->inputJson = json_encode($params, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + + protected function callTool(string $serverId, string $toolName, array $params): array + { + $server = $this->loadServerYaml($serverId); + + if (! $server) { + return ['error' => 'Server not found']; + } + + $connection = $server['connection'] ?? []; + $type = $connection['type'] ?? 'stdio'; + + if ($type !== 'stdio') { + return ['error' => "Connection type '{$type}' not supported in playground"]; + } + + $command = $connection['command'] ?? null; + $args = $connection['args'] ?? []; + $cwd = $this->resolveEnvVars($connection['cwd'] ?? getcwd()); + + if (! $command) { + return ['error' => 'No command configured for this server']; + } + + // Build MCP tool call request + $request = json_encode([ + 'jsonrpc' => '2.0', + 'method' => 'tools/call', + 'params' => [ + 'name' => $toolName, + 'arguments' => $params, + ], + 'id' => 1, + ]); + + try { + $startTime = microtime(true); + + $fullCommand = array_merge([$command], $args); + $process = new Process($fullCommand, $cwd); + $process->setInput($request); + $process->setTimeout(30); + + $process->run(); + + $duration = (int) round((microtime(true) - $startTime) * 1000); + $output = $process->getOutput(); + + // Log the tool call + McpToolCall::log( + serverId: $serverId, + toolName: $toolName, + params: $params, + success: $process->isSuccessful(), + durationMs: $duration, + errorMessage: $process->isSuccessful() ? null : $process->getErrorOutput(), + ); + + if (! $process->isSuccessful()) { + return [ + 'error' => 'Process failed', + 'exit_code' => $process->getExitCode(), + 'stderr' => $process->getErrorOutput(), + ]; + } + + // Parse JSON-RPC response + $lines = explode("\n", trim($output)); + foreach ($lines as $line) { + $response = json_decode($line, true); + if ($response) { + if (isset($response['error'])) { + return [ + 'error' => $response['error']['message'] ?? 'Unknown error', + 'code' => $response['error']['code'] ?? null, + 'data' => $response['error']['data'] ?? null, + ]; + } + if (isset($response['result'])) { + return $response['result']; + } + } + } + + return [ + 'error' => 'No valid response received', + 'raw_output' => $output, + ]; + + } catch (\Exception $e) { + return ['error' => $e->getMessage()]; + } + } + + protected function loadRegistry(): array + { + return Cache::remember('mcp:registry', 0, function () { + $path = resource_path('mcp/registry.yaml'); + if (! file_exists($path)) { + return ['servers' => []]; + } + + return Yaml::parseFile($path); + }); + } + + protected function loadServerYaml(string $id): ?array + { + // Sanitise server ID to prevent path traversal attacks + $id = basename($id, '.yaml'); + + // Validate ID format (alphanumeric with hyphens only) + if (! preg_match('/^[a-z0-9-]+$/', $id)) { + return null; + } + + $path = resource_path("mcp/servers/{$id}.yaml"); + if (! file_exists($path)) { + return null; + } + + return Yaml::parseFile($path); + } + + protected function resolveEnvVars(string $value): string + { + return preg_replace_callback('/\$\{([^}]+)\}/', function ($matches) { + $parts = explode(':-', $matches[1], 2); + $var = $parts[0]; + $default = $parts[1] ?? ''; + + return env($var, $default); + }, $value); + } + + public function render() + { + return view('mcp::web.mcp-playground'); + } +} diff --git a/src/php/src/Website/Mcp/View/Modal/Playground.php b/src/php/src/Website/Mcp/View/Modal/Playground.php new file mode 100644 index 0000000..38785fc --- /dev/null +++ b/src/php/src/Website/Mcp/View/Modal/Playground.php @@ -0,0 +1,293 @@ +loadServers(); + } + + public function loadServers(): void + { + try { + $registry = $this->loadRegistry(); + $this->servers = collect($registry['servers'] ?? []) + ->map(fn ($ref) => $this->loadServerSummary($ref['id'])) + ->filter() + ->values() + ->toArray(); + } catch (\Throwable $e) { + $this->error = 'Failed to load servers'; + $this->servers = []; + } + } + + public function updatedSelectedServer(): void + { + $this->error = null; + $this->selectedTool = ''; + $this->toolSchema = null; + $this->arguments = []; + $this->response = ''; + + if (! $this->selectedServer) { + $this->tools = []; + + return; + } + + try { + $server = $this->loadServerFull($this->selectedServer); + $this->tools = $server['tools'] ?? []; + } catch (\Throwable $e) { + $this->error = 'Failed to load server tools'; + $this->tools = []; + } + } + + public function updatedSelectedTool(): void + { + $this->error = null; + $this->arguments = []; + $this->response = ''; + + if (! $this->selectedTool) { + $this->toolSchema = null; + + return; + } + + try { + $this->toolSchema = collect($this->tools)->firstWhere('name', $this->selectedTool); + + // Pre-fill arguments with defaults + $params = $this->toolSchema['inputSchema']['properties'] ?? []; + foreach ($params as $name => $schema) { + $this->arguments[$name] = $schema['default'] ?? ''; + } + } catch (\Throwable $e) { + $this->error = 'Failed to load tool schema'; + $this->toolSchema = null; + } + } + + public function updatedApiKey(): void + { + // Clear key status when key changes + $this->keyStatus = null; + $this->keyInfo = null; + } + + public function validateKey(): void + { + $this->keyStatus = null; + $this->keyInfo = null; + + if (empty($this->apiKey)) { + $this->keyStatus = 'empty'; + + return; + } + + $key = ApiKey::findByPlainKey($this->apiKey); + + if (! $key) { + $this->keyStatus = 'invalid'; + + return; + } + + if ($key->isExpired()) { + $this->keyStatus = 'expired'; + + return; + } + + $this->keyStatus = 'valid'; + $this->keyInfo = [ + 'name' => $key->name, + 'scopes' => $key->scopes, + 'server_scopes' => $key->getAllowedServers(), + 'workspace' => $key->workspace?->name ?? 'Unknown', + 'last_used' => $key->last_used_at?->diffForHumans() ?? 'Never', + ]; + } + + public function isAuthenticated(): bool + { + return auth()->check(); + } + + public function execute(): void + { + if (! $this->selectedServer || ! $this->selectedTool) { + return; + } + + // Rate limit: 10 executions per minute per user/IP + $rateLimitKey = 'mcp-playground-api:'.$this->getRateLimitKey(); + if (RateLimiter::tooManyAttempts($rateLimitKey, 10)) { + $this->error = 'Too many requests. Please wait before trying again.'; + + return; + } + RateLimiter::hit($rateLimitKey, 60); + + $this->loading = true; + $this->response = ''; + $this->error = null; + + try { + // Filter out empty arguments + $args = array_filter($this->arguments, fn ($v) => $v !== '' && $v !== null); + + // Convert numeric strings to numbers where appropriate + foreach ($args as $key => $value) { + if (is_numeric($value)) { + $args[$key] = str_contains($value, '.') ? (float) $value : (int) $value; + } + if ($value === 'true') { + $args[$key] = true; + } + if ($value === 'false') { + $args[$key] = false; + } + } + + $payload = [ + 'server' => $this->selectedServer, + 'tool' => $this->selectedTool, + 'arguments' => $args, + ]; + + // If we have an API key, make a real request + if (! empty($this->apiKey) && $this->keyStatus === 'valid') { + $response = Http::withToken($this->apiKey) + ->timeout(30) + ->post(config('app.url').'/api/v1/mcp/tools/call', $payload); + + $this->response = json_encode([ + 'status' => $response->status(), + 'response' => $response->json(), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + return; + } + + // Otherwise, just show request format + $this->response = json_encode([ + 'request' => $payload, + 'note' => 'Add an API key above to execute this request live.', + 'curl' => sprintf( + "curl -X POST %s/api/v1/mcp/tools/call \\\n -H \"Authorization: Bearer YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '%s'", + config('app.url'), + json_encode($payload, JSON_UNESCAPED_SLASHES) + ), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } catch (\Throwable $e) { + $this->response = json_encode([ + 'error' => $e->getMessage(), + ], JSON_PRETTY_PRINT); + } finally { + $this->loading = false; + } + } + + public function render() + { + $isAuthenticated = $this->isAuthenticated(); + $workspace = $isAuthenticated ? auth()->user()?->defaultHostWorkspace() : null; + + return view('mcp::web.playground', [ + 'isAuthenticated' => $isAuthenticated, + 'workspace' => $workspace, + ]); + } + + protected function loadRegistry(): array + { + $path = resource_path('mcp/registry.yaml'); + + return file_exists($path) ? Yaml::parseFile($path) : ['servers' => []]; + } + + protected function loadServerFull(string $id): ?array + { + // Sanitise server ID to prevent path traversal attacks + $id = basename($id, '.yaml'); + + // Validate ID format (alphanumeric with hyphens only) + if (! preg_match('/^[a-z0-9-]+$/', $id)) { + return null; + } + + $path = resource_path("mcp/servers/{$id}.yaml"); + + return file_exists($path) ? Yaml::parseFile($path) : null; + } + + /** + * Get rate limit key based on user or IP. + */ + protected function getRateLimitKey(): string + { + if (auth()->check()) { + return 'user:'.auth()->id(); + } + + return 'ip:'.request()->ip(); + } + + protected function loadServerSummary(string $id): ?array + { + $server = $this->loadServerFull($id); + if (! $server) { + return null; + } + + return [ + 'id' => $server['id'], + 'name' => $server['name'], + 'tagline' => $server['tagline'] ?? '', + ]; + } +} diff --git a/src/php/src/Website/Mcp/View/Modal/RequestLog.php b/src/php/src/Website/Mcp/View/Modal/RequestLog.php new file mode 100644 index 0000000..cac4380 --- /dev/null +++ b/src/php/src/Website/Mcp/View/Modal/RequestLog.php @@ -0,0 +1,100 @@ +resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function selectRequest(int $id): void + { + $workspace = auth()->user()?->defaultHostWorkspace(); + + // Only allow selecting requests that belong to the user's workspace + $request = McpApiRequest::query() + ->when($workspace, fn ($q) => $q->forWorkspace($workspace->id)) + ->find($id); + + if (! $request) { + $this->selectedRequestId = null; + $this->selectedRequest = null; + + return; + } + + $this->selectedRequestId = $id; + $this->selectedRequest = $request; + } + + public function closeDetail(): void + { + $this->selectedRequestId = null; + $this->selectedRequest = null; + } + + public function render() + { + $workspace = auth()->user()?->defaultHostWorkspace(); + + $query = McpApiRequest::query() + ->orderByDesc('created_at'); + + if ($workspace) { + $query->forWorkspace($workspace->id); + } + + if ($this->serverFilter) { + $query->forServer($this->serverFilter); + } + + if ($this->statusFilter === 'success') { + $query->successful(); + } elseif ($this->statusFilter === 'failed') { + $query->failed(); + } + + $requests = $query->paginate(20); + + // Get unique servers for filter dropdown + $servers = McpApiRequest::query() + ->when($workspace, fn ($q) => $q->forWorkspace($workspace->id)) + ->distinct() + ->pluck('server_id') + ->filter() + ->values(); + + return view('mcp::web.request-log', [ + 'requests' => $requests, + 'servers' => $servers, + ]); + } +} diff --git a/src/php/src/Website/Mcp/View/Modal/UnifiedSearch.php b/src/php/src/Website/Mcp/View/Modal/UnifiedSearch.php new file mode 100644 index 0000000..4130fe4 --- /dev/null +++ b/src/php/src/Website/Mcp/View/Modal/UnifiedSearch.php @@ -0,0 +1,82 @@ +searchService = $searchService; + } + + public function updatedQuery(): void + { + // Debounce handled by wire:model.debounce + } + + public function toggleType(string $type): void + { + if (in_array($type, $this->selectedTypes)) { + $this->selectedTypes = array_values(array_diff($this->selectedTypes, [$type])); + } else { + $this->selectedTypes[] = $type; + } + } + + public function clearFilters(): void + { + $this->selectedTypes = []; + } + + public function getResultsProperty(): Collection + { + if (strlen($this->query) < 2) { + return collect(); + } + + return $this->searchService->search($this->query, $this->selectedTypes, $this->limit); + } + + public function getTypesProperty(): array + { + return UnifiedSearchService::getTypes(); + } + + public function getResultCountsByTypeProperty(): array + { + if (strlen($this->query) < 2) { + return []; + } + + $allResults = $this->searchService->search($this->query, [], 200); + + return $allResults->groupBy('type')->map->count()->toArray(); + } + + public function render() + { + return view('mcp::web.unified-search'); + } +} diff --git a/src/php/storage/app/.gitignore b/src/php/storage/app/.gitignore new file mode 100644 index 0000000..8f4803c --- /dev/null +++ b/src/php/storage/app/.gitignore @@ -0,0 +1,3 @@ +* +!public/ +!.gitignore diff --git a/src/php/storage/app/public/.gitignore b/src/php/storage/app/public/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/src/php/storage/app/public/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/php/storage/framework/.gitignore b/src/php/storage/framework/.gitignore new file mode 100644 index 0000000..05c4471 --- /dev/null +++ b/src/php/storage/framework/.gitignore @@ -0,0 +1,9 @@ +compiled.php +config.php +down +events.scanned.php +maintenance.php +routes.php +routes.scanned.php +schedule-* +services.json diff --git a/src/php/storage/framework/cache/.gitignore b/src/php/storage/framework/cache/.gitignore new file mode 100644 index 0000000..01e4a6c --- /dev/null +++ b/src/php/storage/framework/cache/.gitignore @@ -0,0 +1,3 @@ +* +!data/ +!.gitignore diff --git a/src/php/storage/framework/cache/data/.gitignore b/src/php/storage/framework/cache/data/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/src/php/storage/framework/cache/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/php/storage/framework/sessions/.gitignore b/src/php/storage/framework/sessions/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/src/php/storage/framework/sessions/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/php/storage/framework/testing/.gitignore b/src/php/storage/framework/testing/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/src/php/storage/framework/testing/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/php/storage/framework/views/.gitignore b/src/php/storage/framework/views/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/src/php/storage/framework/views/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/php/storage/logs/.gitignore b/src/php/storage/logs/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/src/php/storage/logs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/php/tailwind.config.js b/src/php/tailwind.config.js new file mode 100644 index 0000000..26e1310 --- /dev/null +++ b/src/php/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./resources/**/*.blade.php", + "./resources/**/*.js", + ], + theme: { + extend: {}, + }, + plugins: [], +}; diff --git a/src/php/tests/Feature/.gitkeep b/src/php/tests/Feature/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/php/tests/Pest.php b/src/php/tests/Pest.php new file mode 100644 index 0000000..4feefee --- /dev/null +++ b/src/php/tests/Pest.php @@ -0,0 +1,41 @@ +in('Feature', 'Unit', '../src/Mcp/Tests/Unit'); + +/* +|-------------------------------------------------------------------------- +| Database Refresh +|-------------------------------------------------------------------------- +| +| Apply RefreshDatabase to Feature tests that need a clean database state. +| Unit tests typically don't require database access. +| +*/ + +uses(RefreshDatabase::class)->in('Feature', '../src/Mcp/Tests/Unit'); diff --git a/src/php/tests/TestCase.php b/src/php/tests/TestCase.php new file mode 100644 index 0000000..fe1ffc2 --- /dev/null +++ b/src/php/tests/TestCase.php @@ -0,0 +1,10 @@ +isValid('SELECT * FROM users'))->toBeTrue(); + expect($validator->isValid('SELECT id, name FROM users'))->toBeTrue(); + expect($validator->isValid('SELECT `id`, `name` FROM `users`'))->toBeTrue(); + }); + + it('allows SELECT with WHERE clause', function () { + $validator = new SqlQueryValidator(); + + expect($validator->isValid("SELECT * FROM users WHERE id = 1"))->toBeTrue(); + expect($validator->isValid("SELECT * FROM users WHERE name = 'John'"))->toBeTrue(); + expect($validator->isValid("SELECT * FROM users WHERE id = 1 AND status = 'active'"))->toBeTrue(); + expect($validator->isValid("SELECT * FROM users WHERE id = 1 OR id = 2"))->toBeTrue(); + }); + + it('allows SELECT with ORDER BY', function () { + $validator = new SqlQueryValidator(); + + expect($validator->isValid('SELECT * FROM users ORDER BY name'))->toBeTrue(); + expect($validator->isValid('SELECT * FROM users ORDER BY name ASC'))->toBeTrue(); + expect($validator->isValid('SELECT * FROM users ORDER BY name DESC'))->toBeTrue(); + }); + + it('allows SELECT with LIMIT', function () { + $validator = new SqlQueryValidator(); + + expect($validator->isValid('SELECT * FROM users LIMIT 10'))->toBeTrue(); + expect($validator->isValid('SELECT * FROM users LIMIT 10, 20'))->toBeTrue(); + }); + + it('allows COUNT queries', function () { + $validator = new SqlQueryValidator(); + + expect($validator->isValid('SELECT COUNT(*) FROM users'))->toBeTrue(); + expect($validator->isValid("SELECT COUNT(*) FROM users WHERE status = 'active'"))->toBeTrue(); + }); + + it('allows queries with trailing semicolon', function () { + $validator = new SqlQueryValidator(); + + expect($validator->isValid('SELECT * FROM users;'))->toBeTrue(); + expect($validator->isValid('SELECT id FROM users WHERE id = 1;'))->toBeTrue(); + }); + }); + + describe('blocked data modification statements', function () { + it('blocks INSERT statements', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate('INSERT INTO users (name) VALUES ("test")')) + ->toThrow(ForbiddenQueryException::class); + + expect(fn () => $validator->validate('INSERT users SET name = "test"')) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks UPDATE statements', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate('UPDATE users SET name = "test"')) + ->toThrow(ForbiddenQueryException::class); + + expect(fn () => $validator->validate('UPDATE users SET name = "test" WHERE id = 1')) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks DELETE statements', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate('DELETE FROM users')) + ->toThrow(ForbiddenQueryException::class); + + expect(fn () => $validator->validate('DELETE FROM users WHERE id = 1')) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks REPLACE statements', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate('REPLACE INTO users (id, name) VALUES (1, "test")')) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks TRUNCATE statements', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate('TRUNCATE TABLE users')) + ->toThrow(ForbiddenQueryException::class); + }); + }); + + describe('blocked schema modification statements', function () { + it('blocks DROP statements', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate('DROP TABLE users')) + ->toThrow(ForbiddenQueryException::class); + + expect(fn () => $validator->validate('DROP DATABASE mydb')) + ->toThrow(ForbiddenQueryException::class); + + expect(fn () => $validator->validate('DROP INDEX idx_name ON users')) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks ALTER statements', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate('ALTER TABLE users ADD column email VARCHAR(255)')) + ->toThrow(ForbiddenQueryException::class); + + expect(fn () => $validator->validate('ALTER TABLE users DROP column email')) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks CREATE statements', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate('CREATE TABLE test (id INT)')) + ->toThrow(ForbiddenQueryException::class); + + expect(fn () => $validator->validate('CREATE INDEX idx ON users (name)')) + ->toThrow(ForbiddenQueryException::class); + + expect(fn () => $validator->validate('CREATE DATABASE newdb')) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks RENAME statements', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate('RENAME TABLE users TO customers')) + ->toThrow(ForbiddenQueryException::class); + }); + }); + + describe('blocked permission and admin statements', function () { + it('blocks GRANT statements', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate('GRANT SELECT ON users TO user@localhost')) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks REVOKE statements', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate('REVOKE SELECT ON users FROM user@localhost')) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks FLUSH statements', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate('FLUSH PRIVILEGES')) + ->toThrow(ForbiddenQueryException::class); + + expect(fn () => $validator->validate('FLUSH TABLES')) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks KILL statements', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate('KILL 12345')) + ->toThrow(ForbiddenQueryException::class); + + expect(fn () => $validator->validate('KILL QUERY 12345')) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks SET statements', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate('SET GLOBAL max_connections = 500')) + ->toThrow(ForbiddenQueryException::class); + + expect(fn () => $validator->validate('SET SESSION sql_mode = ""')) + ->toThrow(ForbiddenQueryException::class); + }); + }); + + describe('blocked execution statements', function () { + it('blocks EXECUTE statements', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate('EXECUTE prepared_stmt')) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks PREPARE statements', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate('PREPARE stmt FROM "SELECT * FROM users"')) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks CALL statements', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate('CALL stored_procedure()')) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks DEALLOCATE statements', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate('DEALLOCATE PREPARE stmt')) + ->toThrow(ForbiddenQueryException::class); + }); + }); + + describe('blocked file operations', function () { + it('blocks INTO OUTFILE', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate("SELECT * FROM users INTO OUTFILE '/tmp/users.csv'")) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks INTO DUMPFILE', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate("SELECT * FROM users INTO DUMPFILE '/tmp/dump.txt'")) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks LOAD_FILE', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate("SELECT LOAD_FILE('/etc/passwd')")) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks LOAD DATA', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate("LOAD DATA INFILE '/tmp/data.csv' INTO TABLE users")) + ->toThrow(ForbiddenQueryException::class); + }); + }); + + describe('SQL injection prevention - UNION attacks', function () { + it('blocks basic UNION injection', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate("SELECT * FROM users WHERE id = 1 UNION SELECT * FROM passwords")) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks UNION ALL injection', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate("SELECT * FROM users WHERE id = 1 UNION ALL SELECT password FROM users")) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks UNION with NULL padding', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate("SELECT id, name FROM users WHERE id = 1 UNION SELECT NULL, password FROM admin")) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks UNION with comment obfuscation', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate("SELECT * FROM users WHERE id = 1 UN/**/ION SELECT * FROM admin")) + ->toThrow(ForbiddenQueryException::class); + + expect(fn () => $validator->validate("SELECT * FROM users WHERE id = 1 /*!UNION*/ SELECT * FROM admin")) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks UNION with case variation', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate("SELECT * FROM users WHERE id = 1 UnIoN SELECT * FROM admin")) + ->toThrow(ForbiddenQueryException::class); + + expect(fn () => $validator->validate("SELECT * FROM users WHERE id = 1 union SELECT * FROM admin")) + ->toThrow(ForbiddenQueryException::class); + }); + }); + + describe('SQL injection prevention - stacked queries', function () { + it('blocks semicolon-separated statements', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate("SELECT * FROM users; DROP TABLE users")) + ->toThrow(ForbiddenQueryException::class); + + expect(fn () => $validator->validate("SELECT * FROM users; DELETE FROM users")) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks stacked queries with comments', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate("SELECT * FROM users; -- DROP TABLE users")) + ->toThrow(ForbiddenQueryException::class); + + expect(fn () => $validator->validate("SELECT * FROM users;/* comment */DROP TABLE users")) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks multiple semicolons', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate("SELECT 1; SELECT 2; SELECT 3")) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks semicolon not at end', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate("SELECT * FROM users; ")) + ->toThrow(ForbiddenQueryException::class); + }); + }); + + describe('SQL injection prevention - time-based attacks', function () { + it('blocks SLEEP function', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate("SELECT * FROM users WHERE id = 1 AND SLEEP(5)")) + ->toThrow(ForbiddenQueryException::class); + + expect(fn () => $validator->validate("SELECT SLEEP(5)")) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks BENCHMARK function', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate("SELECT BENCHMARK(10000000, SHA1('test'))")) + ->toThrow(ForbiddenQueryException::class); + + expect(fn () => $validator->validate("SELECT * FROM users WHERE id = 1 AND BENCHMARK(1000000, MD5('x'))")) + ->toThrow(ForbiddenQueryException::class); + }); + }); + + describe('SQL injection prevention - encoding attacks', function () { + it('blocks hex-encoded strings', function () { + $validator = new SqlQueryValidator(); + + // 0x61646d696e = 'admin' + expect(fn () => $validator->validate("SELECT * FROM users WHERE name = 0x61646d696e")) + ->toThrow(ForbiddenQueryException::class); + + expect(fn () => $validator->validate("SELECT 0x44524f50205441424c4520757365727320")) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks CHAR function for string construction', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate("SELECT * FROM users WHERE name = CHAR(97, 100, 109, 105, 110)")) + ->toThrow(ForbiddenQueryException::class); + + expect(fn () => $validator->validate("SELECT CHAR(65)")) + ->toThrow(ForbiddenQueryException::class); + }); + }); + + describe('SQL injection prevention - subquery restrictions', function () { + it('blocks subqueries in WHERE clause', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate("SELECT * FROM users WHERE id = (SELECT admin_id FROM admins)")) + ->toThrow(ForbiddenQueryException::class); + + expect(fn () => $validator->validate("SELECT * FROM users WHERE id IN (SELECT id FROM admins)")) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks correlated subqueries', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate("SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM admins a WHERE a.user_id = u.id)")) + ->toThrow(ForbiddenQueryException::class); + }); + }); + + describe('SQL injection prevention - system table access', function () { + it('blocks INFORMATION_SCHEMA access', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate("SELECT * FROM INFORMATION_SCHEMA.TABLES")) + ->toThrow(ForbiddenQueryException::class); + + expect(fn () => $validator->validate("SELECT table_name FROM information_schema.columns")) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks mysql system database access', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate("SELECT * FROM mysql.user")) + ->toThrow(ForbiddenQueryException::class); + + expect(fn () => $validator->validate("SELECT host, user FROM mysql.db")) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks performance_schema access', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate("SELECT * FROM performance_schema.threads")) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks sys schema access', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate("SELECT * FROM sys.session")) + ->toThrow(ForbiddenQueryException::class); + }); + }); + + describe('SQL injection prevention - comment obfuscation', function () { + it('blocks inline comment keyword obfuscation', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate("SEL/**/ECT * FROM users")) + ->toThrow(ForbiddenQueryException::class); + + expect(fn () => $validator->validate("SELECT * FROM users WHERE id = 1 OR/**/1=1")) + ->toThrow(ForbiddenQueryException::class); + }); + + it('blocks MySQL conditional comments with harmful content', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate("/*!50000 DROP TABLE users */")) + ->toThrow(ForbiddenQueryException::class); + }); + }); + + describe('query structure validation', function () { + it('requires query to start with SELECT', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate("SHOW TABLES")) + ->toThrow(ForbiddenQueryException::class); + + expect(fn () => $validator->validate("DESCRIBE users")) + ->toThrow(ForbiddenQueryException::class); + + expect(fn () => $validator->validate("EXPLAIN SELECT * FROM users")) + ->toThrow(ForbiddenQueryException::class); + }); + + it('validates query does not start with non-SELECT', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate(" UPDATE users SET name = 'test'")) + ->toThrow(ForbiddenQueryException::class); + }); + }); + + describe('whitelist configuration', function () { + it('can disable whitelist checking', function () { + $validator = new SqlQueryValidator(useWhitelist: false); + + // This complex query would fail whitelist but passes without it + // (still blocked by other checks, but testing the flag works) + expect($validator->isValid('SELECT * FROM users'))->toBeTrue(); + }); + + it('can add custom whitelist patterns', function () { + $validator = new SqlQueryValidator(); + + // Add pattern for JOINs which aren't in default whitelist + $validator->addWhitelistPattern('/^\s*SELECT\s+.+\s+FROM\s+\w+\s+JOIN\s+\w+/i'); + + // Now JOIN queries should work (if they pass other checks) + // Note: The default whitelist may still reject, testing the method works + expect($validator)->toBeInstanceOf(SqlQueryValidator::class); + }); + + it('can replace entire whitelist', function () { + $validator = new SqlQueryValidator(); + + $validator->setWhitelist([ + '/^\s*SELECT\s+1\s*;?\s*$/i', + ]); + + expect($validator->isValid('SELECT 1'))->toBeTrue(); + expect($validator->isValid('SELECT * FROM users'))->toBeFalse(); + }); + }); + + describe('exception details', function () { + it('includes query in exception for blocked keyword', function () { + $validator = new SqlQueryValidator(); + $query = 'DROP TABLE users'; + + try { + $validator->validate($query); + test()->fail('Expected ForbiddenQueryException'); + } catch (ForbiddenQueryException $e) { + expect($e->query)->toBe($query); + expect($e->reason)->toContain('DROP'); + } + }); + + it('includes reason for structural issues', function () { + $validator = new SqlQueryValidator(); + $query = 'SHOW TABLES'; + + try { + $validator->validate($query); + test()->fail('Expected ForbiddenQueryException'); + } catch (ForbiddenQueryException $e) { + expect($e->reason)->toContain('SELECT'); + } + }); + + it('includes reason for whitelist failure', function () { + $validator = new SqlQueryValidator(); + // Complex query that passes keyword checks but fails whitelist + $query = 'SELECT @@version'; + + try { + $validator->validate($query); + test()->fail('Expected ForbiddenQueryException'); + } catch (ForbiddenQueryException $e) { + expect($e->reason)->toContain('pattern'); + } + }); + }); + + describe('edge cases', function () { + it('handles empty query', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate('')) + ->toThrow(ForbiddenQueryException::class); + }); + + it('handles whitespace-only query', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate(' ')) + ->toThrow(ForbiddenQueryException::class); + }); + + it('normalises excessive whitespace', function () { + $validator = new SqlQueryValidator(); + + expect($validator->isValid("SELECT * FROM users"))->toBeTrue(); + expect($validator->isValid("SELECT\n*\nFROM\nusers"))->toBeTrue(); + expect($validator->isValid("SELECT\t*\tFROM\tusers"))->toBeTrue(); + }); + + it('is case insensitive for keywords', function () { + $validator = new SqlQueryValidator(); + + expect(fn () => $validator->validate('drop TABLE users')) + ->toThrow(ForbiddenQueryException::class); + + expect(fn () => $validator->validate('DrOp TaBlE users')) + ->toThrow(ForbiddenQueryException::class); + }); + + it('handles queries with backtick-quoted identifiers', function () { + $validator = new SqlQueryValidator(); + + expect($validator->isValid('SELECT `id`, `name` FROM `users`'))->toBeTrue(); + }); + + it('handles queries with single-quoted strings', function () { + $validator = new SqlQueryValidator(); + + expect($validator->isValid("SELECT * FROM users WHERE name = 'O''Brien'"))->toBeTrue(); + }); + + it('handles queries with double-quoted strings', function () { + $validator = new SqlQueryValidator(); + + expect($validator->isValid('SELECT * FROM users WHERE name = "John"'))->toBeTrue(); + }); + }); + + describe('boolean-based injection prevention', function () { + it('allows legitimate OR conditions in WHERE', function () { + $validator = new SqlQueryValidator(); + + // Legitimate use + expect($validator->isValid("SELECT * FROM users WHERE id = 1 OR id = 2"))->toBeTrue(); + }); + + it('blocks dangerous patterns even within valid structure', function () { + $validator = new SqlQueryValidator(); + + // These contain hex encoding which is always blocked + expect(fn () => $validator->validate("SELECT * FROM users WHERE name = 0x41")) + ->toThrow(ForbiddenQueryException::class); + }); + }); +}); diff --git a/src/php/vite.config.js b/src/php/vite.config.js new file mode 100644 index 0000000..421b569 --- /dev/null +++ b/src/php/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; + +export default defineConfig({ + plugins: [ + laravel({ + input: ['resources/css/app.css', 'resources/js/app.js'], + refresh: true, + }), + ], +});