/** * MCP tool registration and shared utilities. * * @module src/mcp/tools */ import type { McpServer } from "@modelcontextprotocol/server"; import { z } from "zod"; import type { ToolContext } from "../server"; import { contextBuildSurfaceSchema, contextVerifySurfaceSchema, } from "../../app/context-surface"; import { CAPTURE_MAX_TEXT_BYTES } from "../../core/capture"; import { NOTE_PRESETS, type NotePresetId } from "../../core/note-presets"; import { RETRIEVAL_TRACE_METADATA } from "../../core/retrieval-trace-session"; import { normalizeTag } from "../../core/tags"; import { metadataPredicateSchema } from "../../core/typed-metadata"; import { assertInferenceActive, acquireInferencePermit, } from "../../llm/inference-scope"; import { profileToolDescription } from "../tool-descriptions-core"; import { createProfileToolRegistrar, DEFAULT_MCP_TOOL_PROFILE, } from "../tool-profile"; import { handleAddCollection } from "./add-collection"; import { askInputSchema, handleAsk } from "./ask"; import { AUDIT_MCP_ANNOTATIONS, auditInputSchema, handleAudit } from "./audit"; import { handleCapture } from "./capture"; import { changesInputSchema, diffInputSchema, handleChanges, handleDiff, handleImpact, impactInputSchema, } from "./changes"; import { handleClearCollectionEmbeddings } from "./clear-collection-embeddings"; import { handleContext, handleContextVerify } from "./context"; import { egressAuditIdInputSchema, egressAuditListInputSchema, egressAuditPurgeInputSchema, egressAuditStatusInputSchema, egressCheckInputSchema, egressPolicyGetInputSchema, egressPolicySetInputSchema, handleEgressAuditDelete, handleEgressAuditList, handleEgressAuditPurge, handleEgressAuditShow, handleEgressAuditStatus, handleEgressCheck, handleEgressPolicyGet, handleEgressPolicySet, } from "./egress"; import { handleEmbed } from "./embed"; import { handleGet } from "./get"; import { handleIndex } from "./index-cmd"; import { handleJobStatus } from "./job-status"; import { handleBacklinks, handleGraph, handleGraphQuery, handleGraphNeighbors, handleGraphPath, handleLinks, handleSimilar, } from "./links"; import { handleListJobs } from "./list-jobs"; import { handleListTags } from "./list-tags"; import { handleRecall, RECALL_MCP_ANNOTATIONS, recallInputSchema, } from "./memory-recall"; import { handleRemember, REMEMBER_MCP_ANNOTATIONS, rememberInputSchema, } from "./memory-remember"; import { handleMultiGet } from "./multi-get"; import { handlePeek, PEEK_MCP_ANNOTATIONS } from "./peek"; import { handleQuery, handleQueryDiagnose } from "./query"; import { handleRemoveCollection } from "./remove-collection"; import { handleSearch } from "./search"; import { handleSection, SECTION_MCP_ANNOTATIONS, sectionInputSchema, sectionOutputSchema, } from "./sections"; import { handleStatus } from "./status"; import { handleSync } from "./sync"; import { handleTraceDelete, handleTraceExport, handleTraceLabel, handleTraceList, handleTracePurge, handleTraceShow, traceDeleteInputSchema, traceExportInputSchema, traceLabelInputSchema, traceListInputSchema, tracePurgeInputSchema, traceShowInputSchema, } from "./trace"; import { handleVsearch } from "./vsearch"; import { handleCreateFolder, handleDuplicateNote, handleMoveNote, handleRenameNote, MOVE_NOTE_MCP_ANNOTATIONS, moveNoteInputSchema, RENAME_NOTE_MCP_ANNOTATIONS, renameNoteInputSchema, } from "./workspace-write"; // ───────────────────────────────────────────────────────────────────────────── // Shared Helpers // ───────────────────────────────────────────────────────────────────────────── /** * Normalize and dedupe tag filter arrays. * Returns undefined if empty, normalized array otherwise. */ export function normalizeTagFilters(tags?: string[]): string[] | undefined { if (!tags?.length) return undefined; return [...new Set(tags.map(normalizeTag))]; } export const MCP_TOOL_DESCRIPTIONS = { search: "BM25 keyword search. Fast exact-term lookup for names, identifiers, error text, and known phrases. Structured results include uri/docid, line when available, and optional user-configured context guidance; use gno_get with fromLine/lineCount or gno_multi_get for full context. Use gno_query when wording is uncertain.", vsearch: "Vector semantic search. Finds conceptually similar docs with different wording. Structured results preserve optional user-configured context guidance. Best after embeddings are current; use intent to disambiguate short terms. Use gno_query for default hybrid retrieval.", query: "Hybrid search (BM25 + vector + default bounded graph expansion + optional query expansion/reranking). Recommended default. Structured results preserve optional user-configured context guidance with source identity. Use intent for ambiguous terms, queryModes to combine term/intent/hyde strategies, fast=true or graph=false for quick lookup, thorough=true when recall matters, and candidateLimit to trade latency for coverage.", queryDiagnose: "Diagnose why one target document does or does not appear for a query. Use when an important doc is missing, a filter may exclude it, or you need stage-by-stage BM25/vector/fusion/graph/rerank evidence before changing retrieval strategy.", get: "Retrieve one document by gno:// URI, docid (#abc123), or collection/path. After search results include line, pass fromLine and lineCount to fetch only the relevant range before expanding to the full document.", multiGet: "Retrieve multiple documents by refs array or glob pattern. Use after gno_search/gno_query to batch top result URIs/docids; set maxBytes and lineNumbers to control context size.", section: "Create or resolve a durable SectionTargetV1 against one indexed document. action=create needs ref plus exactly one of anchor|line; action=resolve needs ref plus target. Exact/recovered include citation (uri, anchor, title, inclusive lines, fingerprint); ambiguous/stale/missing omit citation and are not safe to navigate or cite. Read-only — does not write or persist targets. Follow navigable ranges with gno_get fromLine/lineCount.", peek: "Cheap peek@1.0 snapshot: initialized flag, document/collection counts, embedding backlog, recent files, and serve liveness. Model-free — never initializes embeddings or models. Use for counts/backlog/recent/serve questions; use gno_status for full health and activation.", status: "Get index health: collection count, document count, chunk count, embedding backlog, and per-collection stats. Check first when vector/hybrid results look stale or unavailable.", audit: "Run deterministic, offline, read-only integrity audits for links, declared provenance completeness, and source/index freshness. Returns stable bounded findings and explicit partial/unavailable states; never repairs or mutates the workspace.", context: "Compile one deterministic, budgeted, extractive evidence Capsule with exact line spans, coverage gaps, omissions, provenance, and verification fingerprints. Raw search/get tools remain available for manual retrieval.", contextVerify: "Verify a saved Context Capsule without rebuilding or mutating it. Reports unchanged, stale, missing, reranked, and fingerprint drift states against the active index.", ask: "Generate one answer from a deterministic Context Capsule, verify every substantive claim against exact retained spans, and abstain unless support coverage is complete. Read-only; returns the Capsule, freshness receipt, claim verdicts, gaps, and evidence IDs.", recall: "Recall current facts from a memory-managed collection for explicit scopes. Call before answering about the user's preferences, decisions, people, or prior work, and before gno_remember to find the predecessor of a changed fact. Returns at most 8 facts within 512 tokens by default, each with text, scopes, provenance, gno:// cite, and content hash, plus a content-free receipt; superseded facts are excluded. Pass the receipt to gno_remember when a stored fact derives from this recall. An empty result names the command that stores the first fact.", remember: "Store one fact in a memory-managed collection under explicit scopes. Call when the user states a durable preference, decision, or fact worth recalling later; use gno_capture for documents and file edits for existing notes. Without decision it returns likely matches and writes nothing; decision=add writes a new fact; decision=supersede replaces predecessorUri after a hash check, one successor per fact. Exact duplicates return the existing record. Text that replays a recall receipt span or declares a gno:// origin is rejected. The fact is lexically searchable when the call returns.", } as const; /** Tool names whose execution mutates disk, config, or index state. */ export const MCP_WRITE_TOOL_NAMES = new Set([ "gno_capture", "gno_remember", "gno_add_collection", "gno_sync", "gno_embed", "gno_index", "gno_remove_collection", "gno_clear_collection_embeddings", "gno_create_folder", "gno_rename_note", "gno_move_note", "gno_duplicate_note", "gno_trace_label", "gno_trace_export", "gno_trace_delete", "gno_trace_purge", "gno_egress_policy_set", "gno_egress_audit_delete", "gno_egress_audit_purge", ]); // ───────────────────────────────────────────────────────────────────────────── // Shared Input Schemas // ───────────────────────────────────────────────────────────────────────────── const projectHintsInputSchema = z .array(z.string()) .max(16) .optional() .describe( "Opaque caller project hints for cross-surface parity; remote hints never inspect server paths" ); export const searchInputSchema = z.object({ query: z .string() .min(1, "Query cannot be empty") .describe( "Exact keyword, identifier, filename, error text, or phrase to match with BM25" ), projectHints: projectHintsInputSchema, collection: z .string() .optional() .describe("Filter to a single collection name"), limit: z .number() .int() .min(1) .max(100) .default(5) .describe("Max results to return"), minScore: z .number() .min(0) .max(1) .optional() .describe("Minimum relevance score (0-1). Omit to return all matches"), lang: z .string() .optional() .describe( "BCP-47 language hint for tokenization (e.g. 'en', 'de'). Auto-detected if omitted" ), intent: z .string() .optional() .describe( "Disambiguating context for ambiguous queries; not searched directly (e.g. 'programming language' when query is 'python')" ), exclude: z .array(z.string()) .optional() .describe("Exclude documents containing any of these terms"), since: z .string() .optional() .describe( "Only docs modified after this date (ISO format: 2026-03-01 or 2026-03-01T00:00:00)" ), until: z .string() .optional() .describe("Only docs modified before this date (ISO format)"), categories: z .array(z.string()) .optional() .describe("Require category match (from document frontmatter)"), author: z .string() .optional() .describe("Filter by author (case-insensitive substring match)"), filter: metadataPredicateSchema .optional() .describe("Typed custom metadata predicate; intersects existing scope"), tagsAll: z .array(z.string()) .optional() .describe("Require ALL of these tags (AND filter)"), tagsAny: z .array(z.string()) .optional() .describe("Require ANY of these tags (OR filter)"), }); const notePresetIds = NOTE_PRESETS.map((preset) => preset.id) as [ NotePresetId, ...NotePresetId[], ]; export const captureInputSchema = z.object({ collection: z .string() .min(1, "Collection cannot be empty") .describe("Target collection name (must already exist)"), content: z .string() .max(CAPTURE_MAX_TEXT_BYTES) .optional() .describe( "Document content (markdown or plain text). Optional when presetId provides a scaffold." ), title: z .string() .optional() .describe("Document title. Auto-derived from content if omitted"), path: z .string() .optional() .describe( "Relative path within collection (e.g. 'notes/meeting.md'). Auto-generated from title if omitted" ), folderPath: z .string() .optional() .describe("Optional folder path within the collection"), collisionPolicy: z .enum(["error", "open_existing", "create_with_suffix"]) .optional() .describe("How to handle name collisions"), presetId: z .enum(notePresetIds) .optional() .describe("Optional note preset scaffold"), overwrite: z .boolean() .default(false) .describe("Overwrite if file already exists at path"), tags: z .array(z.string()) .optional() .describe("Tags to apply to the new document"), source: z .object({ kind: z .enum([ "direct", "web", "email", "meeting", "chat", "file", "api", "unknown", ]) .optional() .describe("Capture source kind"), title: z.string().optional().describe("Human source title"), url: z.string().optional().describe("Source URL"), uri: z.string().optional().describe("Source URI"), docid: z.string().optional().describe("Source GNO doc ID"), mime: z.string().optional().describe("Source MIME type"), ext: z.string().optional().describe("Source file extension"), author: z.string().optional().describe("Source author"), observedAt: z .string() .optional() .describe("When the source was observed"), capturedAt: z.string().optional().describe("Capture timestamp override"), externalId: z.string().optional().describe("External system/source ID"), }) .optional() .describe( "Structured provenance metadata written under source frontmatter" ), }); const addCollectionInputSchema = z.object({ path: z .string() .min(1, "Path cannot be empty") .describe("Absolute path to the directory to index"), name: z .string() .optional() .describe("Collection name. Auto-derived from directory name if omitted"), pattern: z .string() .optional() .describe( "Glob pattern for files to include (default: '**/*'). E.g. '**/*.md' for markdown only" ), include: z .array(z.string()) .optional() .describe("Extension allowlist (e.g. ['.md', '.pdf', '.docx'])"), exclude: z .array(z.string()) .optional() .describe("Glob patterns to exclude (default: ['.git', 'node_modules'])"), gitPull: z .boolean() .default(false) .describe("Run git pull before indexing (if collection is a git repo)"), }); const syncInputSchema = z.object({ collection: z .string() .optional() .describe("Collection name to sync. Omit to sync all collections"), gitPull: z.boolean().default(false).describe("Run git pull before syncing"), runUpdateCmd: z .boolean() .default(false) .describe("Run the collection's configured update command before syncing"), }); const embedInputSchema = z.object({ collection: z .string() .optional() .describe("Collection name to embed. Omit to embed all collections"), }); const indexInputSchema = z.object({ collection: z .string() .optional() .describe("Collection name to index. Omit to index all collections"), gitPull: z.boolean().default(false).describe("Run git pull before indexing"), }); const removeCollectionInputSchema = z.object({ collection: z .string() .min(1, "Collection cannot be empty") .describe("Collection name to remove"), }); const clearCollectionEmbeddingsInputSchema = z.object({ collection: z .string() .min(1, "Collection cannot be empty") .describe("Collection name to clean"), mode: z .enum(["stale", "all"]) .default("stale") .describe("Cleanup mode: stale models only, or all embeddings"), }); const createFolderInputSchema = z.object({ collection: z.string().min(1, "Collection cannot be empty"), name: z.string().min(1, "Folder name cannot be empty"), parentPath: z.string().optional(), }); const duplicateNoteInputSchema = z.object({ ref: z.string().min(1, "ref cannot be empty"), folderPath: z.string().optional(), name: z.string().optional(), }); export const vsearchInputSchema = z.object({ query: z .string() .min(1, "Query cannot be empty") .describe( "Natural-language concept to match semantically; use gno_search for exact error text or identifiers" ), projectHints: projectHintsInputSchema, collection: z .string() .optional() .describe("Filter to a single collection name"), limit: z .number() .int() .min(1) .max(100) .default(5) .describe("Max results to return"), minScore: z .number() .min(0) .max(1) .optional() .describe("Minimum similarity score (0-1)"), lang: z .string() .optional() .describe("BCP-47 language hint (e.g. 'en', 'de')"), intent: z .string() .optional() .describe( "Disambiguating context for ambiguous terms; steers snippet choice without becoming the searched text" ), exclude: z .array(z.string()) .optional() .describe("Exclude documents containing any of these terms"), since: z .string() .optional() .describe("Only docs modified after this date (ISO format)"), until: z .string() .optional() .describe("Only docs modified before this date (ISO format)"), categories: z.array(z.string()).optional().describe("Require category match"), author: z .string() .optional() .describe("Filter by author (case-insensitive substring)"), filter: metadataPredicateSchema .optional() .describe("Typed custom metadata predicate; intersects existing scope"), tagsAll: z.array(z.string()).optional().describe("Require ALL of these tags"), tagsAny: z.array(z.string()).optional().describe("Require ANY of these tags"), }); const queryModeInputSchema = z.object({ mode: z .enum(["term", "intent", "hyde"]) .describe( "Retrieval strategy: 'term' for exact lexical anchors, 'intent' for disambiguation, 'hyde' for one hypothetical answer/document to improve semantic matching" ), text: z .string() .trim() .min(1, "Query mode text cannot be empty") .describe( "Text for this query mode; keep term modes concise and hyde modes answer-shaped" ), }); export const queryInputSchema = z.object({ query: z .string() .min(1, "Query cannot be empty") .describe( "Primary user query; combine with intent or queryModes for ambiguous requests" ), projectHints: projectHintsInputSchema, collection: z .string() .optional() .describe("Filter to a single collection name"), limit: z .number() .int() .min(1) .max(100) .default(5) .describe("Max results to return"), minScore: z .number() .min(0) .max(1) .optional() .describe("Minimum relevance score (0-1)"), lang: z .string() .optional() .describe( "BCP-47 language hint (e.g. 'en', 'de'). Auto-detected if omitted" ), intent: z .string() .optional() .describe( "Disambiguating context (e.g. 'programming language' when query is 'python'); steers expansion, rerank, and snippet choice" ), candidateLimit: z .number() .int() .min(1) .max(100) .optional() .describe( "Max candidates passed to reranking stage; raise when top results miss relevant docs, lower for latency" ), exclude: z .array(z.string()) .optional() .describe("Exclude documents containing any of these terms"), since: z .string() .optional() .describe("Only docs modified after this date (ISO format)"), until: z .string() .optional() .describe("Only docs modified before this date (ISO format)"), categories: z.array(z.string()).optional().describe("Require category match"), author: z .string() .optional() .describe("Filter by author (case-insensitive substring)"), queryModes: z .array(queryModeInputSchema) .describe( "Structured query modes for typed retrieval: combine term anchors, intent disambiguation, and at most one hyde hypothetical document" ) .superRefine((entries, ctx) => { const hydeCount = entries.filter((entry) => entry.mode === "hyde").length; if (hydeCount > 1) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Only one hyde mode is allowed in queryModes", }); } }) .optional(), fast: z .boolean() .default(false) .describe( "Skip query expansion, graph expansion, and reranking (~0.7s). Use for quick lookups" ), thorough: z .boolean() .default(false) .describe( "Enable query expansion for best recall (~5-8s). Use for broad research or when default results miss likely docs" ), expand: z .boolean() .optional() .describe("Override: enable/disable query expansion"), rerank: z .boolean() .optional() .describe("Override: enable/disable cross-encoder reranking"), noGraph: z.boolean().optional().describe("Disable graph neighbor expansion"), graph: z .boolean() .optional() .describe("Enable bounded one-hop graph neighbor expansion (default)"), explain: z .boolean() .optional() .describe("Include deterministic stage and per-result scoring metadata"), filter: metadataPredicateSchema .optional() .describe("Typed custom metadata predicate; intersects existing scope"), tagsAll: z.array(z.string()).optional().describe("Require ALL of these tags"), tagsAny: z.array(z.string()).optional().describe("Require ANY of these tags"), }); export const queryDiagnoseInputSchema = queryInputSchema.extend({ target: z .string() .trim() .min(1, "Target reference cannot be empty") .describe( "Target document reference to diagnose (gno URI, docid, or collection/path)" ), }); const getInputSchema = z.object({ ref: z .string() .min(1, "Reference cannot be empty") .describe( "Document reference: URI (gno://collection/path), docid (#abc123), or collection/path" ), fromLine: z .number() .int() .min(1) .optional() .describe( "Start reading from this line number; use the line returned by search/query results" ), lineCount: z .number() .int() .min(1) .optional() .describe( "Number of lines to return from fromLine; prefer a small range before fetching full docs" ), lineNumbers: z .boolean() .default(true) .describe("Include line numbers in output"), traceId: z .string() .min(1) .max(128) .optional() .describe("Continue an open retrieval trace returned by a search/query"), }); const multiGetInputSchema = z.object({ refs: z .array(z.string()) .min(1) .optional() .describe( "Array of document references from search/query results (gno:// URIs or docids)" ), pattern: z .string() .optional() .describe("Glob pattern to match documents (e.g. 'work/**/*.md')"), maxBytes: z .number() .int() .min(1) .default(10_240) .describe( "Max bytes per document; lower this when batching many top search results" ), lineNumbers: z .boolean() .default(true) .describe("Include line numbers in output"), }); const peekInputSchema = z.object({}); const statusInputSchema = z.object({}); const jobStatusInputSchema = z.object({ jobId: z .string() .min(1, "Job ID cannot be empty") .describe("Job ID returned by async operations (embed, index)"), }); const listJobsInputSchema = z.object({ limit: z .number() .int() .min(1) .max(100) .default(10) .describe("Max jobs to return"), }); const listTagsInputSchema = z.object({ collection: z .string() .optional() .describe("Filter tags to a single collection"), prefix: z .string() .optional() .describe("Filter tags by prefix (e.g. 'project/' for hierarchical tags)"), }); const linksInputSchema = z.object({ ref: z .string() .trim() .min(1, "Reference cannot be empty") .describe("Document reference (URI, docid, or collection/path)"), type: z .enum(["wiki", "markdown"]) .optional() .describe( "Filter by link type: 'wiki' ([[links]]) or 'markdown' ([links](url))" ), }); const backlinksInputSchema = z.object({ ref: z .string() .trim() .min(1, "Reference cannot be empty") .describe("Document reference to find backlinks for"), collection: z .string() .trim() .optional() .describe("Filter backlinks to a single collection"), }); const similarInputSchema = z.object({ ref: z .string() .trim() .min(1, "Reference cannot be empty") .describe("Document reference to find similar docs for"), limit: z .number() .int() .min(1) .max(50) .default(5) .describe("Max similar documents to return"), threshold: z .number() .min(0) .max(1) .optional() .describe("Minimum similarity score (0-1, default: 0.7)"), crossCollection: z .boolean() .default(false) .describe( "Search across all collections (not just the document's own collection)" ), }); const graphInputSchema = z.object({ collection: z .string() .trim() .optional() .describe("Filter graph to a single collection"), limit: z .number() .int() .min(1) .max(5000) .default(2000) .describe("Max nodes in graph"), edgeLimit: z .number() .int() .min(1) .max(50000) .default(10000) .describe("Max edges in graph"), includeSimilar: z .boolean() .default(false) .describe("Include vector-similarity edges (not just wiki/markdown links)"), threshold: z .number() .min(0) .max(1) .default(0.7) .describe("Similarity threshold for similar edges (0-1)"), linkedOnly: z .boolean() .default(true) .describe("Exclude isolated nodes (no links)"), similarTopK: z .number() .int() .min(1) .max(20) .default(5) .describe("Max similar docs per node when includeSimilar=true"), }); const graphNeighborsInputSchema = graphInputSchema.extend({ ref: z .string() .trim() .min(1, "Reference cannot be empty") .describe( "Document/node reference: gno URI, #docid, collection/path, relPath, or exact title" ), direction: z .enum(["both", "out", "in"]) .default("both") .describe("Which graph edges to follow from the reference node"), }); const graphPathInputSchema = graphInputSchema.extend({ from: z .string() .trim() .min(1, "From reference cannot be empty") .describe("Starting document/node reference"), to: z .string() .trim() .min(1, "To reference cannot be empty") .describe("Target document/node reference"), maxDepth: z .number() .int() .min(1) .max(12) .default(6) .describe("Maximum relationship hops to search"), }); export const graphQueryInputSchema = z.object({ ref: z .string() .trim() .min(1, "Reference cannot be empty") .describe( "Root document reference for typed-edge traversal: gno URI, docid, or collection/path" ), direction: z .enum(["both", "out", "in"]) .default("both") .describe("Which typed edges to traverse"), edgeType: z .string() .trim() .min(1) .optional() .describe("Semantic edge type filter, e.g. mentions or works_at"), relation: z .string() .trim() .min(1) .optional() .describe("Alias for edgeType; must match edgeType when both are set"), maxDepth: z .number() .int() .min(1) .max(6) .optional() .describe("Maximum typed-edge hops to traverse"), depth: z .number() .int() .min(1) .max(6) .optional() .describe("Alias for maxDepth"), maxNodes: z .number() .int() .min(1) .max(1000) .default(100) .describe("Returned node cap"), frontierLimit: z .number() .int() .min(1) .max(1000) .default(100) .describe("Per-depth frontier cap"), visitedLimit: z .number() .int() .min(1) .max(5000) .default(500) .describe("SQL traversal visited-row cap"), }); // ───────────────────────────────────────────────────────────────────────────── // Tool Result Type // ───────────────────────────────────────────────────────────────────────────── export interface ToolResult { [x: string]: unknown; content: Array<{ type: "text"; text: string }>; structuredContent?: { [x: string]: unknown }; isError?: boolean; } // ───────────────────────────────────────────────────────────────────────────── // DRY Helper: Exception Firewall + Mutex + Response Shaping // ───────────────────────────────────────────────────────────────────────────── export async function runTool( ctx: ToolContext, name: string, fn: () => Promise, formatText: (data: T) => string ): Promise { // Check shutdown if (ctx.isShuttingDown()) { return { isError: true, content: [{ type: "text", text: "Error: Server is shutting down" }], }; } // Sequential execution via mutex const release = await acquireInferencePermit(() => ctx.toolMutex.acquire()); try { assertInferenceActive(); const data = await (ctx.runWithSnapshot?.(fn) ?? fn()); assertInferenceActive(); const traceMetadata = data !== null && typeof data === "object" ? (data as Record)[RETRIEVAL_TRACE_METADATA] : undefined; return { content: [{ type: "text", text: formatText(data) }], structuredContent: data as { [x: string]: unknown }, ...(traceMetadata ? { _meta: { gno: { retrievalTrace: traceMetadata } } } : {}), }; } catch (e) { // Exception firewall: never throw, always return isError const message = e instanceof Error ? e.message : String(e); console.error(`[MCP] ${name} error:`, message); const parsedError = parseErrorMessage(message); return { isError: true, content: [{ type: "text", text: `Error: ${message}` }], structuredContent: parsedError, }; } finally { release(); } } /** * Run a tool without acquiring the mutex. * For read-only in-memory tools (job_status, list_jobs) that should not block. * Fixes mutex starvation where status queries block while jobs run. */ export async function runToolNoMutex( ctx: ToolContext, name: string, fn: () => Promise, formatText: (data: T) => string ): Promise { // Check shutdown if (ctx.isShuttingDown()) { return { isError: true, content: [{ type: "text", text: "Error: Server is shutting down" }], }; } try { assertInferenceActive(); const data = await (ctx.runWithSnapshot?.(fn) ?? fn()); assertInferenceActive(); return { content: [{ type: "text", text: formatText(data) }], structuredContent: data as { [x: string]: unknown }, }; } catch (e) { // Exception firewall: never throw, always return isError const message = e instanceof Error ? e.message : String(e); console.error(`[MCP] ${name} error:`, message); const parsedError = parseErrorMessage(message); return { isError: true, content: [{ type: "text", text: `Error: ${message}` }], structuredContent: parsedError, }; } } function parseErrorMessage(message: string): { [x: string]: unknown } { const match = message.match(/^([A-Z_]+):\s*(.*)$/); if (match) { return { error: match[1], message: match[2] || message, }; } return { error: "RUNTIME", message, }; } // ───────────────────────────────────────────────────────────────────────────── // Tool Registration // ───────────────────────────────────────────────────────────────────────────── export function registerTools(server: McpServer, ctx: ToolContext): void { // Tool IDs use underscores (MCP pattern: ^[a-zA-Z0-9_-]{1,64}$) const profile = ctx.toolProfile ?? DEFAULT_MCP_TOOL_PROFILE; const registerTool = createProfileToolRegistrar(server, profile); // The core profile serves micro-instruction variants; full keeps the // original strings verbatim (legacy golden + tool-profile byte-compat). const describe = (name: string, fullDescription: string) => profileToolDescription(profile, name, fullDescription); registerTool( "gno_context", { description: describe("gno_context", MCP_TOOL_DESCRIPTIONS.context), inputSchema: contextBuildSurfaceSchema, }, (args) => handleContext(args, ctx) ); registerTool( "gno_context_verify", { description: MCP_TOOL_DESCRIPTIONS.contextVerify, inputSchema: contextVerifySurfaceSchema, }, (args) => handleContextVerify(args, ctx) ); registerTool( "gno_ask", { description: MCP_TOOL_DESCRIPTIONS.ask, // The advertised 2025-11-25 schema is the non-strict shape (SDK v1 // registered `.shape`); the handler still parses with the strict schema. inputSchema: z.object(askInputSchema.shape), }, (args) => handleAsk(args, ctx) ); registerTool( "gno_recall", { description: describe("gno_recall", MCP_TOOL_DESCRIPTIONS.recall), inputSchema: recallInputSchema, annotations: RECALL_MCP_ANNOTATIONS, }, (args, request) => handleRecall(args, ctx, { clientName: server.server.getClientVersion()?.name, sessionId: request.sessionId, requestIdentity: ctx.getRequestIdentity?.(), }) ); registerTool( "gno_search", { description: describe("gno_search", MCP_TOOL_DESCRIPTIONS.search), inputSchema: searchInputSchema, }, (args) => handleSearch(args, ctx) ); registerTool( "gno_vsearch", { description: MCP_TOOL_DESCRIPTIONS.vsearch, inputSchema: vsearchInputSchema, }, (args) => handleVsearch(args, ctx) ); registerTool( "gno_query", { description: describe("gno_query", MCP_TOOL_DESCRIPTIONS.query), inputSchema: queryInputSchema, }, (args) => handleQuery(args, ctx) ); registerTool( "gno_query_diagnose", { description: MCP_TOOL_DESCRIPTIONS.queryDiagnose, inputSchema: queryDiagnoseInputSchema, }, (args) => handleQueryDiagnose(args, ctx) ); registerTool( "gno_get", { description: describe("gno_get", MCP_TOOL_DESCRIPTIONS.get), inputSchema: getInputSchema, }, (args) => handleGet(args, ctx) ); registerTool( "gno_section", { description: MCP_TOOL_DESCRIPTIONS.section, inputSchema: sectionInputSchema, outputSchema: sectionOutputSchema, annotations: SECTION_MCP_ANNOTATIONS, }, (args) => handleSection(args, ctx) ); registerTool( "gno_multi_get", { description: describe("gno_multi_get", MCP_TOOL_DESCRIPTIONS.multiGet), inputSchema: multiGetInputSchema, }, (args) => handleMultiGet(args, ctx) ); registerTool( "gno_peek", { description: MCP_TOOL_DESCRIPTIONS.peek, inputSchema: peekInputSchema, annotations: PEEK_MCP_ANNOTATIONS, }, (args) => handlePeek(args, ctx) ); registerTool( "gno_status", { description: MCP_TOOL_DESCRIPTIONS.status, inputSchema: statusInputSchema, }, (args) => handleStatus(args, ctx) ); registerTool( "gno_audit", { description: MCP_TOOL_DESCRIPTIONS.audit, inputSchema: auditInputSchema, annotations: AUDIT_MCP_ANNOTATIONS, }, (args, request) => handleAudit(args, ctx, request.mcpReq.signal) ); registerTool( "gno_egress_policy_get", { description: "Show one collection's configured and effective egress policy, provenance source, and confirmation-bound version.", inputSchema: egressPolicyGetInputSchema, }, (args) => handleEgressPolicyGet(args, ctx) ); registerTool( "gno_egress_check", { description: "Check or explain an exact action, destination, caller, content class, and collection scope without performing the action.", inputSchema: egressCheckInputSchema, }, (args) => handleEgressCheck(args, ctx) ); registerTool( "gno_egress_audit_list", { description: "List content-free local egress decisions newest-first through an opaque cursor.", inputSchema: egressAuditListInputSchema, }, (args) => handleEgressAuditList(args, ctx) ); registerTool( "gno_egress_audit_show", { description: "Inspect one content-free local egress decision receipt.", inputSchema: egressAuditIdInputSchema, }, (args) => handleEgressAuditShow(args, ctx) ); registerTool( "gno_egress_audit_status", { description: "Show local egress audit retention and storage status.", inputSchema: egressAuditStatusInputSchema, }, (args) => handleEgressAuditStatus(args, ctx) ); registerTool( "gno_changes", { description: describe( "gno_changes", "List retained metadata-only document changes with opaque cursor pagination and retention disclosure." ), inputSchema: changesInputSchema, }, (args) => handleChanges(args, ctx) ); registerTool( "gno_diff", { description: "Inspect one retained metadata-only structural document change. Source bodies are never returned.", inputSchema: diffInputSchema, }, (args) => handleDiff(args, ctx) ); registerTool( "gno_impact", { description: "Find bounded inbound typed, wiki, and Markdown dependencies with deterministic evidence paths.", inputSchema: impactInputSchema, }, (args) => handleImpact(args, ctx) ); registerTool( "gno_trace_list", { description: "List bounded metadata-only summaries of private local retrieval traces. Raw replay queries are omitted from history.", inputSchema: traceListInputSchema, }, (args) => handleTraceList(args, ctx) ); registerTool( "gno_trace_show", { description: "Inspect one bounded local retrieval trace. Replay-mode content is returned only for the explicitly requested trace.", inputSchema: traceShowInputSchema, }, (args) => handleTraceShow(args, ctx) ); registerTool( "gno_list_tags", { description: "List all tags with document counts. Use prefix to filter hierarchical tags (e.g. 'project/').", inputSchema: listTagsInputSchema, }, (args) => handleListTags(args, ctx) ); registerTool( "gno_links", { description: "Get outgoing wiki ([[links]]) and markdown links from one document. Use after gno_query when you need immediate local expansion from a known source document; use gno_graph_neighbors for bidirectional graph navigation.", inputSchema: linksInputSchema, }, (args) => handleLinks(args, ctx) ); registerTool( "gno_backlinks", { description: "Find all documents that link TO a given document. Use after gno_query/gno_get to discover incoming references around a known target; use gno_graph_path for 'how are X and Y connected?' questions.", inputSchema: backlinksInputSchema, }, (args) => handleBacklinks(args, ctx) ); registerTool( "gno_similar", { description: "Find semantically similar documents using vector embeddings. Use for local expansion when wording differs; use gno_query as the default retrieval entry point and gno_graph_neighbors/path for explicit relationship questions.", inputSchema: similarInputSchema, }, (args) => handleSimilar(args, ctx) ); registerTool( "gno_graph", { description: "Get graph report/stats plus nodes/edges for corpus navigation. Use for unfamiliar corpus structure, hubs/isolates/unresolved links, or custom graph analysis; do not replace normal retrieval with this. Start with gno_query for content questions, then graph tools for relationship context, then gno_get for targeted reads.", inputSchema: graphInputSchema, }, (args) => handleGraph(args, ctx) ); registerTool( "gno_graph_query", { description: "Run bounded traversal over typed doc_edges from one root document. Use for explicit relationship questions like 'what does Alice work_at within 2 hops?' or to inspect typed graph hints; returns schemaVersion, root, nodes, edges, caps, and truncation.", inputSchema: graphQueryInputSchema, }, (args) => handleGraphQuery(args, ctx) ); registerTool( "gno_graph_neighbors", { description: "Find graph neighbors around a document/node. Use for relationship questions, missed obvious related docs, or unfamiliar corpus navigation after gno_query identifies a seed; returns incoming/outgoing wiki, markdown, and optional similarity edges. Follow with gno_get for targeted reads.", inputSchema: graphNeighborsInputSchema, }, (args) => handleGraphNeighbors(args, ctx) ); registerTool( "gno_graph_path", { description: "Find the shortest relationship path between two documents/nodes. Use for prompts like 'how are X and Y connected?' or to explain corpus relationships. Use gno_query for finding candidate refs first, then gno_get on path nodes for evidence.", inputSchema: graphPathInputSchema, }, (args) => handleGraphPath(args, ctx) ); if (ctx.enableWrite) { registerTool( "gno_egress_policy_set", { description: "Set one collection policy. Relaxation requires confirmation bound to the current policy and version.", inputSchema: egressPolicySetInputSchema, }, (args) => handleEgressPolicySet(args, ctx) ); registerTool( "gno_egress_audit_delete", { description: "Delete exactly one local egress audit receipt with physical cleanup status.", inputSchema: egressAuditIdInputSchema, }, (args) => handleEgressAuditDelete(args, ctx) ); registerTool( "gno_egress_audit_purge", { description: "Purge local egress audit receipts only. Requires confirm=true.", inputSchema: egressAuditPurgeInputSchema, }, (args) => handleEgressAuditPurge(args, ctx) ); registerTool( "gno_trace_label", { description: "Append an explicit relevant, irrelevant, or missing_expected judgment to a local retrieval trace.", inputSchema: traceLabelInputSchema, }, (args) => handleTraceLabel(args, ctx) ); registerTool( "gno_trace_export", { description: "Build a deterministic local agentic receipt from one or more immutable terminal retrieval traces.", inputSchema: traceExportInputSchema, }, (args) => handleTraceExport(args, ctx) ); registerTool( "gno_trace_delete", { description: "Delete one private local retrieval trace and all owned receipt records.", inputSchema: traceDeleteInputSchema, }, (args) => handleTraceDelete(args, ctx) ); registerTool( "gno_trace_purge", { description: "Purge every private local retrieval trace receipt. Requires confirm=true.", inputSchema: tracePurgeInputSchema, }, (args) => handleTracePurge(args, ctx) ); registerTool( "gno_remember", { description: describe("gno_remember", MCP_TOOL_DESCRIPTIONS.remember), inputSchema: rememberInputSchema, annotations: REMEMBER_MCP_ANNOTATIONS, }, (args, request) => handleRemember(args, ctx, { clientName: server.server.getClientVersion()?.name, sessionId: request.sessionId, requestIdentity: ctx.getRequestIdentity?.(), }) ); registerTool( "gno_capture", { description: describe( "gno_capture", "Create a new document in a collection. Writes to disk. Does NOT auto-embed; run gno_index after to make it searchable via vector search." ), inputSchema: captureInputSchema, }, (args) => handleCapture(args, ctx) ); registerTool( "gno_add_collection", { description: "Add a directory as a new collection and start indexing. Returns a job ID for tracking.", inputSchema: addCollectionInputSchema, }, (args) => handleAddCollection(args, ctx) ); registerTool( "gno_sync", { description: "Sync files from disk into the index (FTS only, no embeddings). Does NOT auto-embed; run gno_embed after if vector search needed.", inputSchema: syncInputSchema, }, (args) => handleSync(args, ctx) ); registerTool( "gno_embed", { description: "Generate vector embeddings for all unembedded chunks, optionally scoped to one collection. Async: returns a job ID. Poll with gno_job_status.", inputSchema: embedInputSchema, }, (args) => handleEmbed(args, ctx) ); registerTool( "gno_index", { description: "Full index: sync files from disk + generate embeddings. Async: returns a job ID. Poll with gno_job_status.", inputSchema: indexInputSchema, }, (args) => handleIndex(args, ctx) ); registerTool( "gno_remove_collection", { description: "Remove a collection from config and delete its indexed data.", inputSchema: removeCollectionInputSchema, }, (args) => handleRemoveCollection(args, ctx) ); registerTool( "gno_clear_collection_embeddings", { description: "Remove stale or all embeddings for one collection.", inputSchema: clearCollectionEmbeddingsInputSchema, }, (args) => handleClearCollectionEmbeddings(args, ctx) ); registerTool( "gno_create_folder", { description: "Create a folder inside an existing collection.", inputSchema: createFolderInputSchema, }, (args) => handleCreateFolder(args, ctx) ); registerTool( "gno_rename_note", { description: 'Preview or apply a reference-safe rename of an editable note. Use action=preview first, then action=apply with the exact planDigest, confirmation="apply", confirm=true, and schemaVersion="1.0". Annotations are hints only; --enable-write remains authoritative.', inputSchema: renameNoteInputSchema, annotations: RENAME_NOTE_MCP_ANNOTATIONS, }, (args) => handleRenameNote(args, ctx) ); registerTool( "gno_move_note", { description: 'Preview or apply a reference-safe same-collection move of an editable note. Use action=preview first, then action=apply with the exact planDigest, confirmation="apply", confirm=true, and schemaVersion="1.0". Annotations are hints only; --enable-write remains authoritative.', inputSchema: moveNoteInputSchema, annotations: MOVE_NOTE_MCP_ANNOTATIONS, }, (args) => handleMoveNote(args, ctx) ); registerTool( "gno_duplicate_note", { description: "Duplicate an editable note into the current or another folder.", inputSchema: duplicateNoteInputSchema, }, (args) => handleDuplicateNote(args, ctx) ); } registerTool( "gno_job_status", { description: "Check status of an async job (embed, index). Returns progress percentage and completion state.", inputSchema: jobStatusInputSchema, }, (args) => handleJobStatus(args, ctx) ); registerTool( "gno_list_jobs", { description: "List active and recently completed async jobs with their status and progress.", inputSchema: listJobsInputSchema, }, (args) => handleListJobs(args, ctx) ); }