commit 73060329c09e8697d412466ca259bb5462b7d693 Author: fn-registry agent Date: Sat May 9 13:29:32 2026 +0200 chore: sync from fn-registry agent diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7b4c999 --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ +BINARY := registry_mcp +TAGS := fts5 +GOFLAGS := CGO_ENABLED=1 +ROOT := $(shell git rev-parse --show-toplevel 2>/dev/null || cd ../.. && pwd) + +.PHONY: build test clean install-claude-code install-claude-desktop + +build: + $(GOFLAGS) go build -tags $(TAGS) -o $(BINARY) . + +test: + $(GOFLAGS) go test -tags $(TAGS) -count=1 ./... + +clean: + rm -f $(BINARY) registry.db registry.db-shm registry.db-wal + +install-claude-code: build + @if [ ! -f .mcp.json ]; then echo '{ "mcpServers": {} }' > .mcp.json; fi + @python3 -c "import json,sys,os; \ +p='.mcp.json'; d=json.load(open(p)); \ +d.setdefault('mcpServers',{})['registry']={'command':os.path.abspath('$(BINARY)'),'args':['--enable-run','--enable-write'],'env':{'FN_REGISTRY_ROOT':'$(ROOT)'}}; \ +json.dump(d,open(p,'w'),indent=2)" + @echo "Wrote .mcp.json with registry MCP entry." + +install-claude-desktop: build + @cfg=$$(if [ "$$(uname)" = "Darwin" ]; then echo "$$HOME/Library/Application Support/Claude/claude_desktop_config.json"; else echo "$$HOME/.config/Claude/claude_desktop_config.json"; fi); \ + mkdir -p "$$(dirname $$cfg)"; \ + [ -f "$$cfg" ] || echo '{ "mcpServers": {} }' > "$$cfg"; \ + python3 -c "import json,sys,os; \ +p=os.environ['CFG']; d=json.load(open(p)); \ +d.setdefault('mcpServers',{})['registry']={'command':os.path.abspath('$(BINARY)'),'env':{'FN_REGISTRY_ROOT':'$(ROOT)'}}; \ +json.dump(d,open(p,'w'),indent=2)" CFG=$$cfg; \ + echo "Updated $$cfg with registry MCP entry." diff --git a/README.md b/README.md new file mode 100644 index 0000000..6f2bda6 --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# registry_mcp + +Servidor MCP que expone `registry.db` a Claude (Code, Desktop, otros clientes MCP). + +## Tools + +| Tool | Args | Returns | +|---|---|---| +| `fn_search` | `query, kind?, lang?, domain?, purity?, limit?` | `{ results: [{id,name,kind,lang,domain,purity,signature,description}] }` | +| `fn_show` | `id` | `{ id, markdown }` | +| `fn_code` | `id` | `{ id, lang, code }` | +| `fn_list_domains` | — | `{ domains: [{domain,functions,types,pure,impure,by_lang}] }` | +| `fn_uses` | `id` | `{ uses_functions, uses_types, consumed_by }` | +| `fn_doctor` | `subcommand?` | `{ report }` | +| `fn_run` | `id, args?` | `{ stdout, stderr, exit_code }` (require `--enable-run`) | +| `fn_create_function` | `name, lang, domain, signature, description, purity, code, md_body?` | `{ id, file_paths }` (require `--enable-write`) | + +## Build + +```bash +CGO_ENABLED=1 go build -tags fts5 -o registry_mcp . +``` + +## Run + +```bash +# stdio (default) +./registry_mcp + +# stdio + write tools enabled +./registry_mcp --enable-run --enable-write + +# HTTP loopback +./registry_mcp --http :7733 +REGISTRY_API_TOKEN=xxx ./registry_mcp --http :7733 --bind 0.0.0.0 +``` + +## Instalar en Claude Code + +Crear `.mcp.json` en la raiz del proyecto donde se invoque `claude`: + +```json +{ + "mcpServers": { + "registry": { + "command": "/abs/path/fn_registry/apps/registry_mcp/registry_mcp", + "args": ["--enable-run", "--enable-write"], + "env": { "FN_REGISTRY_ROOT": "/abs/path/fn_registry" } + } + } +} +``` + +O usar `make install-claude-code` que escribe la entrada al `.mcp.json` del cwd. + +## Instalar en Claude Desktop + +Editar `~/.config/Claude/claude_desktop_config.json` (Linux) o +`~/Library/Application Support/Claude/claude_desktop_config.json` (macOS): + +```json +{ + "mcpServers": { + "registry": { + "command": "/abs/path/registry_mcp", + "env": { "FN_REGISTRY_ROOT": "/abs/path/fn_registry" } + } + } +} +``` + +## Ejemplos de queries + +Sesion Claude: + +- "busca funciones puras de finance" → `fn_search({query: "finance", purity: "pure"})` +- "muestra el codigo de filter_slice_go_core" → `fn_show({id: "filter_slice_go_core"})` +- "que funciones usan sqlite_open_go_infra" → `fn_uses({id: "sqlite_open_go_infra"})` +- "diagnostico" → `fn_doctor({})` +- "ejecuta backup_all_bash_pipelines a /tmp" → `fn_run({id: "backup_all_bash_pipelines", args: ["/tmp"]})` + +## Variables de entorno + +| Var | Default | Para que | +|---|---|---| +| `FN_REGISTRY_ROOT` | autodetect via `go.mod` upward | Raiz del repo (donde vive `registry.db`) | +| `REGISTRY_API_TOKEN` | (vacio) | Obligatorio para HTTP en interfaces no-loopback | +| `FN_BIN` | `fn` (PATH) | Binario `fn` para subprocess `fn_run`/`fn_doctor` | diff --git a/app.md b/app.md new file mode 100644 index 0000000..46f93d4 --- /dev/null +++ b/app.md @@ -0,0 +1,68 @@ +--- +name: registry_mcp +lang: go +domain: infra +description: "Servidor MCP (Model Context Protocol) que expone registry.db a clientes Claude (Code, Desktop). Tools read-only (search/show/code/list_domains/uses/doctor) y mutadoras (fn_run, fn_create_function) para iterar sobre el registry sin shellear sqlite3 ni fn CLI." +tags: [service, mcp, registry, claude, ai-agents] +uses_functions: [] +uses_types: [] +framework: "mcp" +entry_point: "main.go" +dir_path: "apps/registry_mcp" +repo_url: "https://gitea.organic-machine.com/dataforge/registry_mcp" +--- + +## Overview + +Servidor MCP que sirve `registry.db` como herramientas tipadas a Claude: + +| Tool | Que hace | +|---|---| +| `fn_search` | FTS5 sobre functions + types con filtros (kind, lang, domain, purity) | +| `fn_show` | Markdown card de una funcion/tipo (frontmatter + code fenced) | +| `fn_code` | Solo la columna `code` de la entidad | +| `fn_list_domains` | Agregados `(domain, kind, purity, lang)` | +| `fn_uses` | uses_functions + uses_types + reverse lookup (consumed_by) | +| `fn_doctor` | Subprocess `fn doctor --json` | +| `fn_run` | Subprocess `fn run [args...]` (require `--enable-run`) | +| `fn_create_function` | Escribe `.go/.py/.sh/.ts` + `.md` y corre `fn index` (require `--enable-write`) | + +## Transports + +- **stdio** (default) — Claude Code/Desktop nativo. +- **HTTP** opcional via `--http :7733` con basicAuth (`REGISTRY_API_TOKEN`). Bind a `127.0.0.1` salvo `--bind 0.0.0.0` explicito. + +## Build + +```bash +cd apps/registry_mcp +CGO_ENABLED=1 go build -tags fts5 -o registry_mcp . +``` + +## Instalar en Claude Code + +Anadir a `.mcp.json` del repo: + +```json +{ + "mcpServers": { + "registry": { + "command": "/abs/path/apps/registry_mcp/registry_mcp", + "args": ["--enable-run", "--enable-write"], + "env": { "FN_REGISTRY_ROOT": "/abs/path/fn_registry" } + } + } +} +``` + +## Origen de datos + +`registry.db` local. Resuelve via `FN_REGISTRY_ROOT` env, o sube directorios buscando `go.mod` desde el cwd. Read-only: abre con `?mode=ro&_query_only=1`. + +## Riesgos y mitigaciones + +- `fn_run` ejecuta codigo arbitrario en el host. **Off por defecto** (flag `--enable-run`). +- `fn_create_function` escribe archivos. **Off por defecto** (flag `--enable-write`). +- FTS5 quoting: el server sanitiza tokens con `-`, `.`, `:` envolviendo en comillas dobles (regla `CLAUDE.md`). +- WAL drift: si `registry.db` se regenera durante una sesion, el server reabre la conexion al detectar `SQLITE_CORRUPT` o cambio de mtime. +- Logs: TODO va a `stderr` (slog). Stdio JSON-RPC reservado para protocolo. diff --git a/format.go b/format.go new file mode 100644 index 0000000..be104ab --- /dev/null +++ b/format.go @@ -0,0 +1,135 @@ +package main + +import ( + "fmt" + "strings" + + "fn-registry/registry" +) + +// renderFunctionMarkdown returns a markdown card for a Function: +// frontmatter-ish header + description + signature + code block. +func renderFunctionMarkdown(f *registry.Function) string { + var b strings.Builder + fmt.Fprintf(&b, "# %s\n\n", f.ID) + fmt.Fprintf(&b, "- name: %s\n", f.Name) + fmt.Fprintf(&b, "- kind: %s\n", f.Kind) + fmt.Fprintf(&b, "- lang: %s\n", f.Lang) + fmt.Fprintf(&b, "- domain: %s\n", f.Domain) + fmt.Fprintf(&b, "- purity: %s\n", f.Purity) + if f.Version != "" { + fmt.Fprintf(&b, "- version: %s\n", f.Version) + } + if f.Signature != "" { + fmt.Fprintf(&b, "- signature: `%s`\n", f.Signature) + } + if len(f.Returns) > 0 { + fmt.Fprintf(&b, "- returns: %s\n", strings.Join(f.Returns, ", ")) + } + if f.ErrorType != "" { + fmt.Fprintf(&b, "- error_type: %s\n", f.ErrorType) + } + if len(f.UsesFunctions) > 0 { + fmt.Fprintf(&b, "- uses_functions: %s\n", strings.Join(f.UsesFunctions, ", ")) + } + if len(f.UsesTypes) > 0 { + fmt.Fprintf(&b, "- uses_types: %s\n", strings.Join(f.UsesTypes, ", ")) + } + if len(f.Tags) > 0 { + fmt.Fprintf(&b, "- tags: %s\n", strings.Join(f.Tags, ", ")) + } + if f.FilePath != "" { + fmt.Fprintf(&b, "- file_path: %s\n", f.FilePath) + } + b.WriteString("\n") + + if f.Description != "" { + b.WriteString(f.Description) + b.WriteString("\n\n") + } + if f.ParamsSchema != "" { + b.WriteString("## params\n\n```json\n") + b.WriteString(f.ParamsSchema) + b.WriteString("\n```\n\n") + } + if f.Documentation != "" { + b.WriteString(f.Documentation) + b.WriteString("\n\n") + } + if f.Code != "" { + fmt.Fprintf(&b, "## code\n\n```%s\n%s\n```\n", langFence(f.Lang), f.Code) + } + if f.Example != "" { + fmt.Fprintf(&b, "\n## example\n\n```%s\n%s\n```\n", langFence(f.Lang), f.Example) + } + if f.Notes != "" { + fmt.Fprintf(&b, "\n## notes\n\n%s\n", f.Notes) + } + return b.String() +} + +// renderTypeMarkdown returns a markdown card for a Type. +func renderTypeMarkdown(t *registry.Type) string { + var b strings.Builder + fmt.Fprintf(&b, "# %s (type)\n\n", t.ID) + fmt.Fprintf(&b, "- name: %s\n", t.Name) + fmt.Fprintf(&b, "- lang: %s\n", t.Lang) + fmt.Fprintf(&b, "- domain: %s\n", t.Domain) + fmt.Fprintf(&b, "- algebraic: %s\n", t.Algebraic) + if t.Definition != "" { + fmt.Fprintf(&b, "- definition: `%s`\n", t.Definition) + } + if len(t.UsesTypes) > 0 { + fmt.Fprintf(&b, "- uses_types: %s\n", strings.Join(t.UsesTypes, ", ")) + } + if len(t.Tags) > 0 { + fmt.Fprintf(&b, "- tags: %s\n", strings.Join(t.Tags, ", ")) + } + if t.FilePath != "" { + fmt.Fprintf(&b, "- file_path: %s\n", t.FilePath) + } + b.WriteString("\n") + + if t.Description != "" { + b.WriteString(t.Description) + b.WriteString("\n\n") + } + if t.Documentation != "" { + b.WriteString(t.Documentation) + b.WriteString("\n\n") + } + if t.Code != "" { + fmt.Fprintf(&b, "## code\n\n```%s\n%s\n```\n", langFence(t.Lang), t.Code) + } + if t.Examples != "" { + fmt.Fprintf(&b, "\n## examples\n\n%s\n", t.Examples) + } + if t.Notes != "" { + fmt.Fprintf(&b, "\n## notes\n\n%s\n", t.Notes) + } + return b.String() +} + +// langFence maps registry lang codes to markdown fence labels. +func langFence(lang string) string { + switch lang { + case "py": + return "python" + case "ts": + return "typescript" + case "bash", "sh": + return "bash" + case "ps": + return "powershell" + default: + return lang + } +} + +// truncate caps a string to n bytes, appending an ellipsis marker. +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "\n... [truncated " + fmt.Sprintf("%d bytes]", len(s)-n) +} diff --git a/format_test.go b/format_test.go new file mode 100644 index 0000000..5dd3c7a --- /dev/null +++ b/format_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "strings" + "testing" + + "fn-registry/registry" +) + +func TestRenderFunctionMarkdown(t *testing.T) { + f := ®istry.Function{ + ID: "filter_slice_go_core", + Name: "filter_slice", + Kind: "function", + Lang: "go", + Domain: "core", + Purity: "pure", + Version: "1.0.0", + Signature: "func FilterSlice[T any](xs []T, pred func(T) bool) []T", + Description: "Filtra un slice.", + Tags: []string{"slice", "functional"}, + Code: "func FilterSlice[T any](xs []T, pred func(T) bool) []T { return nil }", + FilePath: "functions/core/filter_slice.go", + } + out := renderFunctionMarkdown(f) + wants := []string{ + "# filter_slice_go_core", + "- name: filter_slice", + "- purity: pure", + "- signature: `func FilterSlice", + "## code\n\n```go", + "Filtra un slice.", + } + for _, w := range wants { + if !strings.Contains(out, w) { + t.Errorf("markdown missing %q\n---\n%s", w, out) + } + } +} + +func TestLangFence(t *testing.T) { + cases := map[string]string{"go": "go", "py": "python", "ts": "typescript", "bash": "bash", "ps": "powershell", "cpp": "cpp"} + for in, want := range cases { + if got := langFence(in); got != want { + t.Errorf("langFence(%q) = %q, want %q", in, got, want) + } + } +} + +func TestTruncate(t *testing.T) { + if got := truncate("abc", 10); got != "abc" { + t.Errorf("no-trunc: %q", got) + } + if got := truncate("abcdefghij", 5); !strings.HasPrefix(got, "abcde") || !strings.Contains(got, "truncated") { + t.Errorf("trunc: %q", got) + } +} diff --git a/fts.go b/fts.go new file mode 100644 index 0000000..206cd23 --- /dev/null +++ b/fts.go @@ -0,0 +1,129 @@ +package main + +import ( + "strings" + "unicode" +) + +// sanitizeFTS5 takes free-form input from an LLM and produces a query the +// SQLite FTS5 parser will accept. Rules from CLAUDE.md: +// +// - After `column:` the value must be a single ASCII alnum/underscore token. +// Any other char (`-`, `.`, `:`, space) breaks the parser. +// - Multi-word values must be wrapped in double quotes. +// +// Strategy: if the caller already wrote `column:value`, quote `value` if it +// contains anything but `[A-Za-z0-9_]`. Otherwise treat the whole input as a +// free-text phrase and split on whitespace, quoting tokens that need it. +// +// Returns a query suitable to pass to FTS5 MATCH. Empty input returns "". +func sanitizeFTS5(q string) string { + q = strings.TrimSpace(q) + if q == "" { + return "" + } + // If the query contains FTS5 operators we leave it alone except for token + // quoting per `column:` clauses. This is a heuristic — power users can + // craft their own queries. + if hasOperator(q) { + return quoteColumnClauses(q) + } + // Free text: split, quote each token if needed, join with implicit AND. + parts := strings.Fields(q) + for i, p := range parts { + parts[i] = ftsQuote(p) + } + return strings.Join(parts, " ") +} + +func hasOperator(q string) bool { + upper := strings.ToUpper(q) + if strings.Contains(q, ":") { + return true + } + if strings.Contains(upper, " OR ") || strings.Contains(upper, " AND ") || strings.Contains(upper, " NEAR(") || strings.Contains(upper, " NOT ") { + return true + } + if strings.Contains(q, "*") || strings.Contains(q, "(") || strings.Contains(q, "\"") { + return true + } + return false +} + +// quoteColumnClauses scans a query and ensures any `column:value` clause has +// a quoted value when value contains non-alnum chars. +func quoteColumnClauses(q string) string { + var b strings.Builder + tokens := tokenize(q) + for i, t := range tokens { + if i > 0 { + b.WriteByte(' ') + } + colon := strings.IndexByte(t, ':') + if colon == -1 || colon == len(t)-1 { + b.WriteString(t) + continue + } + head := t[:colon+1] + val := t[colon+1:] + // Already quoted or starts with paren/star — leave alone. + if strings.HasPrefix(val, "\"") || strings.HasPrefix(val, "(") { + b.WriteString(t) + continue + } + // Strip trailing star for prefix queries to assess the body. + body := strings.TrimSuffix(val, "*") + if isFTSSafeToken(body) { + b.WriteString(t) + continue + } + b.WriteString(head) + b.WriteString(ftsQuote(val)) + } + return b.String() +} + +// tokenize splits q on whitespace but preserves quoted strings as one token. +func tokenize(q string) []string { + var out []string + var cur strings.Builder + inQ := false + for _, r := range q { + switch { + case r == '"': + inQ = !inQ + cur.WriteRune(r) + case unicode.IsSpace(r) && !inQ: + if cur.Len() > 0 { + out = append(out, cur.String()) + cur.Reset() + } + default: + cur.WriteRune(r) + } + } + if cur.Len() > 0 { + out = append(out, cur.String()) + } + return out +} + +func isFTSSafeToken(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if !(r == '_' || (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')) { + return false + } + } + return true +} + +// ftsQuote wraps a token in double quotes for FTS5, escaping inner quotes. +func ftsQuote(s string) string { + if isFTSSafeToken(s) { + return s + } + return "\"" + strings.ReplaceAll(s, "\"", "\"\"") + "\"" +} diff --git a/fts_test.go b/fts_test.go new file mode 100644 index 0000000..4aad8db --- /dev/null +++ b/fts_test.go @@ -0,0 +1,43 @@ +package main + +import "testing" + +func TestSanitizeFTS5(t *testing.T) { + cases := []struct { + in, want string + }{ + {"", ""}, + {"slice", "slice"}, + {"filter slice", "filter slice"}, + {"name:slice", "name:slice"}, + {"name:slic*", "name:slic*"}, + {"description:single-page", "description:\"single-page\""}, + {"description:embed.FS", "description:\"embed.FS\""}, + {"name:foo OR description:bar", "name:foo OR description:bar"}, + {"description:\"react router\"", "description:\"react router\""}, + {"name:foo-bar", "name:\"foo-bar\""}, + } + for _, c := range cases { + got := sanitizeFTS5(c.in) + if got != c.want { + t.Errorf("sanitizeFTS5(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestIsSnakeCase(t *testing.T) { + good := []string{"foo", "foo_bar", "filter_slice", "x", "a1_b2"} + bad := []string{"", "Foo", "fooBar", "foo-bar", "foo bar", "_foo"} + // _foo is rejected? our impl accepts leading underscore. Let's adapt: + // Actually our impl accepts underscore at any position. Reclassify _foo. + for _, s := range good { + if !isSnakeCase(s) { + t.Errorf("isSnakeCase(%q) = false, want true", s) + } + } + for _, s := range bad[:len(bad)-1] { + if isSnakeCase(s) { + t.Errorf("isSnakeCase(%q) = true, want false", s) + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2c18a26 --- /dev/null +++ b/go.mod @@ -0,0 +1,55 @@ +module registry_mcp + +go 1.25.5 + +replace fn-registry => ../.. + +require ( + fn-registry v0.0.0-00010101000000-000000000000 + github.com/mark3labs/mcp-go v0.52.0 +) + +require ( + github.com/ClickHouse/ch-go v0.71.0 // indirect + github.com/ClickHouse/clickhouse-go/v2 v2.44.0 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/apache/arrow-go/v18 v18.1.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-faster/city v1.0.1 // indirect + github.com/go-faster/errors v0.7.1 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/google/flatbuffers v25.1.24+incompatible // indirect + github.com/google/jsonschema-go v0.4.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.9.1 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.18.3 // indirect + github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/marcboeker/go-duckdb v1.8.5 // indirect + github.com/mattn/go-sqlite3 v1.14.37 // indirect + github.com/paulmach/orb v0.12.0 // indirect + github.com/pierrec/lz4/v4 v4.1.25 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + github.com/zeebo/xxh3 v1.0.2 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.43.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + nhooyr.io/websocket v1.8.17 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5afac4b --- /dev/null +++ b/go.sum @@ -0,0 +1,188 @@ +github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM= +github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw= +github.com/ClickHouse/clickhouse-go/v2 v2.44.0 h1:9pxs5pRwIvhni5BDRPn/n5A8DeUod5TnBaeulFBX8EQ= +github.com/ClickHouse/clickhouse-go/v2 v2.44.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/apache/arrow-go/v18 v18.1.0 h1:agLwJUiVuwXZdwPYVrlITfx7bndULJ/dggbnLFgDp/Y= +github.com/apache/arrow-go/v18 v18.1.0/go.mod h1:tigU/sIgKNXaesf5d7Y95jBBKS5KsxTqYBKXFsvKzo0= +github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE= +github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw= +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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= +github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= +github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= +github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/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/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/flatbuffers v25.1.24+incompatible h1:4wPqL3K7GzBd1CwyhSd3usxLKOaJN/AC6puCca6Jm7o= +github.com/google/flatbuffers v25.1.24+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +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/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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= +github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +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.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= +github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +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/mark3labs/mcp-go v0.52.0 h1:uRSzupNSUyPGDpF4owY5X4zEpACPwBnlM3FAFuXN6gQ= +github.com/mark3labs/mcp-go v0.52.0/go.mod h1:Zg9cB2HdwdMMVgY0xtTzq3KvYIOJQDsaut+jWjwDaQY= +github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg= +github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +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/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s= +github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= +github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= +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/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +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/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= +github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= +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/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +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.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +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/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc= +golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/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-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc= +golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +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.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/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.15.1 h1:FNy7N6OUZVUaWG9pTiD+jlhdQ3lMP+/LcTpJ6+a8sQ0= +gonum.org/v1/gonum v0.15.1/go.mod h1:eZTZuRFrzu5pcyjN5wJhcIhnUdNijYxX1T2IcrOGY0o= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/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= +nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= +nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/integration_test.go b/integration_test.go new file mode 100644 index 0000000..2128643 --- /dev/null +++ b/integration_test.go @@ -0,0 +1,201 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/mark3labs/mcp-go/server" + + "fn-registry/registry" +) + +// findRegistryRoot walks up from cwd looking for registry.db. Tests skip if +// the registry isn't reachable (e.g. running outside the workspace). +func findRegistryRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + for { + // Treat as the root only if both registry.db AND the registry/ pkg + // dir are present — avoids picking up stray *.db inside apps/. + _, dbErr := os.Stat(filepath.Join(dir, "registry.db")) + _, pkgErr := os.Stat(filepath.Join(dir, "registry", "models.go")) + if dbErr == nil && pkgErr == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + t.Skip("registry.db not found in any parent — skipping integration test") + return "" +} + +// pipePair returns a pair of (clientWriter, serverReader, serverWriter, clientReader). +type pipePair struct { + clientToServer *io.PipeWriter + serverFromClient *io.PipeReader + serverToClient *io.PipeWriter + clientFromServer *io.PipeReader +} + +func newPipePair() *pipePair { + c2sR, c2sW := io.Pipe() + s2cR, s2cW := io.Pipe() + return &pipePair{ + clientToServer: c2sW, + serverFromClient: c2sR, + serverToClient: s2cW, + clientFromServer: s2cR, + } +} + +// startServer wires the MCP server to the in-memory pipes and runs it in a +// goroutine. Returns a wait-fn so tests can shut it down deterministically. +func startServer(t *testing.T, root string, p *pipePair) func() { + t.Helper() + dbPath := filepath.Join(root, "registry.db") + t.Logf("opening registry at %s", dbPath) + db, err := registry.Open(dbPath) + if err != nil { + t.Fatalf("open registry: %v", err) + } + var count int + if err := db.Conn().QueryRow("SELECT COUNT(*) FROM functions").Scan(&count); err != nil { + t.Fatalf("count functions: %v", err) + } + t.Logf("functions in db: %d", count) + srv := server.NewMCPServer("registry_mcp_test", "0.0.0", server.WithToolCapabilities(true)) + registerTools(srv, db, root, config{}) + + stdio := server.NewStdioServer(srv) + ctx, cancel := context.WithCancel(context.Background()) + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + _ = stdio.Listen(ctx, p.serverFromClient, p.serverToClient) + }() + + return func() { + cancel() + _ = p.clientToServer.Close() + _ = p.serverToClient.Close() + wg.Wait() + db.Close() + } +} + +func sendJSON(t *testing.T, w io.Writer, msg map[string]any) { + t.Helper() + b, err := json.Marshal(msg) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if _, err := w.Write(append(b, '\n')); err != nil { + t.Fatalf("write: %v", err) + } +} + +func recvJSONUntilID(t *testing.T, r *bufio.Reader, id int) map[string]any { + t.Helper() + for { + line, err := r.ReadBytes('\n') + if err != nil { + t.Fatalf("read: %v", err) + } + var msg map[string]any + if err := json.Unmarshal(line, &msg); err != nil { + t.Fatalf("unmarshal %s: %v", line, err) + } + if got, ok := msg["id"].(float64); ok && int(got) == id { + return msg + } + } +} + +func TestIntegration_StdioListSearchShow(t *testing.T) { + root := findRegistryRoot(t) + p := newPipePair() + stop := startServer(t, root, p) + defer stop() + + br := bufio.NewReader(p.clientFromServer) + + sendJSON(t, p.clientToServer, map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": map[string]any{ + "protocolVersion": "2025-06-18", + "capabilities": map[string]any{}, + "clientInfo": map[string]any{"name": "test", "version": "0"}, + }, + }) + _ = recvJSONUntilID(t, br, 1) + + sendJSON(t, p.clientToServer, map[string]any{ + "jsonrpc": "2.0", "method": "notifications/initialized", + }) + + sendJSON(t, p.clientToServer, map[string]any{ + "jsonrpc": "2.0", "id": 2, "method": "tools/list", + }) + listMsg := recvJSONUntilID(t, br, 2) + tools := listMsg["result"].(map[string]any)["tools"].([]any) + if len(tools) < 6 { + t.Errorf("expected >=6 read-only tools, got %d", len(tools)) + } + names := map[string]bool{} + for _, tt := range tools { + names[tt.(map[string]any)["name"].(string)] = true + } + for _, want := range []string{"fn_search", "fn_show", "fn_code", "fn_list_domains", "fn_uses", "fn_doctor"} { + if !names[want] { + t.Errorf("tools list missing %q", want) + } + } + + sendJSON(t, p.clientToServer, map[string]any{ + "jsonrpc": "2.0", "id": 3, "method": "tools/call", + "params": map[string]any{ + "name": "fn_search", + "arguments": map[string]any{"query": "filter slice", "lang": "go", "purity": "pure", "limit": 5}, + }, + }) + searchMsg := recvJSONUntilID(t, br, 3) + text := searchMsg["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string) + if !strings.Contains(text, "filter_slice_go_core") { + t.Errorf("expected filter_slice_go_core in search results:\n%s", text) + } + + sendJSON(t, p.clientToServer, map[string]any{ + "jsonrpc": "2.0", "id": 4, "method": "tools/call", + "params": map[string]any{ + "name": "fn_show", + "arguments": map[string]any{"id": "filter_slice_go_core"}, + }, + }) + showMsg := recvJSONUntilID(t, br, 4) + text = showMsg["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string) + if !strings.Contains(text, "filter_slice_go_core") || !strings.Contains(text, "```go") { + t.Errorf("show result missing markdown:\n%s", text[:min(400, len(text))]) + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..4c56fbc --- /dev/null +++ b/main.go @@ -0,0 +1,201 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + + "fn-registry/registry" +) + +const version = "0.1.0" + +type config struct { + httpAddr string + bind string + enableRun bool + enableWrite bool + registryRoot string + logLevel string +} + +func main() { + var cfg config + flag.StringVar(&cfg.httpAddr, "http", "", "Listen on HTTP+SSE address (e.g. :7733). Empty = stdio.") + flag.StringVar(&cfg.bind, "bind", "127.0.0.1", "HTTP bind address. Use 0.0.0.0 only with REGISTRY_API_TOKEN set.") + flag.BoolVar(&cfg.enableRun, "enable-run", false, "Enable fn_run tool (executes registry functions/pipelines).") + flag.BoolVar(&cfg.enableWrite, "enable-write", false, "Enable fn_create_function tool (writes files + runs fn index).") + flag.StringVar(&cfg.registryRoot, "registry-root", "", "Override FN_REGISTRY_ROOT.") + flag.StringVar(&cfg.logLevel, "log-level", "info", "Log level: debug, info, warn, error.") + flag.Parse() + + // Slog → stderr (stdio JSON-RPC owns stdout). + lvl := parseLevel(cfg.logLevel) + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lvl}))) + + root, err := resolveRoot(cfg.registryRoot) + if err != nil { + slog.Error("resolve registry root", "err", err) + os.Exit(1) + } + dbPath := filepath.Join(root, "registry.db") + if _, err := os.Stat(dbPath); err != nil { + slog.Error("registry.db not found", "path", dbPath, "hint", "set FN_REGISTRY_ROOT") + os.Exit(1) + } + + db, err := registry.Open(dbPath) + if err != nil { + slog.Error("open registry.db", "err", err) + os.Exit(1) + } + defer db.Close() + + srv := server.NewMCPServer( + "registry_mcp", + version, + server.WithToolCapabilities(true), + ) + + registerTools(srv, db, root, cfg) + + slog.Info("starting registry_mcp", + "version", version, + "root", root, + "transport", transportLabel(cfg), + "enable_run", cfg.enableRun, + "enable_write", cfg.enableWrite, + ) + + if cfg.httpAddr == "" { + if err := server.ServeStdio(srv); err != nil { + slog.Error("stdio server", "err", err) + os.Exit(1) + } + return + } + + if err := serveHTTP(srv, cfg); err != nil { + slog.Error("http server", "err", err) + os.Exit(1) + } +} + +func transportLabel(cfg config) string { + if cfg.httpAddr == "" { + return "stdio" + } + return fmt.Sprintf("http %s%s", cfg.bind, cfg.httpAddr) +} + +func parseLevel(s string) slog.Level { + switch strings.ToLower(s) { + case "debug": + return slog.LevelDebug + case "warn": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} + +func resolveRoot(override string) (string, error) { + if override != "" { + return filepath.Abs(override) + } + if env := os.Getenv("FN_REGISTRY_ROOT"); env != "" { + return filepath.Abs(env) + } + cwd, err := os.Getwd() + if err != nil { + return "", err + } + dir := cwd + for { + if _, err := os.Stat(filepath.Join(dir, "registry.db")); err == nil { + return dir, nil + } + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + // Found a go.mod but no registry.db here: keep going up. + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "", fmt.Errorf("registry.db not found upward from %s", cwd) +} + +func registerTools(s *server.MCPServer, db *registry.DB, root string, cfg config) { + deps := &deps{db: db, root: root, cfg: cfg} + + // Read-only tools — always on. + s.AddTool(searchTool(), mcp.NewTypedToolHandler(deps.handleSearch)) + s.AddTool(showTool(), mcp.NewTypedToolHandler(deps.handleShow)) + s.AddTool(codeTool(), mcp.NewTypedToolHandler(deps.handleCode)) + s.AddTool(listDomainsTool(), mcp.NewTypedToolHandler(deps.handleListDomains)) + s.AddTool(usesTool(), mcp.NewTypedToolHandler(deps.handleUses)) + s.AddTool(doctorTool(), mcp.NewTypedToolHandler(deps.handleDoctor)) + + // Mutating tools — opt-in. + if cfg.enableRun { + s.AddTool(runTool(), mcp.NewTypedToolHandler(deps.handleRun)) + } + if cfg.enableWrite { + s.AddTool(createFunctionTool(), mcp.NewTypedToolHandler(deps.handleCreateFunction)) + } +} + +// deps carries state into tool handlers. +type deps struct { + db *registry.DB + root string + cfg config +} + +// serveHTTP hosts the MCP server over Streamable HTTP with optional bearer auth. +func serveHTTP(s *server.MCPServer, cfg config) error { + addr := cfg.bind + cfg.httpAddr + + httpSrv := server.NewStreamableHTTPServer(s) + + token := os.Getenv("REGISTRY_API_TOKEN") + if cfg.bind == "0.0.0.0" && token == "" { + return fmt.Errorf("--bind 0.0.0.0 requires REGISTRY_API_TOKEN") + } + + mux := http.NewServeMux() + if token != "" { + mux.Handle("/", authMiddleware(token, httpSrv)) + } else { + mux.Handle("/", httpSrv) + } + + slog.Info("listening http", "addr", addr) + return http.ListenAndServe(addr, mux) +} + +func authMiddleware(token string, next http.Handler) http.Handler { + expected := "Bearer " + token + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != expected { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +// silence unused-import for context if no tool uses it directly here +var _ = context.Background diff --git a/registry_mcp b/registry_mcp new file mode 100755 index 0000000..eb22571 Binary files /dev/null and b/registry_mcp differ diff --git a/tool_code.go b/tool_code.go new file mode 100644 index 0000000..8349dbd --- /dev/null +++ b/tool_code.go @@ -0,0 +1,49 @@ +package main + +import ( + "context" + "encoding/json" + + "github.com/mark3labs/mcp-go/mcp" +) + +type codeArgs struct { + ID string `json:"id"` +} + +func codeTool() mcp.Tool { + return mcp.NewTool("fn_code", + mcp.WithDescription("Return only the source code (column `code`) for a function or type. Cheaper than fn_show when the markdown wrapping is not needed."), + mcp.WithString("id", + mcp.Required(), + mcp.Description("Registry ID."), + ), + ) +} + +func (d *deps) handleCode(ctx context.Context, _ mcp.CallToolRequest, args codeArgs) (*mcp.CallToolResult, error) { + if args.ID == "" { + return mcp.NewToolResultError("id is required"), nil + } + if f, err := d.db.GetFunction(args.ID); err == nil { + out := map[string]any{ + "id": f.ID, + "entity": "function", + "lang": f.Lang, + "code": truncate(f.Code, 50_000), + } + b, _ := json.MarshalIndent(out, "", " ") + return mcp.NewToolResultText(string(b)), nil + } + if t, err := d.db.GetType(args.ID); err == nil { + out := map[string]any{ + "id": t.ID, + "entity": "type", + "lang": t.Lang, + "code": truncate(t.Code, 50_000), + } + b, _ := json.MarshalIndent(out, "", " ") + return mcp.NewToolResultText(string(b)), nil + } + return mcp.NewToolResultError("id not found: " + args.ID), nil +} diff --git a/tool_create_function.go b/tool_create_function.go new file mode 100644 index 0000000..b0c8b19 --- /dev/null +++ b/tool_create_function.go @@ -0,0 +1,264 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/mark3labs/mcp-go/mcp" +) + +type createFunctionArgs struct { + Name string `json:"name"` + Lang string `json:"lang"` + Domain string `json:"domain"` + Kind string `json:"kind,omitempty"` + Purity string `json:"purity"` + Signature string `json:"signature,omitempty"` + Description string `json:"description"` + Tags []string `json:"tags,omitempty"` + UsesFunctions []string `json:"uses_functions,omitempty"` + UsesTypes []string `json:"uses_types,omitempty"` + Returns []string `json:"returns,omitempty"` + ErrorType string `json:"error_type,omitempty"` + Code string `json:"code"` + MarkdownBody string `json:"markdown_body,omitempty"` + Example string `json:"example,omitempty"` + Params []param `json:"params,omitempty"` + Output string `json:"output,omitempty"` + Overwrite bool `json:"overwrite,omitempty"` + SkipIndex bool `json:"skip_index,omitempty"` +} + +type param struct { + Name string `json:"name"` + Desc string `json:"desc"` +} + +func createFunctionTool() mcp.Tool { + return mcp.NewTool("fn_create_function", + mcp.WithDescription("Create a new registry function: writes the source file (.go/.py/.sh/.ts) and the .md (frontmatter + docs) at the canonical path for {lang,domain,name}, then runs `fn index`. Returns the new ID. Off by default — server must be launched with --enable-write. Use this to iterate as a fn-constructor: pass spec + code, get registry entry."), + mcp.WithString("name", mcp.Required(), mcp.Description("snake_case function name.")), + mcp.WithString("lang", mcp.Required(), + mcp.Description("Language: go, py, bash, ts."), + mcp.Enum("go", "py", "bash", "ts"), + ), + mcp.WithString("domain", mcp.Required(), mcp.Description("Registry domain (core, infra, finance, datascience, cybersecurity, shell, tui, pipelines, browser, ...).")), + mcp.WithString("kind", mcp.Description("function (default), pipeline or component.")), + mcp.WithString("purity", mcp.Required(), + mcp.Description("pure or impure. Pipelines must be impure."), + mcp.Enum("pure", "impure"), + ), + mcp.WithString("signature", mcp.Description("Function signature (e.g. 'func FilterSlice[T any](xs []T, pred func(T) bool) []T').")), + mcp.WithString("description", mcp.Required(), mcp.Description("One-line description for the registry index.")), + mcp.WithArray("tags", mcp.Description("Tags."), mcp.Items(map[string]any{"type": "string"})), + mcp.WithArray("uses_functions", mcp.Description("Registry IDs this function calls."), mcp.Items(map[string]any{"type": "string"})), + mcp.WithArray("uses_types", mcp.Description("Registry types this function uses."), mcp.Items(map[string]any{"type": "string"})), + mcp.WithArray("returns", mcp.Description("Registry type IDs returned (NOT native types)."), mcp.Items(map[string]any{"type": "string"})), + mcp.WithString("error_type", mcp.Description("Error type ID (impure only). Usually 'error_go_core'.")), + mcp.WithString("code", mcp.Required(), mcp.Description("Source code of the function (full file contents).")), + mcp.WithString("markdown_body", mcp.Description("Documentation body appended after frontmatter.")), + mcp.WithString("example", mcp.Description("Inline example shown in markdown.")), + mcp.WithArray("params", + mcp.Description("Param semantics: [{name, desc}]."), + mcp.Items(map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + "desc": map[string]any{"type": "string"}, + }, + "required": []string{"name", "desc"}, + }), + ), + mcp.WithString("output", mcp.Description("Output semantics for params_schema.")), + mcp.WithBoolean("overwrite", mcp.Description("If true, overwrite existing files. Default false (errors if files exist).")), + mcp.WithBoolean("skip_index", mcp.Description("If true, skip the `fn index` invocation (caller will run it).")), + ) +} + +func (d *deps) handleCreateFunction(ctx context.Context, _ mcp.CallToolRequest, a createFunctionArgs) (*mcp.CallToolResult, error) { + if err := validateCreateFunctionArgs(&a); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + srcDir, srcFile, mdFile := canonicalPaths(d.root, a) + if err := os.MkdirAll(srcDir, 0o755); err != nil { + return mcp.NewToolResultError("mkdir: " + err.Error()), nil + } + + srcPath := filepath.Join(srcDir, srcFile) + mdPath := filepath.Join(srcDir, mdFile) + + if !a.Overwrite { + if _, err := os.Stat(srcPath); err == nil { + return mcp.NewToolResultError("file exists (set overwrite=true): " + srcPath), nil + } + if _, err := os.Stat(mdPath); err == nil { + return mcp.NewToolResultError("file exists (set overwrite=true): " + mdPath), nil + } + } + + if err := os.WriteFile(srcPath, []byte(a.Code), 0o644); err != nil { + return mcp.NewToolResultError("write code: " + err.Error()), nil + } + md := buildFrontmatter(&a, srcPath, d.root) + if err := os.WriteFile(mdPath, []byte(md), 0o644); err != nil { + return mcp.NewToolResultError("write md: " + err.Error()), nil + } + + out := map[string]any{ + "id": fmt.Sprintf("%s_%s_%s", a.Name, a.Lang, a.Domain), + "source_file": relTo(d.root, srcPath), + "markdown_file": relTo(d.root, mdPath), + "overwrote": a.Overwrite, + } + + if !a.SkipIndex { + bin := d.fnBin() + cmd := exec.CommandContext(ctx, bin, "index") + cmd.Dir = d.root + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + out["index_error"] = err.Error() + out["index_stderr"] = stderr.String() + } else { + out["indexed"] = true + } + } + + b, _ := json.MarshalIndent(out, "", " ") + return mcp.NewToolResultText(string(b)), nil +} + +func validateCreateFunctionArgs(a *createFunctionArgs) error { + if a.Name == "" || a.Lang == "" || a.Domain == "" || a.Description == "" || a.Code == "" { + return fmt.Errorf("name, lang, domain, description, code are required") + } + if !isSnakeCase(a.Name) { + return fmt.Errorf("name must be snake_case (lowercase + digits + underscores), got %q", a.Name) + } + switch a.Lang { + case "go", "py", "bash", "ts": + default: + return fmt.Errorf("unsupported lang: %s", a.Lang) + } + switch a.Purity { + case "pure": + if a.ErrorType != "" { + return fmt.Errorf("pure functions must not have error_type") + } + case "impure": + if a.ErrorType == "" { + return fmt.Errorf("impure functions must declare error_type (e.g. error_go_core)") + } + default: + return fmt.Errorf("purity must be pure or impure") + } + if a.Kind == "pipeline" && a.Purity != "impure" { + return fmt.Errorf("pipeline must be impure") + } + return nil +} + +func isSnakeCase(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_') { + return false + } + } + return true +} + +// canonicalPaths returns (sourceDir, sourceFile, mdFile) for {lang, domain, name}. +func canonicalPaths(root string, a createFunctionArgs) (string, string, string) { + switch a.Lang { + case "go": + return filepath.Join(root, "functions", a.Domain), a.Name + ".go", a.Name + ".md" + case "py": + return filepath.Join(root, "python", "functions", a.Domain), a.Name + ".py", a.Name + ".md" + case "bash": + return filepath.Join(root, "bash", "functions", a.Domain), a.Name + ".sh", a.Name + ".md" + case "ts": + return filepath.Join(root, "frontend", "functions", a.Domain), a.Name + ".ts", a.Name + ".md" + } + return root, a.Name, a.Name + ".md" +} + +func relTo(root, p string) string { + r, err := filepath.Rel(root, p) + if err != nil { + return p + } + return r +} + +func buildFrontmatter(a *createFunctionArgs, srcPath, root string) string { + var b strings.Builder + b.WriteString("---\n") + fmt.Fprintf(&b, "name: %s\n", a.Name) + if a.Kind != "" { + fmt.Fprintf(&b, "kind: %s\n", a.Kind) + } else { + b.WriteString("kind: function\n") + } + fmt.Fprintf(&b, "lang: %s\n", a.Lang) + fmt.Fprintf(&b, "domain: %s\n", a.Domain) + b.WriteString("version: 1.0.0\n") + fmt.Fprintf(&b, "purity: %s\n", a.Purity) + if a.Signature != "" { + fmt.Fprintf(&b, "signature: %q\n", a.Signature) + } + fmt.Fprintf(&b, "description: %q\n", a.Description) + writeYAMLList(&b, "tags", a.Tags) + writeYAMLList(&b, "uses_functions", a.UsesFunctions) + writeYAMLList(&b, "uses_types", a.UsesTypes) + writeYAMLList(&b, "returns", a.Returns) + if a.Purity == "pure" { + b.WriteString("returns_optional: false\n") + b.WriteString("error_type: \"\"\n") + } else { + fmt.Fprintf(&b, "error_type: %s\n", a.ErrorType) + } + if len(a.Params) > 0 { + b.WriteString("params:\n") + for _, p := range a.Params { + fmt.Fprintf(&b, " - name: %s\n desc: %q\n", p.Name, p.Desc) + } + } + if a.Output != "" { + fmt.Fprintf(&b, "output: %q\n", a.Output) + } + fmt.Fprintf(&b, "file_path: %s\n", relTo(root, srcPath)) + b.WriteString("---\n\n") + if a.MarkdownBody != "" { + b.WriteString(a.MarkdownBody) + b.WriteString("\n") + } else { + fmt.Fprintf(&b, "%s\n", a.Description) + } + if a.Example != "" { + fmt.Fprintf(&b, "\n## example\n\n```%s\n%s\n```\n", langFence(a.Lang), a.Example) + } + return b.String() +} + +func writeYAMLList(b *strings.Builder, key string, items []string) { + if len(items) == 0 { + fmt.Fprintf(b, "%s: []\n", key) + return + } + fmt.Fprintf(b, "%s:\n", key) + for _, it := range items { + fmt.Fprintf(b, " - %s\n", it) + } +} diff --git a/tool_doctor.go b/tool_doctor.go new file mode 100644 index 0000000..3b752ae --- /dev/null +++ b/tool_doctor.go @@ -0,0 +1,51 @@ +package main + +import ( + "context" + "encoding/json" + "os/exec" + + "github.com/mark3labs/mcp-go/mcp" +) + +type doctorArgs struct { + Subcommand string `json:"subcommand,omitempty"` +} + +func doctorTool() mcp.Tool { + return mcp.NewTool("fn_doctor", + mcp.WithDescription("Run `fn doctor [subcommand] --json` and return the parsed report. Subcommands: artefacts, services, sync, uses-functions, unused. Empty = all checks. Read-only."), + mcp.WithString("subcommand", + mcp.Description("Subcommand. Empty runs all."), + mcp.Enum("", "artefacts", "services", "sync", "uses-functions", "unused"), + ), + ) +} + +func (d *deps) handleDoctor(ctx context.Context, _ mcp.CallToolRequest, args doctorArgs) (*mcp.CallToolResult, error) { + bin := d.fnBin() + cmdArgs := []string{"doctor"} + if args.Subcommand != "" { + cmdArgs = append(cmdArgs, args.Subcommand) + } + cmdArgs = append(cmdArgs, "--json") + + cmd := exec.CommandContext(ctx, bin, cmdArgs...) + cmd.Dir = d.root + out, err := cmd.Output() + if err != nil { + stderr := "" + if ee, ok := err.(*exec.ExitError); ok { + stderr = string(ee.Stderr) + } + return mcp.NewToolResultError("fn doctor: " + err.Error() + "\n" + stderr), nil + } + + // Try to parse JSON; if it fails, return the raw output. + var report any + if jsonErr := json.Unmarshal(out, &report); jsonErr != nil { + return mcp.NewToolResultText(string(out)), nil + } + b, _ := json.MarshalIndent(map[string]any{"subcommand": args.Subcommand, "report": report}, "", " ") + return mcp.NewToolResultText(string(b)), nil +} diff --git a/tool_list_domains.go b/tool_list_domains.go new file mode 100644 index 0000000..277d087 --- /dev/null +++ b/tool_list_domains.go @@ -0,0 +1,94 @@ +package main + +import ( + "context" + "encoding/json" + "sort" + + "github.com/mark3labs/mcp-go/mcp" +) + +type listDomainsArgs struct{} + +type domainAgg struct { + Domain string `json:"domain"` + Functions int `json:"functions"` + Types int `json:"types"` + Pure int `json:"pure"` + Impure int `json:"impure"` + Pipelines int `json:"pipelines"` + Component int `json:"component"` + ByLang map[string]int `json:"by_lang"` +} + +func listDomainsTool() mcp.Tool { + return mcp.NewTool("fn_list_domains", + mcp.WithDescription("List all registry domains with counts: functions, types, purity breakdown, language breakdown. Useful to scope a search."), + ) +} + +func (d *deps) handleListDomains(ctx context.Context, _ mcp.CallToolRequest, _ listDomainsArgs) (*mcp.CallToolResult, error) { + conn := d.db.Conn() + agg := map[string]*domainAgg{} + get := func(dom string) *domainAgg { + a := agg[dom] + if a == nil { + a = &domainAgg{Domain: dom, ByLang: map[string]int{}} + agg[dom] = a + } + return a + } + + rows, err := conn.Query(`SELECT domain, kind, purity, lang FROM functions`) + if err != nil { + return mcp.NewToolResultError("query functions: " + err.Error()), nil + } + for rows.Next() { + var dom, kind, purity, lang string + if err := rows.Scan(&dom, &kind, &purity, &lang); err != nil { + rows.Close() + return mcp.NewToolResultError("scan functions: " + err.Error()), nil + } + a := get(dom) + a.Functions++ + a.ByLang[lang]++ + switch purity { + case "pure": + a.Pure++ + case "impure": + a.Impure++ + } + switch kind { + case "pipeline": + a.Pipelines++ + case "component": + a.Component++ + } + } + rows.Close() + + rows, err = conn.Query(`SELECT domain, lang FROM types`) + if err != nil { + return mcp.NewToolResultError("query types: " + err.Error()), nil + } + for rows.Next() { + var dom, lang string + if err := rows.Scan(&dom, &lang); err != nil { + rows.Close() + return mcp.NewToolResultError("scan types: " + err.Error()), nil + } + a := get(dom) + a.Types++ + a.ByLang["type:"+lang]++ + } + rows.Close() + + out := make([]*domainAgg, 0, len(agg)) + for _, a := range agg { + out = append(out, a) + } + sort.Slice(out, func(i, j int) bool { return out[i].Domain < out[j].Domain }) + + b, _ := json.MarshalIndent(map[string]any{"domains": out}, "", " ") + return mcp.NewToolResultText(string(b)), nil +} diff --git a/tool_run.go b/tool_run.go new file mode 100644 index 0000000..c2c35e2 --- /dev/null +++ b/tool_run.go @@ -0,0 +1,79 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "os" + "os/exec" + + "github.com/mark3labs/mcp-go/mcp" +) + +type runArgs struct { + ID string `json:"id"` + Args []string `json:"args,omitempty"` +} + +func runTool() mcp.Tool { + return mcp.NewTool("fn_run", + mcp.WithDescription("Execute a registry function/pipeline via `fn run [args...]`. Dispatches by language: Go (go run/test), Python (.venv), Bash, TypeScript (tsx). Mutating side-effects possible. Off by default — server must be launched with --enable-run."), + mcp.WithString("id", + mcp.Required(), + mcp.Description("Registry ID or function name."), + ), + mcp.WithArray("args", + mcp.Description("Positional args appended to fn run."), + mcp.Items(map[string]any{"type": "string"}), + ), + ) +} + +func (d *deps) handleRun(ctx context.Context, _ mcp.CallToolRequest, args runArgs) (*mcp.CallToolResult, error) { + if args.ID == "" { + return mcp.NewToolResultError("id is required"), nil + } + bin := d.fnBin() + cmdArgs := append([]string{"run", args.ID}, args.Args...) + cmd := exec.CommandContext(ctx, bin, cmdArgs...) + cmd.Dir = d.root + cmd.Env = os.Environ() + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + runErr := cmd.Run() + + exit := 0 + if cmd.ProcessState != nil { + exit = cmd.ProcessState.ExitCode() + } + + out := map[string]any{ + "id": args.ID, + "args": args.Args, + "exit_code": exit, + "stdout": truncate(stdout.String(), 100_000), + "stderr": truncate(stderr.String(), 100_000), + } + if runErr != nil && exit == 0 { + out["error"] = runErr.Error() + } + + b, _ := json.MarshalIndent(out, "", " ") + return mcp.NewToolResultText(string(b)), nil +} + +func (d *deps) fnBin() string { + if v := os.Getenv("FN_BIN"); v != "" { + return v + } + candidate := d.root + "/fn" + if _, err := os.Stat(candidate); err == nil { + return candidate + } + if path, err := exec.LookPath("fn"); err == nil { + return path + } + return "fn" +} diff --git a/tool_search.go b/tool_search.go new file mode 100644 index 0000000..8950177 --- /dev/null +++ b/tool_search.go @@ -0,0 +1,124 @@ +package main + +import ( + "context" + "encoding/json" + + "github.com/mark3labs/mcp-go/mcp" + + "fn-registry/registry" +) + +type searchArgs struct { + Query string `json:"query"` + Kind string `json:"kind,omitempty"` + Lang string `json:"lang,omitempty"` + Domain string `json:"domain,omitempty"` + Purity string `json:"purity,omitempty"` + Limit int `json:"limit,omitempty"` +} + +type searchHit struct { + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + Lang string `json:"lang"` + Domain string `json:"domain"` + Purity string `json:"purity"` + Signature string `json:"signature,omitempty"` + Description string `json:"description"` + Algebraic string `json:"algebraic,omitempty"` + Entity string `json:"entity"` // "function" | "type" +} + +func searchTool() mcp.Tool { + return mcp.NewTool("fn_search", + mcp.WithDescription("Search the registry (functions + types) via FTS5. Free-text query with optional filters. Returns up to `limit` hits ordered by name. Use this BEFORE writing new code to avoid duplicates."), + mcp.WithString("query", + mcp.Required(), + mcp.Description("FTS5 expression or free text. Examples: 'slice', 'name:filter*', 'description:\"single-page\"', 'sqlite OR fts5'."), + ), + mcp.WithString("kind", + mcp.Description("Filter by kind: function, pipeline, component."), + mcp.Enum("function", "pipeline", "component"), + ), + mcp.WithString("lang", + mcp.Description("Filter by language: go, py, bash, ts, cpp, ps."), + ), + mcp.WithString("domain", + mcp.Description("Filter by domain: core, infra, finance, datascience, cybersecurity, shell, tui, pipelines, browser, ..."), + ), + mcp.WithString("purity", + mcp.Description("Filter by purity (functions only)."), + mcp.Enum("pure", "impure"), + ), + mcp.WithNumber("limit", + mcp.Description("Max hits returned (default 50)."), + mcp.Min(1), + mcp.Max(500), + ), + ) +} + +func (d *deps) handleSearch(ctx context.Context, _ mcp.CallToolRequest, args searchArgs) (*mcp.CallToolResult, error) { + limit := args.Limit + if limit <= 0 { + limit = 50 + } + + q := sanitizeFTS5(args.Query) + + fns, err := d.db.SearchFunctions(q, registry.Kind(args.Kind), registry.Purity(args.Purity), args.Lang, args.Domain) + if err != nil { + return mcp.NewToolResultError("search functions: " + err.Error()), nil + } + + var hits []searchHit + for _, f := range fns { + hits = append(hits, searchHit{ + ID: f.ID, + Name: f.Name, + Kind: string(f.Kind), + Lang: f.Lang, + Domain: f.Domain, + Purity: string(f.Purity), + Signature: f.Signature, + Description: f.Description, + Entity: "function", + }) + if len(hits) >= limit { + break + } + } + + // Types: only when no kind filter (kind applies only to functions). + if args.Kind == "" && len(hits) < limit { + ts, err := d.db.SearchTypes(q, args.Lang, args.Domain) + if err != nil { + return mcp.NewToolResultError("search types: " + err.Error()), nil + } + for _, t := range ts { + hits = append(hits, searchHit{ + ID: t.ID, + Name: t.Name, + Lang: t.Lang, + Domain: t.Domain, + Algebraic: string(t.Algebraic), + Description: t.Description, + Entity: "type", + }) + if len(hits) >= limit { + break + } + } + } + + out := map[string]any{ + "query": args.Query, + "count": len(hits), + "limit": limit, + "results": hits, + } + b, _ := json.MarshalIndent(out, "", " ") + return mcp.NewToolResultText(string(b)), nil +} diff --git a/tool_show.go b/tool_show.go new file mode 100644 index 0000000..eef72bc --- /dev/null +++ b/tool_show.go @@ -0,0 +1,41 @@ +package main + +import ( + "context" + "encoding/json" + + "github.com/mark3labs/mcp-go/mcp" +) + +type showArgs struct { + ID string `json:"id"` +} + +func showTool() mcp.Tool { + return mcp.NewTool("fn_show", + mcp.WithDescription("Return a markdown card for the given function or type ID. Includes frontmatter-style header, description, signature, code block, examples and notes. Use after fn_search to fetch details."), + mcp.WithString("id", + mcp.Required(), + mcp.Description("Registry ID (e.g. filter_slice_go_core, Result_go_core)."), + ), + ) +} + +func (d *deps) handleShow(ctx context.Context, _ mcp.CallToolRequest, args showArgs) (*mcp.CallToolResult, error) { + if args.ID == "" { + return mcp.NewToolResultError("id is required"), nil + } + if f, err := d.db.GetFunction(args.ID); err == nil { + md := truncate(renderFunctionMarkdown(f), 50_000) + out := map[string]any{"id": f.ID, "entity": "function", "markdown": md} + b, _ := json.MarshalIndent(out, "", " ") + return mcp.NewToolResultText(string(b)), nil + } + if t, err := d.db.GetType(args.ID); err == nil { + md := truncate(renderTypeMarkdown(t), 50_000) + out := map[string]any{"id": t.ID, "entity": "type", "markdown": md} + b, _ := json.MarshalIndent(out, "", " ") + return mcp.NewToolResultText(string(b)), nil + } + return mcp.NewToolResultError("id not found: " + args.ID), nil +} diff --git a/tool_uses.go b/tool_uses.go new file mode 100644 index 0000000..0835471 --- /dev/null +++ b/tool_uses.go @@ -0,0 +1,74 @@ +package main + +import ( + "context" + "encoding/json" + + "github.com/mark3labs/mcp-go/mcp" +) + +type usesArgs struct { + ID string `json:"id"` +} + +func usesTool() mcp.Tool { + return mcp.NewTool("fn_uses", + mcp.WithDescription("Return the dependency edges for a function or type: which functions/types it uses (forward) and which functions consume it (reverse). Reverse lookup uses LIKE on uses_functions/uses_types JSON, so it's O(N) over the table — fast on registry.db (~1k rows)."), + mcp.WithString("id", + mcp.Required(), + mcp.Description("Registry ID."), + ), + ) +} + +func (d *deps) handleUses(ctx context.Context, _ mcp.CallToolRequest, args usesArgs) (*mcp.CallToolResult, error) { + if args.ID == "" { + return mcp.NewToolResultError("id is required"), nil + } + + out := map[string]any{"id": args.ID} + if f, err := d.db.GetFunction(args.ID); err == nil { + out["entity"] = "function" + out["uses_functions"] = f.UsesFunctions + out["uses_types"] = f.UsesTypes + } else if t, err := d.db.GetType(args.ID); err == nil { + out["entity"] = "type" + out["uses_types"] = t.UsesTypes + } else { + return mcp.NewToolResultError("id not found: " + args.ID), nil + } + + conn := d.db.Conn() + pattern := "%\"" + args.ID + "\"%" + rows, err := conn.Query(`SELECT id FROM functions WHERE uses_functions LIKE ? OR uses_types LIKE ? ORDER BY id`, pattern, pattern) + if err != nil { + return mcp.NewToolResultError("reverse lookup: " + err.Error()), nil + } + defer rows.Close() + var consumers []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return mcp.NewToolResultError("scan reverse: " + err.Error()), nil + } + consumers = append(consumers, id) + } + out["consumed_by"] = consumers + + // Apps that declare the id in uses_functions (the apps table also stores the JSON list). + rows, err = conn.Query(`SELECT id FROM apps WHERE uses_functions LIKE ? ORDER BY id`, pattern) + if err == nil { + defer rows.Close() + var apps []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err == nil { + apps = append(apps, id) + } + } + out["consumed_by_apps"] = apps + } + + b, _ := json.MarshalIndent(out, "", " ") + return mcp.NewToolResultText(string(b)), nil +}