chore: sync from fn-registry agent
This commit is contained in:
@@ -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."
|
||||
@@ -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` |
|
||||
@@ -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 <sub> --json` |
|
||||
| `fn_run` | Subprocess `fn run <id> [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.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, "\"", "\"\"") + "\""
|
||||
}
|
||||
+43
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
Executable
BIN
Binary file not shown.
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+79
@@ -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 <id> [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"
|
||||
}
|
||||
+124
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user