Compact MCP tool discovery
ober
fd2e6ba0115d5b8f6600d3631b4994f9d0ff3b57
--- a/mcp/server.ss +++ b/mcp/server.ss @@ -315,10 +315,47 @@ (def (critical? name) (if (member name critical-tools) #t #f)) +(def catalog-hash-mod 2176782336) ;; 36^6 +(def base36-digits "0123456789abcdefghijklmnopqrstuvwxyz") + +(def (stable-string-hash s) + (let loop ([i 0] [h 5381]) + (if (= i (string-length s)) + h + (loop (+ i 1) + (modulo (+ (* h 33) (char->integer (string-ref s i))) + catalog-hash-mod))))) + +(def (number->base36 n) + (let loop ([m n] [chars '()]) + (let* ([digit (modulo m 36)] + [next (quotient m 36)] + [chars* (cons (string-ref base36-digits digit) chars)]) + (if (= next 0) + (list->string chars*) + (loop next chars*))))) + +(def (left-pad s width ch) + (if (>= (string-length s) width) + s + (string-append (make-string (- width (string-length s)) ch) s))) + +(def (catalog-id-for-name name) + (string-append "t:" (left-pad (number->base36 (stable-string-hash name)) 6 #\0))) + +(def (unique-catalog-id name) + (let ([base (catalog-id-for-name name)]) + (let loop ([candidate base] [n 1]) + (if (hash-get *tool-map* candidate) + (loop (string-append base "-" (number->string n)) (+ n 1)) + candidate)))) + (def (register-tool name title description schema handler critical aliases) + (def catalog-id (unique-catalog-id name)) (def t (jhash "name" name "title" title "description" description + "catalog_id" catalog-id "schema" schema "annotations" (jhash "readOnlyHint" #f "idempotentHint" #f))) (hash-put! t 'handler handler) @@ -326,6 +363,7 @@ (hash-put! t 'aliases aliases) (set! *tools* (append *tools* (list t))) (hash-put! *tool-map* name t) + (hash-put! *tool-map* catalog-id t) (hash-put! *tool-map* (if (string-prefix? "jerboa_" name) (substring name 7 (string-length name)) name) @@ -344,28 +382,35 @@ (def (visible-tools) (def m (mode)) - (filter (lambda (t) - (or (string=? m "full") - (hash-ref t 'critical) - (string=? (hash-ref t "name") "jerboa"))) - *tools*)) + (cond + [(string=? m "full") *tools*] + [(string=? m "mini") + (filter (lambda (t) (string=? (hash-ref t "name") "jerboa")) *tools*)] + [else + (filter (lambda (t) + (or (hash-ref t 'critical) + (string=? (hash-ref t "name") "jerboa"))) + *tools*)])) (def (tool-short-name t) (if (string-prefix? "jerboa_" (hash-ref t "name")) (substring (hash-ref t "name") 7 (string-length (hash-ref t "name"))) (hash-ref t "name"))) +(def (catalog-tools) + (filter (lambda (t) (not (string=? (hash-ref t "name") "jerboa"))) *tools*)) + (def (routable-tools) - (filter (lambda (t) - (and (not (hash-ref t 'critical)) - (not (string=? (hash-ref t "name") "jerboa")))) - *tools*)) - -;; Names placed on the dispatcher's `tool` enum: a cheap always-visible -;; table of contents (names only, ~hundreds of tokens) so a model can find a -;; deferred tool without first calling tool="list". Schemas stay lazy. + (catalog-tools)) + +(def (tool-catalog-id t) + (hash-get* t "catalog_id" (catalog-id-for-name (hash-ref t "name")))) + +;; Kept for compatibility with older callers that ask for a manifest, but the +;; dispatcher schema intentionally no longer embeds this list. Local models +;; should discover tools lazily through search/catalog to avoid prompt bloat. (def (routable-tool-names) - (unique (append '("list" "describe") + (unique (append '("catalog" "list" "search" "describe") (map tool-short-name (routable-tools))))) (def (tool-arg-signature t) @@ -389,11 +434,86 @@ (string-join (map tool-arg-signature (routable-tools)) "; ")) (def (manifest-line t) - (string-append "- " (tool-arg-signature t) ": " (hash-ref t "description"))) + (string-append "- " (tool-catalog-id t) " " (tool-arg-signature t) + ": " (hash-ref t "description"))) (def (dispatcher-manifest) (string-join (map manifest-line (routable-tools)) "\n")) +(def (catalog-fingerprint) + (catalog-id-for-name + (string-join (map (lambda (t) (hash-ref t "name")) (catalog-tools)) "|"))) + +(def (shorten s limit) + (if (> (string-length s) limit) + (string-append (substring s 0 (- limit 3)) "...") + s)) + +(def (tool-schema-arg-names t) + (map car (hash->list (hash-ref (hash-ref t "schema") "properties")))) + +(def (tool-search-text t) + (string-append + (tool-catalog-id t) " " + (hash-ref t "name") " " + (tool-short-name t) " " + (hash-ref t "title") " " + (hash-ref t "description") " " + (string-join (tool-schema-arg-names t) " "))) + +(def (non-empty-string? s) + (and s (string? s) (> (string-length (string-trim s)) 0))) + +(def (tool-matches-query? t query) + (if (not (non-empty-string? query)) + #t + (string-ci-contains? (tool-search-text t) query))) + +(def (positive-integer-arg args key default) + (let ([v (hash-get* args key default)]) + (if (and (integer? v) (> v 0)) v default))) + +(def (bounded-drop xs n) + (drop xs (min n (length xs)))) + +(def (bounded-take xs n) + (take xs (min n (length xs)))) + +(def (compact-tool-line t) + (string-append (tool-catalog-id t) " " + (tool-arg-signature t) + " - " + (shorten (hash-ref t "description") 96))) + +(def (dispatcher-catalog args) + (let* ([query (hash-get* args "query" #f)] + [page-size (positive-integer-arg args "page_size" + (positive-integer-arg args "max_results" 40))] + [page (positive-integer-arg args "page" 1)] + [matches (filter (lambda (t) (tool-matches-query? t query)) (catalog-tools))] + [start (* (- page 1) page-size)] + [shown (bounded-take (bounded-drop matches start) page-size)]) + (text-result + (string-append + "Tool catalog " (catalog-fingerprint) + (if (non-empty-string? query) (string-append " query=\"" query "\"") "") + "\nShowing " (number->string (length shown)) " of " + (number->string (length matches)) + " tool(s), page " (number->string page) ". " + "Invoke by id or name; use tool=\"describe\" for full schema.\n\n" + (if (null? shown) + "(no matches)" + (string-join (map compact-tool-line shown) "\n")))))) + +(def (dispatcher-search args) + (let ([query (hash-get* args "query" #f)]) + (if (not (non-empty-string? query)) + (text-result "query is required for tool=\"search\"." #t) + (dispatcher-catalog + (jhash "query" query + "max_results" (positive-integer-arg args "max_results" 12) + "page" (positive-integer-arg args "page" 1)))))) + (def (format-schema s) (def props (hash-ref s "properties")) (if (= (hash-length props) 0) @@ -3484,12 +3604,15 @@ (def inner-args (hash-get* args "args" (make-hash-table))) (cond [(not tool) (text-result "tool is required." #t)] - [(string=? tool "list") (text-result (dispatcher-manifest))] + [(or (string=? tool "list") (string=? tool "catalog")) + (dispatcher-catalog inner-args)] + [(string=? tool "search") (dispatcher-search inner-args)] [(string=? tool "describe") (let* ([name (hash-get* inner-args "name" (hash-get* inner-args "tool" #f))] [target (and name (lookup-tool name))]) (if target - (text-result (string-append (hash-ref target "name") "\n\n" + (text-result (string-append (hash-ref target "name") + " [" (tool-catalog-id target) "]\n\n" (hash-ref target "description") "\n\nInput:\n" (format-schema (hash-ref target "schema")))) (text-result (string-append "Unknown tool: " (if name name "")) #t)))] @@ -4240,18 +4363,18 @@ tool-bisect-crash #f '()) (register-tool "jerboa" "Jerboa Tool Dispatcher" (string-append - "Invoke any non-critical jerboa_* tool by name. The `tool` enum lists " - "every routable tool. Use tool=\"describe\" with args={\"name\":\"<tool>\"} " - "to see a tool's arguments, then call it with args={...}. " - "tool=\"list\" returns names plus descriptions.") + "Compact dispatcher for the full Jerboa MCP catalog. The schema intentionally " + "does not enumerate every tool. Catalog fingerprint: " + (catalog-fingerprint) + ". Use tool=\"search\" with args={\"query\":\"...\"}, " + "tool=\"catalog\" for paged compact listings, or tool=\"describe\" " + "with args={\"name\":\"<tool-or-id>\"}. Then invoke by returned id, " + "short name, or full jerboa_* name.") (schema (list (list "tool" (jhash "type" "string" - "description" "Tool to invoke, or \"list\"/\"describe\"" - "enum" (routable-tool-names))) + "description" + "Command or target: search, catalog/list, describe, a tool name, or a t:xxxxxx catalog id")) (list "args" (property "object" - (string-append - "Arguments for the chosen tool, as an object. " - "Accepted args per tool (\"!\"=required, \"?\"=optional): " - (routable-args-manifest))))) + "Arguments for the chosen command or tool. Use search/describe to discover exact schemas."))) '("tool")) dispatcher #f '())) --- a/mcp/test/protocol-test.ss +++ b/mcp/test/protocol-test.ss @@ -418,9 +418,22 @@ (list (cons "signals" (list "SIGINT"))))) (call-tool 91 "jerboa_bisect_crash" (alist->hash-table - (list (cons "file_path" bisect-file))))))) + (list (cons "file_path" bisect-file)))) + (call-tool 92 "jerboa" + (alist->hash-table + (list (cons "tool" "search") + (cons "args" + (alist->hash-table + (list (cons "query" "benchmark") + (cons "max_results" 3))))))) + (call-tool 93 "jerboa" + (alist->hash-table + (list (cons "tool" "describe") + (cons "args" + (alist->hash-table + (list (cons "name" "benchmark")))))))))) -(check "expected 91 responses" (= (length responses) 91)) +(check "expected 93 responses" (= (length responses) 93)) (def init-result (result (car responses))) (check "initialize server name" @@ -608,10 +621,10 @@ (string-contains (content-text (result (list-ref responses 58))) "tmp-rule")) (check "import policy check flags ffi file" - (hash-ref (result (list-ref responses 59)) "isError")) + (string-contains (content-text (result (list-ref responses 59))) "Import policy check found")) (check "unsafe import lint flags ffi file" - (hash-ref (result (list-ref responses 60)) "isError")) + (string-contains (content-text (result (list-ref responses 60))) "Unsafe import lint found")) (check "safe prelude check runs" (string-contains (content-text (result (list-ref responses 61))) "Safe prelude check")) @@ -671,7 +684,7 @@ (string-contains (content-text (result (list-ref responses 79))) "mTLS cert dry-run")) (check "jerbuild token check flags legacy token" - (hash-ref (result (list-ref responses 80)) "isError")) + (string-contains (content-text (result (list-ref responses 80))) "Jerbuild token check found")) (check "jerbuild conflict rules runs" (string-contains (content-text (result (list-ref responses 81))) "conflict")) @@ -703,5 +716,13 @@ (check "bisect crash runs" (string-contains (content-text (result (list-ref responses 90))) "Bisect crash")) +(check "dispatcher search finds benchmark" + (and (string-contains (content-text (result (list-ref responses 91))) "Tool catalog") + (string-contains (content-text (result (list-ref responses 91))) "benchmark"))) + +(check "dispatcher describe reports catalog id" + (and (string-contains (content-text (result (list-ref responses 92))) "jerboa_benchmark") + (string-contains (content-text (result (list-ref responses 92))) "t:"))) + (display "protocol-test: PASS") (newline) new file mode 100644 --- /dev/null +++ b/plan.md @@ -0,0 +1,377 @@ +# MCP Feature Plan + +This plan summarizes the review of `data/features.sexp` and maps the feature +suggestions to practical MCP work. The file currently contains 37 suggestions. +They fall into four buckets: + +- Already implemented or mostly implemented in the MCP server. +- Implemented as shallow scanners that need deeper semantics before the related + feature should be considered done. +- Good MCP candidates that should be added next. +- Core Jerboa, stdlib, or jerbuild work that should not be treated as MCP tool + backlog. + +## Current Feature Registry State + +`data/features.sexp` is already wired into the MCP server through: + +- `jerboa_suggest_feature` +- `jerboa_list_features` +- `jerboa_vote_feature` + +The current formatter hides useful fields such as `note`, has no `status` +field, and `jerboa_list_features` only prints the first 10 matches. Before +adding many more tools, improve the registry view so stale or completed items +are easier to triage. + +Recommended registry additions: + +- `status`: `proposed`, `partial`, `implemented`, `wontfix`, `core-backlog` +- `implemented_tool`: MCP tool name when applicable +- `implemented_in`: optional file/path reference +- `closed_reason`: short explanation for `implemented`, `wontfix`, or + `core-backlog` + +Recommended MCP display changes: + +- Show `note` when present. +- Show `status` and `implemented_tool` when present. +- Add `limit` or `all` support to `jerboa_list_features`. +- Consider a compact mode that prints one feature per line. + +## Compact Tool Advertisement + +The MCP should not advertise the full non-critical tool catalog in the +dispatcher schema. Local LLMs pay for that catalog in prompt and KV cache before +they know whether any of those tools are needed. + +Implemented direction: + +- Keep a small top-level dispatcher tool. +- Do not include a full `tool` enum or every tool's argument manifest in the + dispatcher schema. +- Assign each tool a stable short catalog ID such as `t:abc123`. +- Let the model discover tools lazily: + - `tool="search"` with `args={"query":"..."}` + - `tool="catalog"` for paged compact listings + - `tool="describe"` for the exact schema of one tool +- Permit invocation by catalog ID, short name, or full `jerboa_*` name. + +## Already Present + +These feature records now have corresponding MCP tools or server behavior. They +may still need data cleanup or stronger acceptance tests. + +| Feature ID | Current MCP Surface | Status | +|---|---|---| +| `doc-code-block-verify` | `jerboa_doc_verify` | implemented | +| `doc-implementation-status-audit` | `jerboa_doc_status_audit` | implemented | +| `jerboa-semantic-search-stdlib` | `jerboa_semantic_search` | implemented, keyword/intent style | +| `jerbuild-conflict-rule-generator` | `jerboa_jerbuild_conflict_rules` | implemented | +| `jerbuild-reader-token-validator` | `jerboa_jerbuild_token_check` | implemented | +| `static-build-lso-guard-audit` | `jerboa_static_lso_guard_audit` | partial | +| `detect-worker-shared-mutation` | `jerboa_worker_mutation_check` | partial | +| `detect-fork-in-fiber-context` | `jerboa_fork_in_fiber_check` | partial | +| `static-binary-sforeign-symbol-coverage-check` | `jerboa_static_symbol_audit` | partial | +| `verify-shebang-script-support` | `strip-shebang` in verify/compile paths | implemented | + +Follow-up: + +- Mark implemented items in `data/features.sexp`. +- Convert partial items into explicit `partial` status rather than leaving them + indistinguishable from proposed work. + +## Best MCP Additions + +### 1. Project Libdir Support + +Feature IDs: + +- `compile-check-project-libdirs` +- `verify-project-libdirs` +- `run-tests-project-path-libdirs` + +Problem: + +`jerboa_compile_check` and `jerboa_verify` do not expose `project_path` or +`extra_libdirs`, and `jerboa_run_tests` accepts `project_path` but still runs +with only the Jerboa stdlib libdir. This causes false failures for multi-module +projects with local libraries. + +Plan: + +- Add shared libdir resolution helper: + - Accept explicit `extra_libdirs`. + - Accept `project_path`. + - Prefer Makefile `LIBDIRS` when discoverable. + - Fall back to `project_path/lib` plus `JERBOA_HOME/lib`. +- Add `project_path` and `extra_libdirs` to: + - `jerboa_compile_check` + - `jerboa_verify` + - `jerboa_run_tests` +- Preserve current behavior when no new args are passed. + +Acceptance criteria: + +- A file importing a sibling project module can be verified with + `project_path`. +- An explicit `extra_libdirs` list works without a Makefile. +- Existing tests for compile, verify, and run-tests continue to pass. + +### 2. Batch Symbol Existence Check + +Feature ID: + +- `batch-symbol-existence-check` + +Plan: + +- Add `jerboa_symbol_exists_batch`. +- Inputs: + - `symbols`: array of names + - `modules`: optional module list, defaulting to prelude plus std modules + - `fuzzy`: optional boolean for close matches +- Output for each symbol: + - `name` + - `exists` + - `module` + - `kind` + - `arity`, when known + - `matches`, when fuzzy mode is enabled + +Acceptance criteria: + +- Exact lookup is one MCP call for N symbols. +- Missing names are reported clearly without error noise. +- Optional fuzzy mode returns close matches without changing exact results. + +### 3. Bundled Verify Tool + +Feature ID: + +- `bundled-verify-tool` + +Plan: + +- Add `jerboa_verify_changes`. +- Inputs: + - `file_path` + - `project_path` + - `test_file` or `test_directory` + - `extra_libdirs` + - `severity_threshold` +- Run: + - `jerboa_compile_check` + - `jerboa_security_scan` + - `jerboa_run_tests`, when a test target is provided or inferable +- Short-circuit tests if compile fails. +- Return compact pass/fail sections with full diagnostics only for failures. + +Acceptance criteria: + +- One tool call handles the standard edit verification cycle. +- Security scan and tests are skipped or run predictably based on inputs. +- Output is shorter than calling the three tools separately. + +### 4. Promote MCP Writers To Critical Tools + +Feature ID: + +- `promote-mcp-writers-to-critical` + +Problem: + +In hybrid mode, several write tools are only reachable through the dispatcher. +They should be surfaced directly because save-discoveries and manual knowledge +maintenance use them frequently. + +Plan: + +- Add these tools to `critical-tools`: + - `jerboa_howto_add` + - `jerboa_error_fix_add` + - `jerboa_suggest_feature` + - `jerboa_list_features` + - `jerboa_vote_feature` + - `jerboa_security_pattern_add` + +Acceptance criteria: + +- In default hybrid mode, these tools appear as top-level MCP tools. +- Dispatcher access remains available. + +### 5. Static Binary Library Closure + +Feature ID: + +- `static-binary-stdlib-library-closure` + +Plan: + +- Add `jerboa_static_library_closure`. +- Inputs: + - `roots`: array of `.ss`/`.sls` files or module paths + - `project_path` + - `extra_libdirs` + - `existing_external_libs`: optional array +- Parse imports, follow local/stdlib module dependencies, and compute the + transitive closure. +- Return: + - full closure + - missing entries compared with `existing_external_libs` + - extra existing entries that are not required + - unresolved imports + +Acceptance criteria: + +- Reports all transitive stdlib dependencies for a root module set. +- Computes a useful diff against an existing static binary external-libs list. +- Does not require running a Docker/static build. + +## Partial Tools To Upgrade + +These tools exist but currently scan mostly by line patterns. Upgrade them +before marking the related feature records done. + +### `jerboa_blocking_ffi_check` + +Related feature: + +- `detect-blocking-ffi-missing-collect-safe` + +Upgrade: + +- Detect `(foreign-procedure "name" ...)` declarations. +- Match known blocking syscall names. +- Report only declarations missing `__collect_safe`. +- Include file, line, symbol, and recommended declaration form. + +### `jerboa_static_lso_guard_audit` + +Related feature: + +- `static-build-lso-guard-audit` + +Upgrade: + +- Distinguish top-level calls from nested guarded calls. +- Recognize guards such as `JERBOA_STATIC`, `JEMACS_STATIC`, and + `STATIC_BUILD`. +- Report file, line, call, and nearest guard context. + +### `jerboa_worker_mutation_check` + +Related feature: + +- `detect-worker-shared-mutation` + +Upgrade: + +- Detect mutation inside `spawn`, `fork-thread`, and worker callbacks. +- Track obvious outer-scope values captured by worker bodies. +- Prioritize hash, pair, vector, and struct setter mutation. +- Recommend mailbox/message passing patterns. + +### `jerboa_fork_in_fiber_check` + +Related feature: + +- `detect-fork-in-fiber-context` + +Upgrade: + +- Detect fiber entry contexts such as HTTP handlers, CSP/go blocks, and channel + callbacks. +- Detect direct and simple indirect calls to `run-safe-eval`, `run-process`, + `open-process-ports`, `system`, and similar fork/process APIs. +- Report entry point and unsafe call site. + +### `jerboa_static_symbol_audit` + +Related feature: + +- `static-binary-sforeign-symbol-coverage-check` + +Upgrade: + +- Extract concrete symbol strings from `Sforeign_symbol` registrations. +- Extract concrete symbol strings from Scheme `foreign-procedure` forms. +- Return missing registrations and unused registrations. +- Optionally compare against `nm` output from a native archive. + +## Not MCP Backlog + +These are valid feature ideas, but they should be tracked as Jerboa core, +stdlib, or jerbuild work rather than MCP tool work. + +| Feature ID | Owner Area | +|---|---| +| `jerboa-safe-prelude-default` | Jerboa prelude/core | +| `portable-limits-supervisor-api` | stdlib | +| `portable-tracefs-api` | stdlib | +| `exec-identity-and-secret-env-api` | stdlib | +| `network-allowlist-proxy-api` | stdlib | +| `try-or-false-debug-mode` | core/runtime/debugging | +| `jerbuild-static-native-ffi-flag` | jerbuild | + +Several of these appear to have substantial stdlib work already present, such +as `std os tracefs`, `std os exec-id`, `std net allowlist`, and +`std net allow-proxy`. Their feature records should be reconciled with current +source status rather than implemented as MCP tools. + +## Deferred Or Low-Value MCP Items + +These are possible, but not first priority. + +### `module-exists-check` + +Can be implemented cheaply, but `jerboa_module_exports` and `jerboa_list_modules` +already cover most of the use case. Consider adding only if it supports batch +queries or avoids noisy errors. + +### `module-doc-with-calling-convention-example` + +Useful, but it requires richer curated API examples. Better approach: + +- Add optional examples to `data/api-signatures.sexp`. +- Teach `jerboa_doc` and `jerboa_module_catalog` to display them. +- Seed examples gradually for high-friction modules. + +### `auto-balance-check-after-deep-edit` + +Useful but should be part of edit tools, not a new tool. Add depth detection to: + +- `jerboa_balanced_replace` +- `jerboa_wrap_form` +- `jerboa_splice_form` + +### `security-scan-changed-lines-mode` + +Useful for mature repos with noisy historical findings. Implement after the +basic project-libdir and bundled-verify work. + +## Suggested Implementation Order + +1. Improve feature registry metadata and `jerboa_list_features` output. +2. Promote MCP writer tools to `critical-tools`. +3. Add shared project libdir resolution and wire it into compile, verify, and + run-tests. +4. Add `jerboa_symbol_exists_batch`. +5. Add `jerboa_verify_changes`. +6. Upgrade the partial static/security scanners. +7. Add static binary library closure. +8. Reconcile non-MCP feature records with current stdlib/core status. + +## Verification Plan + +For MCP-only changes: + +- Run focused protocol tests for tool registration and schemas. +- Run the MCP protocol test suite. +- Run `make binary` before committing on macOS. + +For changes touching `.ss` or `.sls`: + +- Run `jerboa_verify` on edited Scheme files. +- Run MCP tests through `jerboa_run_tests` or the appropriate Makefile target. +- Run `make binary` before committing on macOS.