diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 000000000..c713b712a --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,2 @@ +# Deleted README placeholder from PR #2458; no credential was present. +c9fdab17f25ebaf332fba6e6ba55ee328f20fe66:README.md:curl-auth-header:348 diff --git a/README.md b/README.md index 907bc2984..670ea3f4a 100644 --- a/README.md +++ b/README.md @@ -319,6 +319,33 @@ codex plugin marketplace add abhigyanpatwari/GitNexus +
+MCP read-only mode + +Set `GITNEXUS_MCP_READ_ONLY=1` before starting the MCP server to expose only the proven single-repository read surface. Raw `cypher`, rename and group tools, group routing, and group resources are omitted from discovery and rejected before backend dispatch. Tool descriptions and generated setup/context resources are scrubbed so they do not recommend unavailable routes. + +The default is unchanged when the variable is unset or `0`. Any other value fails server startup rather than silently weakening the policy. + +
+ +
+MCP repository policy + +Set `GITNEXUS_MCP_ALLOWED_REPOS` to a comma-separated list of canonical registry names or absolute indexed paths. Entries are trimmed, resolved against the registry, and deduplicated at startup. When exactly one repository is allowed it becomes the implicit default; when several are allowed, callers must select one unless `GITNEXUS_MCP_DEFAULT_REPO` is also set. + +The default repository must resolve to an allowed repository. Invalid, ambiguous, blank, or mismatched configuration fails startup before stdio or HTTP begins serving. The allowlist applies to tools, aliases, discovery, resources, templates, implicit resolution, and embedded HTTP; hidden repository details are not included in selection errors. Setting only `GITNEXUS_MCP_DEFAULT_REPO` chooses a default without restricting explicit repository selections. An allowed repository whose name is duplicated in the registry must be configured by path, and its context resource is only served for the unique name form. + +
+ +
+MCP response budgets + +The `query`, `context`, and `impact` tools accept an optional positive-integer `maxTokens` argument. It bounds the complete formatted MCP response, including hints and error text, using a deterministic four-UTF-8-bytes-per-token estimate. When truncation is required, the response ends with `…` and remains valid UTF-8. + +Set `GITNEXUS_MCP_DEFAULT_MAX_TOKENS` to apply the same guardrail when callers do not send `maxTokens`. An explicit tool argument takes precedence. Leaving both unset preserves the existing response byte-for-byte; this is a transport guardrail, not semantic pagination or an exact model-specific tokenizer limit. + +
+ ## CLI Reference Everyday commands: @@ -328,6 +355,7 @@ gitnexus setup # Configure MCP for detected editors (one-time; gitnexus analyze [path] # Index a repository (or update a stale index) gitnexus mcp # Start MCP server (stdio) — serves all indexed repos gitnexus serve # Start local HTTP server (multi-repo) for web UI connection +gitnexus eval-server # Start lightweight evaluation HTTP tools (loopback by default) gitnexus list # List all indexed repositories gitnexus status # Show index status for current repo gitnexus clean # Delete index for current repo @@ -337,6 +365,19 @@ gitnexus uninstall # Preview removal of GitNexus MCP/skills/hooks You can also query the graph directly from the terminal — `gitnexus query`, `context`, `impact`, `trace`, `cypher`, `detect-changes`, and `check` mirror the MCP tools of the same names, and `gitnexus doctor` prints runtime platform capabilities. +
+Authenticated eval-server binding + +`gitnexus eval-server` binds to `127.0.0.1` by default. Loopback bindings do not require authentication. Any non-loopback bind, including `0.0.0.0`, a LAN address, or a hostname that resolves to a LAN IPv4 address, requires `GITNEXUS_AUTH_TOKEN`. Every endpoint then requires an exact `Authorization: Bearer ` header. + +```bash +GITNEXUS_AUTH_TOKEN='replace-me' gitnexus eval-server --host 0.0.0.0 +``` + +The token may be set in the shell, `.env.local`, or `.env` in the working directory. Precedence is shell > `.env.local` > `.env`. Only `GITNEXUS_AUTH_TOKEN` is read from those files; their other values are not added to the process environment. Keep token files uncommitted. + +
+
All analyze flags @@ -434,6 +475,7 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max | `GITNEXUS_WORKER_POOL_SIZE` | `cores - 1`, capped at 16 | Parse worker pool size (must be ≥ 1). Equivalent to `--workers `. The worker pool is the sole parse path — there is no sequential parser, so `0` is rejected with an actionable error (the pool self-heals via quarantine + respawn). | Constrained containers (cgroup CPU limits) or CI runners with explicit quotas. To narrow down a worker crash set `1` for a single-worker pool — not `0`. | | `GITNEXUS_PARSE_CHUNK_CONCURRENCY` | `2` | Number of chunks whose file contents may be read into memory in parallel while the pool dispatches the current chunk. Worker dispatch itself stays serial. | Repos large enough to chunk (multi-MB total source) where disk I/O is a measurable fraction of analyze wall-clock. | | `GITNEXUS_VERBOSE` | unset | When `1`, enables verbose ingestion logs (skipped-file warnings, per-chunk throughput, parse-cache stats). Equivalent to `--verbose`. | Debugging an analyze that "completed" but seems to have missed files; tuning `--workers` / chunk concurrency against observable throughput. | +| `GITNEXUS_AUTH_TOKEN` | unset | Bearer token required when `eval-server` binds beyond loopback. May also be read from `.env.local` or `.env`; shell values take precedence. | Exposing the evaluation HTTP tools to a container, VM, or LAN. | | `GITNEXUS_PROFILE_DEFERRED` | unset | When `1`, emits `[deferred-profile]` timing/progress logs for the post-chunk deferred resolution band (imports → heritage → buildHeritageMap → legacy call resolution). Implied by `GITNEXUS_VERBOSE`. | Diagnosing analyze stalls in "Resolving calls (all chunks)" on large Java/Kotlin repos (issue #1741) without the full verbose ingestion noise. | | `GITNEXUS_PROFILE_DEFERRED_SLOW_MS` | `3000` (verbose) / `5000` | Per-file threshold in ms above which `processCallsFromExtracted` emits a `slow file …` log line. Parsed via `Number()`: accepts integers (`5000`), scientific notation (`2.5e3`), decimals (`.5`), and hex (`0x10`). Non-finite or non-positive values fall back to the default. | Hunting a few outlier files dominating the deferred call-resolution stage; lower to surface more, raise to focus only on the worst. | | `PROF_LBUG_LOAD` | unset | When `1`, emits one `[lbug-load prof]` summary line per `loadGraphToLbug` call breaking the graph-DB persistence wall into stages (`csv-emit` / `copy-nodes` / `copy-rels` / `fallback` / `total`) plus node & edge counts. Zero-cost when unset. | Attributing large-repo analyze wall time across CSV generation vs. LadybugDB `COPY` (issue #2203) — the analyze "emit" timing is the scope-resolution bucket, not this DB-write path. | @@ -450,6 +492,10 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max | `GITNEXUS_CHUNK_BYTE_BUDGET` | `2097152` (2 MB) | Chunk boundary used for cache-key composition and dispatch. Smaller = finer-grained cache hits but more dispatch overhead. | Tuning incremental-analyze cache behavior on monorepos. | | `GITNEXUS_NO_GITIGNORE` | unset | When set, skips `.gitignore` parsing. `.gitnexusignore` is still honored. | Indexing a repo whose `.gitignore` excludes files you actually want indexed (e.g., generated code committed for cross-repo lookup). | | `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips the vendored grammar materialize for `tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`, and `tree-sitter-kotlin` at install time (and the Dart/Proto source builds). Those four won't be parsed; the install still succeeds. | Installing on a host without a C++ toolchain or where the vendored prebuilds don't match; willing to skip Dart/Proto/Swift/Kotlin parsing. | +| `GITNEXUS_MCP_READ_ONLY` | unset | Set to `1` to expose only proven single-repository read tools and resources; `0` disables the policy and any other value fails startup. | The MCP server runs in an environment where graph mutation, raw Cypher, and cross-repository group routing must be unavailable. | +| `GITNEXUS_MCP_ALLOWED_REPOS` | unset | Comma-separated allowlist of canonical indexed repository names or absolute paths. Invalid, ambiguous, or blank entries fail startup. | One MCP process must expose only a bounded subset of the repositories in the global registry. | +| `GITNEXUS_MCP_DEFAULT_REPO` | unset | Canonical indexed repository name or absolute path used when a tool or resource omits its repository. Must belong to the allowlist when one is set. | Several repositories are available but unqualified MCP calls should resolve deterministically. | +| `GITNEXUS_MCP_DEFAULT_MAX_TOKENS` | unset | Default positive-integer response budget for MCP `query`, `context`, and `impact`, estimated at four UTF-8 bytes per token. Explicit `maxTokens` wins. | Long MCP responses consume too much model context and callers cannot reliably add a per-request budget. |
diff --git a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts index f5d3dd0bf..f381bb12e 100644 --- a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts +++ b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts @@ -93,6 +93,7 @@ export interface FinalizeHooks { targetRaw: string, fromFile: string, workspaceIndex: WorkspaceIndex, + parsedImport?: ParsedImport, ): string | readonly string[] | null; /** @@ -348,7 +349,12 @@ function makeEdgeDrafts( ]; } - const targetFile = hooks.resolveImportTarget(parsed.targetRaw ?? '', file.filePath, workspace); + const targetFile = hooks.resolveImportTarget( + parsed.targetRaw ?? '', + file.filePath, + workspace, + parsed, + ); // Edge is unresolvable at the file level — mark unresolved now. if (targetFile === null) { diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts index bf837639e..6012694cf 100644 --- a/gitnexus-shared/src/scope-resolution/types.ts +++ b/gitnexus-shared/src/scope-resolution/types.ts @@ -105,6 +105,9 @@ export type ParsedImport = readonly localName: string; readonly importedName: string; readonly targetRaw: string; + /** Provider-specific imported symbol category when module and symbol + * namespaces have distinct resolution rules (for example PHP). */ + readonly importedSymbolKind?: 'type' | 'function' | 'const'; /** * Set by providers when `targetRaw` already names the imported symbol * rather than only its containing module. Consumers that compose @@ -127,6 +130,8 @@ export type ParsedImport = readonly alias: string; readonly targetRaw: string; /** See the same field on the `named` variant. */ + readonly importedSymbolKind?: 'type' | 'function' | 'const'; + /** See the same field on the `named` variant. */ readonly targetIncludesImportedName?: boolean; } /** diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index d3383ae84..3a16d856a 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -39,9 +39,9 @@ "_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED \u2014 @declaration.macro/@reference.macro + MacroRegistry \u2192 USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures \u2014 pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f." }, "php": { - "fingerprint": "bc2c27c5ba26d5aea61142a2a99fb772222f5b969205260eb7a71b4c0bd73cdb", + "fingerprint": "31c9e3f3cb7094a2bf9021cf9db859036e002f8b44605cd993b470fc600e97cb", "scaling_budget": 1.5, - "_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04).", + "_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04). | #2481/#2482: PHP imports carry a symbol-kind capture so function/constant imports resolve by declaring file; capture shape changes, scaling remains linear (~1.04).", "_note": "PR #1931: F53 import multi-clause, F54 enum_case, F55 anonymous_class \u2014 fixture count 138\u2192140, fingerprint drift expected." }, "ruby": { diff --git a/gitnexus/src/cli/eval-server.ts b/gitnexus/src/cli/eval-server.ts index ef900a8c6..31289efb2 100644 --- a/gitnexus/src/cli/eval-server.ts +++ b/gitnexus/src/cli/eval-server.ts @@ -16,7 +16,8 @@ * Usage: * gitnexus eval-server # default port 4848, binds 127.0.0.1 * gitnexus eval-server --port 4848 # explicit port - * gitnexus eval-server --host 0.0.0.0 # reachable from other VMs / containers + * GITNEXUS_AUTH_TOKEN=... gitnexus eval-server --host 0.0.0.0 + * GITNEXUS_AUTH_TOKEN=... gitnexus eval-server --host devbox.local * gitnexus eval-server --idle-timeout 300 # auto-shutdown after 300s idle * * READY signal format: GITNEXUS_EVAL_SERVER_READY:: @@ -31,8 +32,11 @@ import http from 'http'; import crypto from 'node:crypto'; +import { lookup } from 'node:dns/promises'; import { isIPv4, isIPv6 } from 'node:net'; -import { writeSync } from 'node:fs'; +import { readFileSync, writeSync } from 'node:fs'; +import path from 'node:path'; +import { parseEnv } from 'node:util'; import { LocalBackend, type RepoListing, @@ -62,6 +66,112 @@ export function validateHost(raw: string): string | null { return null; } +type EvalServerHostLookup = (hostname: string) => Promise; + +function isHostname(raw: string): boolean { + if (!raw || raw.length > 253 || /^[\d.]+$/.test(raw)) return false; + return raw + .split('.') + .every( + (label) => + label.length > 0 && + label.length <= 63 && + /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/.test(label), + ); +} + +/** Resolve a DNS bind name once so validation and listen() use the same concrete address. */ +export async function resolveEvalServerBindHost( + raw: string, + resolveHostname: EvalServerHostLookup = async (hostname) => + (await lookup(hostname, { family: 4 })).address, +): Promise { + const directHost = validateHost(raw); + if (directHost && directHost !== 'localhost') return directHost; + if (directHost !== 'localhost' && !isHostname(raw)) return null; + + try { + const address = await resolveHostname(raw); + return isIPv4(address) ? address : null; + } catch { + return null; + } +} + +function readAuthTokenFile(filePath: string): string | undefined { + try { + return parseEnv(readFileSync(filePath, 'utf8')).GITNEXUS_AUTH_TOKEN?.trim() || undefined; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw new Error(`Unable to read eval-server authentication from ${filePath}`, { cause: error }); + } +} + +/** Resolve the bearer token from the shell, then .env.local, then .env. */ +export function resolveEvalServerAuthToken( + env: NodeJS.ProcessEnv, + cwd: string = process.cwd(), +): string | undefined { + if (Object.hasOwn(env, 'GITNEXUS_AUTH_TOKEN')) { + return env.GITNEXUS_AUTH_TOKEN?.trim() || undefined; + } + + return ( + readAuthTokenFile(path.join(cwd, '.env.local')) ?? readAuthTokenFile(path.join(cwd, '.env')) + ); +} + +/** True only for literal loopback addresses; DNS names are resolved before this check. */ +export function isEvalServerLoopbackHost(host: string): boolean { + return host === 'localhost' || host === '::1' || (isIPv4(host) && host.startsWith('127.')); +} + +/** + * Resolve the bearer token for a concrete bind host. An unreadable `.env` / + * `.env.local` only matters when the binding actually requires a token, so + * loopback binds degrade to a warning instead of refusing to start; any + * non-loopback bind keeps the fail-closed error. + */ +export function resolveEvalServerAuthTokenForHost( + host: string, + env: NodeJS.ProcessEnv, + cwd: string = process.cwd(), +): { token?: string; warning?: string } { + try { + return { token: resolveEvalServerAuthToken(env, cwd) }; + } catch (error) { + if (isEvalServerLoopbackHost(host)) { + const reason = error instanceof Error ? error.message : String(error); + return { warning: `${reason} Continuing without authentication on loopback host ${host}.` }; + } + throw error; + } +} + +/** Refuse exposure of the eval-server query surface without authentication. */ +export function assertSecureEvalServerBinding(host: string, authToken: string | undefined): void { + if (!authToken && !isEvalServerLoopbackHost(host)) { + throw new Error( + `Refusing to start eval-server on non-loopback host ${host} without authentication. ` + + 'Set GITNEXUS_AUTH_TOKEN or bind to 127.0.0.1, localhost, or ::1.', + ); + } +} + +/** Validate the exact Bearer header while keeping token comparison constant-time. */ +export function isEvalServerBearerAuthorized( + authorization: string | string[] | undefined, + authToken: string | undefined, +): boolean { + if (!authToken) return true; + + const expected = Buffer.from(`Bearer ${authToken}`, 'utf8'); + const supplied = typeof authorization === 'string' ? Buffer.from(authorization, 'utf8') : null; + const sameLength = supplied?.length === expected.length; + const candidate = sameLength && supplied ? supplied : Buffer.alloc(expected.length); + return crypto.timingSafeEqual(candidate, expected) && sameLength; +} + // ─── Text Formatters ────────────────────────────────────────────────── // Convert structured JSON results into compact, LLM-friendly text. // Design: minimize tokens, maximize actionability. @@ -650,21 +760,45 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise { + if (!isEvalServerBearerAuthorized(req.headers.authorization, authToken)) { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('WWW-Authenticate', 'Bearer'); + res.writeHead(401); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + resetIdleTimer(); try { @@ -807,7 +949,7 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise 0) { bannerLines.push(` Auto-shutdown after ${idleTimeoutSec}s idle`); } @@ -863,6 +1008,7 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise 0 ? idleTimeoutSec : undefined, + authEnabled: Boolean(authToken), endpoints: [ 'POST /tool/query', 'POST /tool/context', diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index 0a64c35ca..f60825593 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -273,7 +273,7 @@ export const en = { 'help.option.cypher.limit': 'Max result rows to return', 'help.option.check.cycles': 'Detect circular imports and fail when any are found', 'help.option.evalServer.host': - 'Bind address (default: 127.0.0.1, use 0.0.0.0 to expose to all interfaces)', + 'Bind address or resolvable hostname (default: 127.0.0.1; non-loopback requires GITNEXUS_AUTH_TOKEN; hostnames resolve to IPv4)', 'help.option.evalServer.idleTimeout': 'Auto-shutdown after N seconds idle (0 = disabled)', 'help.option.embeddings.install.cuda': "Also download the CUDA GPU binaries (runs onnxruntime-node's NuGet postinstall; set GLOBAL_AGENT_HTTPS_PROXY behind a proxy)", diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index 2059fca9a..a1d9a37dc 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -255,7 +255,8 @@ export const zhCN = { 'help.option.detectChanges.limit': '最多返回的已变更符号数', 'help.option.cypher.limit': '最多返回的结果行数', 'help.option.check.cycles': '检测循环导入,并在发现循环时失败', - 'help.option.evalServer.host': '绑定地址(默认:127.0.0.1;用 0.0.0.0 暴露到所有网卡)', + 'help.option.evalServer.host': + '绑定地址或可解析的主机名(默认:127.0.0.1;非回环绑定需要 GITNEXUS_AUTH_TOKEN;主机名解析为 IPv4)', 'help.option.evalServer.idleTimeout': '空闲 N 秒后自动关闭(0 = 禁用)', 'help.option.embeddings.install.cuda': '同时下载 CUDA GPU 二进制文件(运行 onnxruntime-node 的 NuGet postinstall;代理后请设置 GLOBAL_AGENT_HTTPS_PROXY)', diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 43f4b1bec..83ac9ce44 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -436,7 +436,7 @@ program .option('-p, --port ', 'Port number', '4848') .option( '--host ', - 'Bind address (default: 127.0.0.1, use 0.0.0.0 to expose to all interfaces)', + 'Bind address or resolvable hostname (default: 127.0.0.1; non-loopback requires GITNEXUS_AUTH_TOKEN; hostnames resolve to IPv4)', ) .option('--idle-timeout ', 'Auto-shutdown after N seconds idle (0 = disabled)', '0') .action(createLbugLazyAction(() => import('./eval-server.js'), 'evalServerCommand')); diff --git a/gitnexus/src/cli/mcp.ts b/gitnexus/src/cli/mcp.ts index 8b191db1d..9ad39268d 100644 --- a/gitnexus/src/cli/mcp.ts +++ b/gitnexus/src/cli/mcp.ts @@ -53,11 +53,13 @@ export const mcpCommand = async (options?: { // stdout at module init, but transitive deps (pino, pino-pretty, the // worker-thread transport) could in theory, and the import-closure // regression test enforces the leaf invariant. - const [{ startMCPServer }, { LocalBackend }, { logger }] = await Promise.all([ - import('../mcp/server.js'), - import('../mcp/local/local-backend.js'), - import('../core/logger.js'), - ]); + const [{ startMCPServer }, { LocalBackend }, { logger }, { createMcpRepositoryPolicy }] = + await Promise.all([ + import('../mcp/server.js'), + import('../mcp/local/local-backend.js'), + import('../core/logger.js'), + import('../mcp/repository-policy.js'), + ]); // Missing-optional-grammar warnings are intentionally NOT emitted here. // `gitnexus analyze` already warns at index time, filtered by the repo's @@ -71,7 +73,8 @@ export const mcpCommand = async (options?: { const backend = new LocalBackend(); await backend.init(); - const repos = await backend.listRepos(); + const repositoryPolicy = await createMcpRepositoryPolicy(backend); + const repos = await repositoryPolicy.scopeBackend(backend).listRepos(); if (repos.length === 0) { // Operator-actionable but the server still starts and serves; warn-level, // not error. Tools will discover newly-analyzed repos via lazy refresh. @@ -105,6 +108,7 @@ export const mcpCommand = async (options?: { port, host: options.host ?? '127.0.0.1', authToken: resolveAuthToken(options.authToken, process.env), + repositoryPolicy, }); } catch (err) { logger.error( @@ -117,5 +121,5 @@ export const mcpCommand = async (options?: { } // Start MCP server (serves all repos, discovers new ones lazily) - await startMCPServer(backend); + await startMCPServer(backend, repositoryPolicy); }; diff --git a/gitnexus/src/core/ingestion/community-processor.ts b/gitnexus/src/core/ingestion/community-processor.ts index e5c85ef58..ff892ae73 100644 --- a/gitnexus/src/core/ingestion/community-processor.ts +++ b/gitnexus/src/core/ingestion/community-processor.ts @@ -302,6 +302,7 @@ export const buildCommunityProjection = (knowledgeGraph: KnowledgeGraph): Commun const nodes: CommunityProjectionNode[] = []; const nodeIndexById = new Map(); + const eligibleNodes: GraphNode[] = []; knowledgeGraph.forEachNode((node) => { if (!isCommunitySymbol(node) || !connectedNodes.has(node.id)) return; @@ -309,6 +310,12 @@ export const buildCommunityProjection = (knowledgeGraph: KnowledgeGraph): Commun // get absorbed into their single neighbor's community, but cost iteration time. if (isLarge && (nodeDegree.get(node.id) || 0) < 2) return; + eligibleNodes.push(node); + }); + + eligibleNodes.sort((left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0)); + + for (const node of eligibleNodes) { nodeIndexById.set(node.id, nodes.length); nodes.push({ id: node.id, @@ -316,7 +323,7 @@ export const buildCommunityProjection = (knowledgeGraph: KnowledgeGraph): Commun filePath: node.properties.filePath, type: node.label, }); - }); + } const seenEdges = new Set(); const edges: Array = []; @@ -338,6 +345,7 @@ export const buildCommunityProjection = (knowledgeGraph: KnowledgeGraph): Commun seenEdges.add(edgeKey); edges.push([a, b]); }); + edges.sort(([leftA, leftB], [rightA, rightB]) => leftA - rightA || leftB - rightB); return { nodes, edges, symbolCount, isLarge }; }; diff --git a/gitnexus/src/core/ingestion/filesystem-walker.ts b/gitnexus/src/core/ingestion/filesystem-walker.ts index 0af28a958..823b3670f 100644 --- a/gitnexus/src/core/ingestion/filesystem-walker.ts +++ b/gitnexus/src/core/ingestion/filesystem-walker.ts @@ -83,6 +83,11 @@ export const walkRepositoryPaths = async ( } } + // Filesystem/glob traversal order is not stable across filesystems or repeated + // scans. Canonicalize once at the scan boundary so every downstream phase sees + // the same repository order. + entries.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)); + if (skippedLarge > 0) { const isDefault = maxFileSizeBytes === DEFAULT_MAX_FILE_SIZE_BYTES; const isOverrideUnset = !process.env.GITNEXUS_MAX_FILE_SIZE; diff --git a/gitnexus/src/core/ingestion/languages/php/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/php/import-decomposer.ts index bcf57d99d..f3d6cfc73 100644 --- a/gitnexus/src/core/ingestion/languages/php/import-decomposer.ts +++ b/gitnexus/src/core/ingestion/languages/php/import-decomposer.ts @@ -22,9 +22,11 @@ import type { Capture, CaptureMatch } from 'gitnexus-shared'; import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; export type PhpImportKind = 'namespace' | 'alias' | 'function' | 'const'; +type PhpImportedSymbolKind = 'type' | 'function' | 'const'; interface PhpImportSpec { readonly kind: PhpImportKind; + readonly symbolKind: PhpImportedSymbolKind; /** Full backslash-separated path (backslashes intact): `Foo\Bar\Baz`. */ readonly source: string; /** Local binding name — last source segment for plain imports, the @@ -119,6 +121,7 @@ function parseUseClause(clause: SyntaxNode, qualifier: PhpImportKind): PhpImport if (alias !== '') { return { kind: 'alias', + symbolKind: symbolKindFor(qualifier), source, name: alias, alias, @@ -130,6 +133,7 @@ function parseUseClause(clause: SyntaxNode, qualifier: PhpImportKind): PhpImport return { kind: qualifier, + symbolKind: symbolKindFor(qualifier), source, name: lastSegment(source), atNode: clause, @@ -214,6 +218,7 @@ function parseInnerClause( if (alias !== '') { return { kind: 'alias', + symbolKind: symbolKindFor(qualifier), source, name: alias, alias, @@ -225,6 +230,7 @@ function parseInnerClause( return { kind: qualifier, + symbolKind: symbolKindFor(qualifier), source, name: lastSegment(innerPath), atNode: clause, @@ -237,6 +243,7 @@ function buildImportMatch(stmtNode: SyntaxNode, spec: PhpImportSpec): CaptureMat const m: Record = { '@import.statement': nodeToCapture('@import.statement', stmtNode), '@import.kind': syntheticCapture('@import.kind', spec.atNode, spec.kind), + '@import.symbol-kind': syntheticCapture('@import.symbol-kind', spec.atNode, spec.symbolKind), '@import.source': syntheticCapture('@import.source', spec.atNode, spec.source), '@import.name': syntheticCapture('@import.name', spec.atNode, spec.name), }; @@ -254,6 +261,12 @@ function lastSegment(path: string): string { return parts[parts.length - 1] ?? path; } +function symbolKindFor(kind: PhpImportKind): PhpImportedSymbolKind { + if (kind === 'function') return 'function'; + if (kind === 'const') return 'const'; + return 'type'; +} + /** Find the first named child with a given node type. */ function findNamedChild(node: SyntaxNode, type: string): SyntaxNode | null { for (let i = 0; i < node.namedChildCount; i++) { diff --git a/gitnexus/src/core/ingestion/languages/php/import-target.ts b/gitnexus/src/core/ingestion/languages/php/import-target.ts index ebf7938b3..523c2b1c7 100644 --- a/gitnexus/src/core/ingestion/languages/php/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/php/import-target.ts @@ -15,7 +15,8 @@ * `linkStatus: 'unresolved'`. */ -import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; +import type { ParsedFile, ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; +import type { ImportResolutionContext } from '../../scope-resolution/contract/scope-resolver.js'; import { resolvePhpImportInternal } from '../../import-resolvers/php.js'; import type { ComposerConfig } from '../../language-config.js'; import { readFileSync } from 'node:fs'; @@ -26,6 +27,95 @@ export interface PhpResolveContext { readonly allFilePaths: ReadonlySet; } +function normalizePhpPath(value: string): string { + return value.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, ''); +} + +function namespaceDirectories( + targetRaw: string, + composerConfig: ComposerConfig | null, + resolved: string | null, +): string[] { + const directories = new Set(); + if (resolved !== null) { + const normalizedResolved = normalizePhpPath(resolved); + const separator = normalizedResolved.lastIndexOf('/'); + if (separator >= 0) directories.add(normalizedResolved.slice(0, separator)); + } + + if (composerConfig === null) return [...directories]; + + const normalizedTarget = normalizePhpPath(targetRaw); + const mappings = [...composerConfig.psr4.entries()].sort((left, right) => { + const lengthDifference = right[0].length - left[0].length; + return lengthDifference !== 0 ? lengthDifference : left[0].localeCompare(right[0]); + }); + for (const [namespacePrefix, directoryPrefix] of mappings) { + const normalizedPrefix = normalizePhpPath(namespacePrefix); + if ( + normalizedTarget !== normalizedPrefix && + !normalizedTarget.startsWith(`${normalizedPrefix}/`) + ) { + continue; + } + + const remainder = normalizedTarget.slice(normalizedPrefix.length).replace(/^\//, ''); + const separator = remainder.lastIndexOf('/'); + const relativeNamespace = separator >= 0 ? remainder.slice(0, separator) : ''; + directories.add( + normalizePhpPath( + relativeNamespace === '' ? directoryPrefix : `${directoryPrefix}/${relativeNamespace}`, + ), + ); + break; + } + return [...directories]; +} + +// A scope-resolution pass shares one stable parsedFiles array across imports. +const phpDirectoryIndexCache = new WeakMap< + readonly ParsedFile[], + ReadonlyMap +>(); + +function parentDirectory(filePath: string): string { + const normalizedPath = normalizePhpPath(filePath); + const separator = normalizedPath.lastIndexOf('/'); + return separator < 0 ? '' : normalizedPath.slice(0, separator); +} + +function directoryAliases(filePath: string): string[] { + const normalizedPath = normalizePhpPath(filePath); + const separator = normalizedPath.lastIndexOf('/'); + if (separator < 0) return ['']; + + const parent = normalizedPath.slice(0, separator); + const aliases = new Set([parent]); + const segments = parent.split('/').filter(Boolean); + for (let index = 0; index < segments.length; index++) { + aliases.add(segments.slice(index).join('/')); + } + return [...aliases]; +} + +function filesByDirectory( + parsedFiles: readonly ParsedFile[], +): ReadonlyMap { + const cached = phpDirectoryIndexCache.get(parsedFiles); + if (cached) return cached; + + const mutable = new Map(); + for (const parsed of parsedFiles) { + for (const directory of directoryAliases(parsed.filePath)) { + const files = mutable.get(directory) ?? []; + files.push(parsed); + mutable.set(directory, files); + } + } + phpDirectoryIndexCache.set(parsedFiles, mutable); + return mutable; +} + // ─── loadResolutionConfig ────────────────────────────────────────────────── /** @@ -117,6 +207,7 @@ export function resolvePhpImportTargetInternal( _fromFile: string, allFilePaths: ReadonlySet, resolutionConfig?: unknown, + context?: ImportResolutionContext, ): string | null { if (targetRaw === '') return null; @@ -129,7 +220,7 @@ export function resolvePhpImportTargetInternal( const normalizedFileList = [...allFiles].map((f) => f.replace(/\\/g, '/')); const allFileList = [...allFiles]; - return resolvePhpImportInternal( + const resolved = resolvePhpImportInternal( targetRaw, composerConfig, allFiles, @@ -137,4 +228,52 @@ export function resolvePhpImportTargetInternal( allFileList, undefined, ); + + const parsedImport = context?.parsedImport; + const symbolKind = + parsedImport?.kind === 'named' || parsedImport?.kind === 'alias' + ? parsedImport.importedSymbolKind + : undefined; + if ( + context === undefined || + parsedImport === undefined || + (symbolKind !== 'function' && symbolKind !== 'const') + ) { + return resolved; + } + + const importedName = targetRaw.replace(/\\/g, '/').split('/').filter(Boolean).at(-1); + if (importedName === undefined) return resolved; + + const directories = namespaceDirectories(targetRaw, composerConfig, resolved); + const directoryIndex = filesByDirectory(context.parsedFiles); + const candidateFiles = [ + ...new Set( + directories.flatMap((directory) => { + const files = directoryIndex.get(normalizePhpPath(directory)) ?? []; + // A suffix alias can match directories under different roots (for + // example app/Models and vendor/pkg/app/Models). Picking either root + // would be a guess, so fail closed to the composer resolution instead. + const distinctParents = new Set(files.map((file) => parentDirectory(file.filePath))); + return distinctParents.size > 1 ? [] : files; + }), + ), + ]; + const expectedType = symbolKind === 'function' ? 'Function' : 'Variable'; + const declaringFiles = candidateFiles.filter((parsed) => + parsed.localDefs.some((def) => { + if (def.type !== expectedType) return false; + const simpleName = (def.qualifiedName ?? '').split(/[\\.]/).at(-1); + return simpleName === importedName; + }), + ); + + if (declaringFiles.length > 1) return null; + if (declaringFiles.length === 1) return declaringFiles[0].filePath; + + // PHP constants are not currently emitted as local definitions. A single + // file in the namespace directory is still unambiguous; multiple files must + // fail closed rather than inheriting Set iteration order. + if (symbolKind === 'const' && candidateFiles.length === 1) return candidateFiles[0].filePath; + return resolved; } diff --git a/gitnexus/src/core/ingestion/languages/php/interpret.ts b/gitnexus/src/core/ingestion/languages/php/interpret.ts index 8aa07a736..7c920f9ef 100644 --- a/gitnexus/src/core/ingestion/languages/php/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/php/interpret.ts @@ -20,12 +20,19 @@ export function interpretPhpImport(captures: CaptureMatch): ParsedImport | null const sourceCap = captures['@import.source']; const nameCap = captures['@import.name']; const aliasCap = captures['@import.alias']; + const symbolKindCap = captures['@import.symbol-kind']; const kind = kindCap?.text; if (kind === undefined || sourceCap === undefined) return null; const source = sourceCap.text.trim(); if (source === '') return null; + const importedSymbolKind = + symbolKindCap?.text === 'function' || symbolKindCap?.text === 'const' + ? symbolKindCap.text + : kind === 'function' || kind === 'const' + ? kind + : 'type'; switch (kind) { case 'namespace': { @@ -39,6 +46,7 @@ export function interpretPhpImport(captures: CaptureMatch): ParsedImport | null localName, importedName: localName, targetRaw: source, + importedSymbolKind, }; } case 'alias': { @@ -53,6 +61,7 @@ export function interpretPhpImport(captures: CaptureMatch): ParsedImport | null importedName, alias, targetRaw: source, + importedSymbolKind, }; } case 'function': { @@ -64,6 +73,7 @@ export function interpretPhpImport(captures: CaptureMatch): ParsedImport | null localName, importedName: localName, targetRaw: source, + importedSymbolKind, }; } case 'const': { @@ -74,6 +84,7 @@ export function interpretPhpImport(captures: CaptureMatch): ParsedImport | null localName, importedName: localName, targetRaw: source, + importedSymbolKind, }; } default: diff --git a/gitnexus/src/core/ingestion/languages/php/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/php/scope-resolver.ts index 8b1fcf41b..2b775e0e3 100644 --- a/gitnexus/src/core/ingestion/languages/php/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/php/scope-resolver.ts @@ -354,8 +354,8 @@ const phpScopeResolver: ScopeResolver = { languageProvider: phpProvider, importEdgeReason: 'php-scope: use', - resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => - resolvePhpImportTargetInternal(targetRaw, fromFile, allFilePaths, resolutionConfig), + resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig, context) => + resolvePhpImportTargetInternal(targetRaw, fromFile, allFilePaths, resolutionConfig, context), loadResolutionConfig: (repoPath) => loadPhpComposerConfig(repoPath), diff --git a/gitnexus/src/core/ingestion/languages/rust/range-binding.ts b/gitnexus/src/core/ingestion/languages/rust/range-binding.ts index 285b84d52..4c7224333 100644 --- a/gitnexus/src/core/ingestion/languages/rust/range-binding.ts +++ b/gitnexus/src/core/ingestion/languages/rust/range-binding.ts @@ -89,6 +89,13 @@ export function populateRustRangeBindings( } } } + + // Publish per-type member bindings for the whole workspace before resolving + // assignments. Otherwise an importer processed before its defining file can + // miss a field or identity-method type solely because of file order. + const scopeMap = new Map(parsed.scopes.map((scope) => [scope.id, scope])); + processFieldTypeBindings(tree.rootNode, parsed, scopeMap); + processIdentityMethodBindings(parsed); } for (const parsed of parsedFiles) { @@ -122,8 +129,6 @@ export function populateRustRangeBindings( const moduleScope = parsed.scopes.find((s) => s.kind === 'Module'); if (moduleScope === undefined) continue; - processFieldTypeBindings(tree.rootNode, parsed, scopeMap); - processIdentityMethodBindings(parsed); processForLoops(tree.rootNode, parsed, scopeMap, moduleScope, allReturnTypes); processPatternBindings(tree.rootNode, parsed, scopeMap, moduleScope); processStructDestructuring(tree.rootNode, parsed, scopeMap, moduleScope, allFieldTypes); diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 88d8cd121..c91df2953 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -58,6 +58,39 @@ export interface WorkerExtractedData { parsedFiles: ParsedFile[]; } +type ParsedGraphNode = ParseWorkerResult['nodes'][number]; + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function sourceLine(node: ParsedGraphNode): number { + const value = node.properties.startLine; + return typeof value === 'number' && Number.isFinite(value) ? value : Number.MAX_SAFE_INTEGER; +} + +function compareParsedNodeSourceOrder(left: ParsedGraphNode, right: ParsedGraphNode): number { + const leftPath = typeof left.properties.filePath === 'string' ? left.properties.filePath : ''; + const rightPath = typeof right.properties.filePath === 'string' ? right.properties.filePath : ''; + const fileOrder = compareText(leftPath, rightPath); + if (fileOrder !== 0) return fileOrder; + + const leftLine = sourceLine(left); + const rightLine = sourceLine(right); + if (leftLine !== rightLine) return leftLine < rightLine ? -1 : 1; + + return compareText(left.id, right.id); +} + +function nodesInSourceOrder(nodes: readonly ParsedGraphNode[]): readonly ParsedGraphNode[] { + for (let index = 1; index < nodes.length; index++) { + if (compareParsedNodeSourceOrder(nodes[index - 1], nodes[index]) > 0) { + return [...nodes].sort(compareParsedNodeSourceOrder); + } + } + return nodes; +} + // ============================================================================ // Worker-based parallel parsing // ============================================================================ @@ -95,7 +128,11 @@ export const mergeChunkResults = ( const allParsedFiles: ParsedFile[] = []; for (const result of chunkResults) { - for (const node of result.nodes) { + // Worker jobs and input files are already merged in stable start-index/path + // order. Canonicalize the final per-result node boundary once so graph + // insertion, cache replay, and first-wins graph indexes share source order. + // The common already-ordered path stays allocation-free and linear. + for (const node of nodesInSourceOrder(result.nodes)) { graph.addNode({ id: node.id, label: node.label as NodeLabel, diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index d10e67da9..e75b793d7 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -273,6 +273,7 @@ import type { Callsite, ConstraintContext, ParsedFile, + ParsedImport, ReferenceSite, ScopeId, SupportedLanguages, @@ -302,6 +303,11 @@ export type ReceiverMemberResolution = | { readonly kind: 'resolved'; readonly definition: SymbolDefinition } | { readonly kind: 'ambiguous'; readonly candidateIds: readonly string[] }; +export interface ImportResolutionContext { + readonly parsedFiles: readonly ParsedFile[]; + readonly parsedImport?: ParsedImport; +} + /** Re-exported for ScopeResolver consumers — same shape as * `RegistryProviders.constraintCompatibility`'s third parameter. */ export type { ConstraintContext } from 'gitnexus-shared'; @@ -340,12 +346,18 @@ export interface ScopeResolver { * orchestrator). TypeScript uses this to thread `tsconfig.json` path * aliases through to the standard resolver. Languages that don't * need any extra config ignore the parameter. + * + * `context.parsedFiles` is the complete, read-only language workspace. It is + * optional so resolvers that only need paths retain their existing shape. + * `context.parsedImport` is the exact import being finalized. PHP uses both + * when a PSR-4 import names a function instead of a file. */ resolveImportTarget( targetRaw: string, fromFile: string, allFilePaths: ReadonlySet, resolutionConfig?: unknown, + context?: ImportResolutionContext, ): string | readonly string[] | null; /** diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts index 9ab1649a1..0d5abe510 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts @@ -163,7 +163,7 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup { } } - // Fallback key: simple name. First-wins within a file — used when + // Fallback key: simple name. Source-order first-wins within a file — used when // the caller doesn't know the qualifier (unqualified free-call // fallback, cross-file resolution where MethodRegistry already // disambiguated the owner). diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 62cb0083e..5f11120b2 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -561,8 +561,11 @@ export function runScopeResolution( const resolutionConfig = input.resolutionConfig; const finalized = finalizeScopeModel(parsedFiles, { hooks: { - resolveImportTarget: (targetRaw, fromFile) => - provider.resolveImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig), + resolveImportTarget: (targetRaw, fromFile, _workspaceIndex, parsedImport) => + provider.resolveImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig, { + parsedFiles, + parsedImport, + }), expandsWildcardTo: (targetModuleScope) => provider.expandsWildcardTo?.(targetModuleScope, parsedFiles) ?? [], mergeBindings: (existing, incoming, scopeId) => diff --git a/gitnexus/src/mcp/http-transport.ts b/gitnexus/src/mcp/http-transport.ts index 265ffa59a..68b897145 100644 --- a/gitnexus/src/mcp/http-transport.ts +++ b/gitnexus/src/mcp/http-transport.ts @@ -31,6 +31,11 @@ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import { createMCPServer, installSignalShutdown } from './server.js'; import type { LocalBackend } from './local/local-backend.js'; import { logger } from '../core/logger.js'; +import { + createMcpRepositoryPolicy, + mcpRepositoryPolicyConfigured, + type McpRepositoryPolicy, +} from './repository-policy.js'; /** HTTP server configuration options. */ export interface McpHttpOptions { @@ -40,6 +45,8 @@ export interface McpHttpOptions { host: string; /** Bearer auth token (optional; no auth when omitted). */ authToken?: string; + /** Prevalidated repository policy shared by startup logging and transports. */ + repositoryPolicy?: McpRepositoryPolicy; } interface MCPSession { @@ -217,13 +224,25 @@ export function startIdleSweep Server; host?: string; port?: number } = {}, + opts: { + createServer?: () => Server; + host?: string; + port?: number; + repositoryPolicy?: McpRepositoryPolicy; + } = {}, ): { handler: (req: Request, res: Response) => Promise; cleanup: () => Promise; } { + if (opts.createServer && !opts.repositoryPolicy && mcpRepositoryPolicyConfigured()) { + throw new Error('A custom MCP server factory cannot bypass configured repository policy.'); + } // Seam: tests inject createServer to observe the per-session Server lifecycle. - const createServer = opts.createServer ?? ((): Server => createMCPServer(backend)); + let repositoryPolicy: Promise | undefined = opts.repositoryPolicy + ? Promise.resolve(opts.repositoryPolicy) + : undefined; + const getRepositoryPolicy = (): Promise => + (repositoryPolicy ??= createMcpRepositoryPolicy(backend)); // DNS-rebinding protection (Host-header allowlist) when the bind host is known. const dnsRebinding = dnsRebindingOptions(opts.host, opts.port); const sessions = new Map(); @@ -280,7 +299,9 @@ export function createStreamableHttpHandler( sessionIdGenerator: () => randomUUID(), ...dnsRebinding, }); - const server = createServer(); + const server = opts.createServer + ? opts.createServer() + : createMCPServer(backend, { repositoryPolicy: await getRepositoryPolicy() }); await server.connect(transport); await transport.handleRequest(req, res, req.body); @@ -337,13 +358,23 @@ export function createStreamableHttpHandler( export function createSseHandlers( backend: LocalBackend, messagesPath = '/messages', - opts: { maxSessions?: number; host?: string; port?: number } = {}, + opts: { + maxSessions?: number; + host?: string; + port?: number; + repositoryPolicy?: McpRepositoryPolicy; + } = {}, ): { sseHandler: (req: Request, res: Response) => Promise; messageHandler: (req: Request, res: Response) => Promise; cleanup: () => Promise; } { const maxSessions = opts.maxSessions ?? MAX_SESSIONS; + let repositoryPolicy: Promise | undefined = opts.repositoryPolicy + ? Promise.resolve(opts.repositoryPolicy) + : undefined; + const getRepositoryPolicy = (): Promise => + (repositoryPolicy ??= createMcpRepositoryPolicy(backend)); // DNS-rebinding protection (Host-header allowlist) when the bind host is known. const dnsRebinding = dnsRebindingOptions(opts.host, opts.port); const sseSessions = new Map(); @@ -364,7 +395,7 @@ export function createSseHandlers( // SSEServerTransport(endpoint, res, options): endpoint is the path clients POST to. const transport = new SSEServerTransport(messagesPath, res, dnsRebinding); - const server = createMCPServer(backend); + const server = createMCPServer(backend, { repositoryPolicy: await getRepositoryPolicy() }); sseSessions.set(transport.sessionId, { server, transport, lastActivity: Date.now() }); @@ -451,6 +482,8 @@ export async function startMcpHttpServer( ); } + const repositoryPolicy = options.repositoryPolicy ?? (await createMcpRepositoryPolicy(backend)); + const app: Express = express(); // Suppress X-Powered-By to reduce information leakage. @@ -502,7 +535,7 @@ export async function startMcpHttpServer( }); // Streamable HTTP (modern MCP clients) at POST /mcp. - const streamable = createStreamableHttpHandler(backend, { host, port }); + const streamable = createStreamableHttpHandler(backend, { host, port, repositoryPolicy }); app.all('/mcp', auth, jsonBody, (req: Request, res: Response) => { void streamable.handler(req, res).catch((err: unknown) => { logger.error({ err }, 'MCP /mcp request failed'); @@ -517,7 +550,7 @@ export async function startMcpHttpServer( }); // Legacy SSE: GET /sse opens the stream; POST /messages receives JSON-RPC messages. - const sse = createSseHandlers(backend, '/messages', { host, port }); + const sse = createSseHandlers(backend, '/messages', { host, port, repositoryPolicy }); app.get('/sse', auth, (req: Request, res: Response) => { void sse.sseHandler(req, res).catch((err: unknown) => { logger.error({ err }, 'MCP /sse failed'); diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 0956b4071..b10cb4269 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -142,6 +142,61 @@ function resolveAliasString(canonical: unknown, legacy: unknown): string | undef return undefined; } +interface StringAliasDefinition { + canonical: string; + aliases: readonly string[]; +} + +const TOOL_STRING_ALIASES: Readonly> = { + impact: [{ canonical: 'target', aliases: ['name', 'symbol'] }], + context: [{ canonical: 'file_path', aliases: ['file'] }], +}; + +function normalizeToolParams( + method: string, + params: unknown, +): { params: Record } | { error: string } { + const input = params && typeof params === 'object' ? (params as Record) : {}; + const definitions = TOOL_STRING_ALIASES[method]; + if (!definitions) return { params: input }; + + const normalized = { ...input }; + for (const { canonical, aliases } of definitions) { + const keys = [canonical, ...aliases]; + const supplied: Array<{ key: string; value: string }> = []; + for (const key of keys) { + if (!Object.prototype.hasOwnProperty.call(input, key)) continue; + const value = input[key]; + // Internal CLI callers materialize omitted optional flags as undefined. + if (value === undefined) continue; + if (typeof value !== 'string' || !value.trim()) { + return { error: `MCP parameter ${method}.${key} must be a non-empty string.` }; + } + supplied.push({ key, value: value.trim() }); + } + const distinctValues = new Set(supplied.map(({ value }) => value)); + if (distinctValues.size > 1) { + return { + error: `Conflicting MCP parameters for ${method}.${canonical}: ${supplied + .map(({ key }) => key) + .join(', ')} must agree.`, + }; + } + + for (const alias of aliases) delete normalized[alias]; + if (supplied.length > 0) normalized[canonical] = supplied[0].value; + } + + if ( + method === 'impact' && + typeof normalized.target !== 'string' && + (typeof normalized.target_uid !== 'string' || !normalized.target_uid.trim()) + ) { + return { error: 'MCP impact requires target, name, symbol, or target_uid.' }; + } + return { params: normalized }; +} + // AI context generation is CLI-only (gitnexus analyze) // import { generateAIContextFiles } from '../../cli/ai-context.js'; @@ -1661,7 +1716,9 @@ export class LocalBackend { return this.handleGroupTool(method, params || {}); } - const p = params && typeof params === 'object' ? (params as Record) : {}; + const normalized = normalizeToolParams(method, params); + if ('error' in normalized) return { error: normalized.error }; + const p = normalized.params; // #2175: Claude Code drops a tool-call argument named exactly "query", so the // query/cypher tools advertise "search_query"/"statement" while still accepting the @@ -1682,47 +1739,52 @@ export class LocalBackend { // Resolve repo from optional param (re-reads registry on miss). An optional // `branch` param scopes the resolved handle to that branch's index (#2106). - const repoParams = params as { repo?: string; branch?: string } | undefined; - const repo = await this.resolveRepo(repoParams?.repo, repoParams?.branch); + const repo = await this.resolveRepo( + p.repo as string | undefined, + p.branch as string | undefined, + ); switch (method) { case 'query': - return this.query(repo, params); + return this.query(repo, p); case 'cypher': { - const raw = await this.cypher(repo, params); + const raw = await this.cypher(repo, p); return this.formatCypherAsMarkdown(raw); } case 'context': - return this.context(repo, params); + return this.context(repo, p); case 'explain': - return this.explain(repo, params); + return this.explain(repo, p); case 'pdg_query': - return this.pdgQuery(repo, params); + return this.pdgQuery(repo, p); case 'impact': - return this.impact(repo, params); + return this.impact(repo, p as unknown as ImpactParams); case 'detect_changes': - return this.detectChanges(repo, params); + return this.detectChanges(repo, p); case 'check': - return this.check(repo, params); + return this.check(repo, p); case 'rename': - return this.rename(repo, params); + return this.rename(repo, p as unknown as Parameters[1]); // Legacy aliases for backwards compatibility case 'search': - return this.query(repo, params); + return this.query(repo, p); case 'explore': - return this.context(repo, { name: params?.name, ...params }); + return this.context(repo, { + name: typeof p.name === 'string' ? p.name : undefined, + ...p, + }); case 'overview': - return this.overview(repo, params); + return this.overview(repo, p); case 'route_map': - return this.routeMap(repo, params); + return this.routeMap(repo, p); case 'shape_check': - return this.shapeCheck(repo, params); + return this.shapeCheck(repo, p); case 'tool_map': - return this.toolMap(repo, params); + return this.toolMap(repo, p); case 'api_impact': - return this.apiImpact(repo, params); + return this.apiImpact(repo, p); case 'trace': - return this.trace(repo, params); + return this.trace(repo, p); default: throw new Error(`Unknown tool: ${method}`); } diff --git a/gitnexus/src/mcp/output-budget.ts b/gitnexus/src/mcp/output-budget.ts new file mode 100644 index 000000000..032d3cd41 --- /dev/null +++ b/gitnexus/src/mcp/output-budget.ts @@ -0,0 +1,58 @@ +const BUDGETED_TOOLS = new Set(['query', 'context', 'impact']); + +export const MCP_TOKEN_ESTIMATE_BYTES = 4; +export const MCP_TRUNCATION_MARKER = '\n…'; + +function parsePositiveInteger(value: unknown, source: string): number { + if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return value; + if (typeof value === 'string' && /^[1-9]\d*$/.test(value.trim())) { + const parsed = Number(value.trim()); + if (Number.isSafeInteger(parsed)) return parsed; + } + throw new Error(`${source} must be a positive integer.`); +} + +export function resolveMcpMaxTokens( + toolName: string, + args: Record | undefined, + env: NodeJS.ProcessEnv = process.env, +): number | undefined { + if (!BUDGETED_TOOLS.has(toolName)) return undefined; + if (args?.maxTokens !== undefined) return parsePositiveInteger(args.maxTokens, 'maxTokens'); + + const configured = env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS; + if (configured === undefined || configured.trim() === '') return undefined; + return parsePositiveInteger(configured, 'GITNEXUS_MCP_DEFAULT_MAX_TOKENS'); +} + +function utf8Prefix(text: string, maxBytes: number): string { + let bytes = 0; + const codePoints: string[] = []; + for (const codePoint of text) { + const codePointBytes = Buffer.byteLength(codePoint, 'utf8'); + if (bytes + codePointBytes > maxBytes) break; + codePoints.push(codePoint); + bytes += codePointBytes; + } + return codePoints.join(''); +} + +export function applyMcpMaxTokens(text: string, maxTokens: number | undefined): string { + if (maxTokens === undefined) return text; + + const textBytes = Buffer.byteLength(text, 'utf8'); + if (maxTokens >= Math.ceil(textBytes / MCP_TOKEN_ESTIMATE_BYTES)) return text; + + const maxBytes = maxTokens * MCP_TOKEN_ESTIMATE_BYTES; + const markerBytes = Buffer.byteLength(MCP_TRUNCATION_MARKER, 'utf8'); + return utf8Prefix(text, Math.max(0, maxBytes - markerBytes)) + MCP_TRUNCATION_MARKER; +} + +export function withoutMcpBudgetArg( + args: Record | undefined, +): Record | undefined { + if (!args || !Object.prototype.hasOwnProperty.call(args, 'maxTokens')) return args; + const backendArgs = { ...args }; + delete backendArgs.maxTokens; + return backendArgs; +} diff --git a/gitnexus/src/mcp/read-only-policy.ts b/gitnexus/src/mcp/read-only-policy.ts new file mode 100644 index 000000000..51fc21de1 --- /dev/null +++ b/gitnexus/src/mcp/read-only-policy.ts @@ -0,0 +1,121 @@ +import type { GITNEXUS_TOOLS } from './tools.js'; + +type GitNexusTool = (typeof GITNEXUS_TOOLS)[number]; + +export const MCP_READ_ONLY_TOOLS = new Set([ + 'list_repos', + 'query', + 'context', + 'detect_changes', + 'check', + 'impact', + 'explain', + 'pdg_query', + 'route_map', + 'tool_map', + 'shape_check', + 'api_impact', + 'trace', +]); + +const MCP_READ_ONLY_ALIASES = new Set(['search', 'explore', 'overview']); + +export function resolveMcpReadOnlyMode(env: NodeJS.ProcessEnv = process.env): boolean { + const value = env.GITNEXUS_MCP_READ_ONLY?.trim(); + if (value === undefined || value === '' || value === '0') return false; + if (value === '1') return true; + throw new Error('GITNEXUS_MCP_READ_ONLY must be 0 or 1.'); +} + +export function assertMcpReadOnlyToolCall( + toolName: string, + args: Record | undefined, + readOnly: boolean, +): void { + if (!readOnly) return; + if (!MCP_READ_ONLY_TOOLS.has(toolName) && !MCP_READ_ONLY_ALIASES.has(toolName)) { + throw new Error(`Tool "${toolName}" is not available in GitNexus MCP read-only mode.`); + } + if (typeof args?.repo === 'string' && args.repo.trim().startsWith('@')) { + throw new Error('Group routing is not available in GitNexus MCP read-only mode.'); + } + // crossDepth/subgroup only do anything on the @group path rejected above, + // but rejecting them here keeps the advertised schema and the dispatch + // contract in agreement. + for (const groupOnlyArg of ['crossDepth', 'subgroup']) { + if (args?.[groupOnlyArg] !== undefined) { + throw new Error( + `Parameter "${groupOnlyArg}" is not available in GitNexus MCP read-only mode.`, + ); + } + } +} + +export function readOnlyResourceTemplateAllowed(uriTemplate: string, readOnly: boolean): boolean { + return !readOnly || !/^gitnexus:\/\/group\//iu.test(uriTemplate); +} + +export function assertMcpReadOnlyResource(uri: string, readOnly: boolean): void { + if (!readOnly) return; + + let isGroupResource = false; + try { + const parsed = new URL(uri); + isGroupResource = + parsed.protocol.toLowerCase() === 'gitnexus:' && parsed.hostname.toLowerCase() === 'group'; + } catch { + // Invalid resource URIs are rejected by the normal parser. This fallback + // keeps obviously group-shaped malformed inputs fail-closed as well. + isGroupResource = /^gitnexus:\/\/group(?:\/|$)/iu.test(uri); + } + + if (isGroupResource) { + throw new Error('Group resources are not available in GitNexus MCP read-only mode.'); + } +} + +// Cosmetic only: dispatch enforcement above is the actual boundary. If the +// generated resource format drifts and a hidden route slips through here, the +// caller still gets a clean rejection at dispatch. +export function filterMcpReadOnlyResourceContent(content: string, readOnly: boolean): string { + if (!readOnly) return content; + return content + .split('\n') + .filter( + (line) => + !/^\s*-\s+(?:rename|cypher|group_sync|group_list):/u.test(line) && + !/^\|\s*`(?:rename|cypher|group_sync|group_list)`\s*\|/u.test(line) && + !line.includes('gitnexus://group/'), + ) + .join('\n'); +} + +/** Shared with repository-policy.ts so both policies scrub identically. */ +export function scrubGroupDescription(description: string): string { + return description + .replace(/\nGROUP MODE:[\s\S]*?(?=\n\n[A-Z][A-Z ()-]*:|$)/gu, '') + .replace(/\nCROSS-REPO \(experimental\):[\s\S]*?(?=\n\n[A-Z][A-Z ()-]*:|$)/gu, '') + .replace(/\nDESTINATION TRACE \(cross-repo\):[\s\S]*?(?=\n\n[A-Z][A-Z ()-]*:|$)/gu, ''); +} + +export function toolForReadOnlyMcp(tool: GitNexusTool, readOnly: boolean): GitNexusTool { + if (!readOnly) return tool; + + const properties = { ...tool.inputSchema.properties }; + const repo = properties.repo; + if (repo && typeof repo === 'object') { + properties.repo = { + ...repo, + description: + 'Indexed repository name or path. Group-mode values beginning with @ are unavailable in MCP read-only mode.', + }; + } + delete properties.subgroup; + delete properties.crossDepth; + + return { + ...tool, + description: `${scrubGroupDescription(tool.description)}\n\nGitNexus MCP read-only mode excludes raw Cypher, mutation, and group routing.`, + inputSchema: { ...tool.inputSchema, properties }, + }; +} diff --git a/gitnexus/src/mcp/repository-policy.ts b/gitnexus/src/mcp/repository-policy.ts new file mode 100644 index 000000000..ad2dad1fa --- /dev/null +++ b/gitnexus/src/mcp/repository-policy.ts @@ -0,0 +1,397 @@ +import path from 'node:path'; +import type { LocalBackend, RepoListing } from './local/local-backend.js'; +import { parseListReposPagination } from './local/local-backend.js'; +import { scrubGroupDescription } from './read-only-policy.js'; +import { LIST_REPOS_DEFAULT_LIMIT, LIST_REPOS_MAX_LIMIT } from './tools.js'; +import type { GITNEXUS_TOOLS } from './tools.js'; + +type GitNexusTool = (typeof GITNEXUS_TOOLS)[number]; + +const CANONICAL_ALLOWED = 'GITNEXUS_MCP_ALLOWED_REPOS'; +const CANONICAL_DEFAULT = 'GITNEXUS_MCP_DEFAULT_REPO'; + +interface RawRepositoryPolicy { + allowed?: string[]; + defaultRepo?: string; +} + +interface ResolvedRepository { + name: string; + path: string; + pathKey: string; +} + +function configuredValue( + env: NodeJS.ProcessEnv, + key: string, +): { key: string; value: string } | undefined { + const value = env[key]; + return value === undefined ? undefined : { key, value }; +} + +function parseRepositoryPolicy(env: NodeJS.ProcessEnv): RawRepositoryPolicy { + const allowedRaw = configuredValue(env, CANONICAL_ALLOWED); + const defaultRaw = configuredValue(env, CANONICAL_DEFAULT); + + let allowed: string[] | undefined; + if (allowedRaw) { + allowed = allowedRaw.value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + if (allowed.length === 0) throw new Error(`${allowedRaw.key} must not be blank.`); + } + + let defaultRepo: string | undefined; + if (defaultRaw) { + defaultRepo = defaultRaw.value.trim(); + if (!defaultRepo) throw new Error(`${defaultRaw.key} must not be blank.`); + } + + return { allowed, defaultRepo }; +} + +function normalizedPath(value: string): string { + const resolved = path.resolve(value); + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; +} + +function isAbsolutePath(value: string): boolean { + return path.isAbsolute(value) || path.win32.isAbsolute(value); +} + +function resolveSpecifier( + specifier: string, + registry: readonly ResolvedRepository[], +): { repo?: ResolvedRepository; reason?: 'invalid' | 'ambiguous' } { + const trimmed = specifier.trim(); + const matches = isAbsolutePath(trimmed) + ? registry.filter((repo) => repo.pathKey === normalizedPath(trimmed)) + : registry.filter((repo) => repo.name.toLowerCase() === trimmed.toLowerCase()); + + if (matches.length === 0) return { reason: 'invalid' }; + if (matches.length > 1) return { reason: 'ambiguous' }; + return { repo: matches[0] }; +} + +function startupResolutionError(reason: 'invalid' | 'ambiguous'): Error { + return new Error( + reason === 'ambiguous' + ? 'MCP repository configuration contains an ambiguous repository selection.' + : 'MCP repository configuration contains an invalid repository selection.', + ); +} + +function unavailableRepositoryError(): Error { + return new Error('Repository is not available through this MCP server.'); +} + +export class McpRepositoryPolicy { + readonly restricted: boolean; + readonly configured: boolean; + + private readonly registry: readonly ResolvedRepository[]; + private readonly allowed: readonly ResolvedRepository[]; + private readonly allowedPathKeys: ReadonlySet; + private readonly defaultRepo?: ResolvedRepository; + private readonly uniqueAllowedContextNames: ReadonlySet; + + static unrestricted(): McpRepositoryPolicy { + return new McpRepositoryPolicy([], undefined, undefined); + } + + constructor( + registry: readonly ResolvedRepository[], + allowed: readonly ResolvedRepository[] | undefined, + defaultRepo: ResolvedRepository | undefined, + ) { + this.registry = registry; + this.restricted = allowed !== undefined; + this.configured = this.restricted || defaultRepo !== undefined; + this.allowed = allowed ?? registry; + this.allowedPathKeys = new Set(this.allowed.map((repo) => repo.pathKey)); + this.defaultRepo = defaultRepo; + + const registryNameCounts = new Map(); + for (const repo of registry) { + const name = repo.name.toLowerCase(); + registryNameCounts.set(name, (registryNameCounts.get(name) ?? 0) + 1); + } + this.uniqueAllowedContextNames = new Set( + this.allowed + .map((repo) => repo.name.toLowerCase()) + .filter((name) => registryNameCounts.get(name) === 1), + ); + } + + private resolveRuntimeRepo(specifier: string): ResolvedRepository { + const result = resolveSpecifier(specifier, this.registry); + if (!result.repo || (this.restricted && !this.allowedPathKeys.has(result.repo.pathKey))) { + throw unavailableRepositoryError(); + } + return result.repo; + } + + private repoForArgs(args: Record | undefined): ResolvedRepository | undefined { + const explicit = args?.repo; + if (explicit !== undefined) { + if (typeof explicit !== 'string') throw unavailableRepositoryError(); + if (explicit.trim().startsWith('@')) { + if (this.restricted) { + throw new Error('Group routing is unavailable when an MCP repository allowlist is set.'); + } + return undefined; + } + return this.resolveRuntimeRepo(explicit); + } + + if (this.defaultRepo) return this.defaultRepo; + if (this.restricted && this.allowed.length === 1) return this.allowed[0]; + if (this.restricted && this.allowed.length > 1) { + throw new Error('Specify an explicit repo because multiple repositories are allowed.'); + } + return undefined; + } + + private normalizeToolArgs( + args: Record | undefined, + ): Record | undefined { + if (!this.configured) return args; + if (!this.restricted && args?.repo !== undefined) return args; + const selected = this.repoForArgs(args); + if (!selected) return args; + return { ...(args ?? {}), repo: selected.path }; + } + + private async listAllowedRepos(backend: LocalBackend): Promise { + const current = await backend.listRepos(); + if (!this.restricted) return current; + return current + .filter((repo) => this.allowedPathKeys.has(normalizedPath(repo.path))) + .map((repo) => { + const siblings = repo.siblings?.filter((sibling) => + this.allowedPathKeys.has(normalizedPath(sibling.path)), + ); + return { + ...repo, + siblings: siblings && siblings.length > 0 ? siblings : undefined, + }; + }); + } + + private async listReposPage( + backend: LocalBackend, + params: Record | undefined, + ): Promise { + const { limit, offset } = parseListReposPagination(params, { + defaultLimit: LIST_REPOS_DEFAULT_LIMIT, + maxLimit: LIST_REPOS_MAX_LIMIT, + }); + const repositories = await this.listAllowedRepos(backend); + repositories.sort((a, b) => { + const an = a.name.toLowerCase(); + const bn = b.name.toLowerCase(); + if (an !== bn) return an < bn ? -1 : 1; + return a.path < b.path ? -1 : a.path > b.path ? 1 : 0; + }); + + const total = repositories.length; + const page = repositories.slice(offset, offset + limit); + const returned = page.length; + const hasMore = offset + returned < total; + return { + repositories: page, + pagination: { + total, + limit, + offset, + returned, + hasMore, + ...(hasMore && { nextOffset: offset + returned }), + }, + }; + } + + private async callTool( + backend: LocalBackend, + method: string, + params: Record | undefined, + ): Promise { + if (!this.configured) return backend.callTool(method, params); + if (method === 'list_repos') return this.listReposPage(backend, params); + if (this.restricted && method.startsWith('group_')) { + throw new Error('Group tools are unavailable when an MCP repository allowlist is set.'); + } + return backend.callTool(method, this.normalizeToolArgs(params)); + } + + private async resolveRepo( + backend: LocalBackend, + repo?: string, + branch?: string, + ): Promise>> { + if (!this.configured) return backend.resolveRepo(repo, branch); + if (!this.restricted) return backend.resolveRepo(repo ?? this.defaultRepo?.path, branch); + const selected = this.repoForArgs(repo === undefined ? undefined : { repo }); + return backend.resolveRepo(selected?.path, branch); + } + + assertResourceUri(uri: string): void { + if (!this.restricted) return; + let parsed: URL; + try { + parsed = new URL(uri); + } catch { + // resources.ts parses with the same URL call, so anything that fails + // here fails there too today. Keep obviously group- or repo-shaped + // malformed inputs fail-closed anyway in case the parsers ever drift. + if (/^gitnexus:\/\/group(?:\/|$)/iu.test(uri)) { + throw new Error('Group resources are unavailable when an MCP repository allowlist is set.'); + } + const repoShaped = /^gitnexus:\/\/repo\/([^/]+)/iu.exec(uri); + if (repoShaped) this.resolveRuntimeRepo(decodeURIComponent(repoShaped[1])); + return; + } + // gitnexus: is a non-special URL scheme, so the host is opaque and NOT + // lowercased by the parser — compare case-insensitively like + // read-only-policy.ts does. + if (parsed.protocol.toLowerCase() !== 'gitnexus:') return; + const hostname = parsed.hostname.toLowerCase(); + if (hostname === 'group') { + throw new Error('Group resources are unavailable when an MCP repository allowlist is set.'); + } + if (hostname !== 'repo') return; + const repoName = parsed.pathname.split('/').filter(Boolean)[0]; + if (!repoName) return; + this.resolveRuntimeRepo(decodeURIComponent(repoName)); + } + + resourceTemplateAllowed(uriTemplate: string): boolean { + return !this.restricted || !uriTemplate.startsWith('gitnexus://group/'); + } + + toolAllowed(toolName: string): boolean { + return !this.restricted || !toolName.startsWith('group_'); + } + + toolForMcp(tool: GitNexusTool): GitNexusTool { + if (!this.restricted) return tool; + const properties = { ...tool.inputSchema.properties }; + const repo = properties.repo; + if (repo && typeof repo === 'object') { + properties.repo = { + ...repo, + description: 'Allowed indexed repository name or path. Group-mode values are unavailable.', + }; + } + delete properties.subgroup; + delete properties.crossDepth; + const description = scrubGroupDescription(tool.description); + return { ...tool, description, inputSchema: { ...tool.inputSchema, properties } }; + } + + scopeBackend(backend: LocalBackend): LocalBackend { + const policy = this; + return new Proxy(backend, { + get(target, property, receiver) { + if (property === 'callTool') { + return (method: string, params: Record | undefined) => + policy.callTool(target, method, params); + } + if (property === 'listRepos') return () => policy.listAllowedRepos(target); + if (property === 'resolveRepo') { + return (repo?: string, branch?: string) => policy.resolveRepo(target, repo, branch); + } + if (property === 'getContext' && policy.restricted) { + return (repoId?: string) => { + if (!repoId || !policy.uniqueAllowedContextNames.has(repoId.toLowerCase())) return null; + return target.getContext(repoId); + }; + } + if ( + policy.restricted && + (property === 'readGroupContractsResource' || property === 'readGroupStatusResource') + ) { + return async () => { + throw new Error( + 'Group resources are unavailable when an MCP repository allowlist is set.', + ); + }; + } + // Repo-scoped resource reads must not depend on assertResourceUri + // running first — enforce the allowlist on the query surface too. + if (policy.restricted && (property === 'queryClusters' || property === 'queryProcesses')) { + return (repoName?: string, limit?: number) => { + const selected = policy.repoForArgs( + repoName === undefined ? undefined : { repo: repoName }, + ); + return property === 'queryClusters' + ? target.queryClusters(selected?.path ?? repoName, limit) + : target.queryProcesses(selected?.path ?? repoName, limit); + }; + } + if ( + policy.restricted && + (property === 'queryClusterDetail' || property === 'queryProcessDetail') + ) { + return (name: string, repoName?: string) => { + const selected = policy.repoForArgs( + repoName === undefined ? undefined : { repo: repoName }, + ); + return property === 'queryClusterDetail' + ? target.queryClusterDetail(name, selected?.path ?? repoName) + : target.queryProcessDetail(name, selected?.path ?? repoName); + }; + } + const value = Reflect.get(target, property, receiver); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + } +} + +export function mcpRepositoryPolicyConfigured(env: NodeJS.ProcessEnv = process.env): boolean { + const raw = parseRepositoryPolicy(env); + return raw.allowed !== undefined || raw.defaultRepo !== undefined; +} + +export async function createMcpRepositoryPolicy( + backend: LocalBackend, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const raw = parseRepositoryPolicy(env); + if (!raw.allowed && !raw.defaultRepo) { + return McpRepositoryPolicy.unrestricted(); + } + + const registry = (await backend.listRepos()).map((repo) => ({ + name: repo.name, + path: repo.path, + pathKey: normalizedPath(repo.path), + })); + + let allowed: ResolvedRepository[] | undefined; + if (raw.allowed) { + const byPath = new Map(); + for (const specifier of raw.allowed) { + const result = resolveSpecifier(specifier, registry); + if (!result.repo) throw startupResolutionError(result.reason ?? 'invalid'); + byPath.set(result.repo.pathKey, result.repo); + } + allowed = [...byPath.values()]; + } + + let defaultRepo: ResolvedRepository | undefined; + if (raw.defaultRepo) { + const result = resolveSpecifier(raw.defaultRepo, registry); + if (!result.repo) throw startupResolutionError(result.reason ?? 'invalid'); + defaultRepo = result.repo; + } + + const defaultPathKey = defaultRepo?.pathKey; + if (defaultPathKey && allowed && !allowed.some((repo) => repo.pathKey === defaultPathKey)) { + throw new Error('The MCP default repository is not in the configured allowlist.'); + } + + return new McpRepositoryPolicy(registry, allowed, defaultRepo); +} diff --git a/gitnexus/src/mcp/server.ts b/gitnexus/src/mcp/server.ts index d4a7c58aa..515028d5a 100644 --- a/gitnexus/src/mcp/server.ts +++ b/gitnexus/src/mcp/server.ts @@ -27,6 +27,21 @@ import { GITNEXUS_TOOLS } from './tools.js'; import { installGlobalStdoutSentinel } from './stdio-context.js'; import type { LocalBackend } from './local/local-backend.js'; import { getResourceDefinitions, getResourceTemplates, readResource } from './resources.js'; +import { + assertMcpReadOnlyResource, + assertMcpReadOnlyToolCall, + filterMcpReadOnlyResourceContent, + MCP_READ_ONLY_TOOLS, + readOnlyResourceTemplateAllowed, + resolveMcpReadOnlyMode, + toolForReadOnlyMcp, +} from './read-only-policy.js'; +import { + createMcpRepositoryPolicy, + McpRepositoryPolicy, + mcpRepositoryPolicyConfigured, +} from './repository-policy.js'; +import { applyMcpMaxTokens, resolveMcpMaxTokens, withoutMcpBudgetArg } from './output-budget.js'; /** * Next-step hints appended to tool responses. @@ -81,7 +96,16 @@ function getNextStepHint(toolName: string, args: Record | undefined * Create a configured MCP Server with all handlers registered. * Transport-agnostic — caller connects the desired transport. */ -export function createMCPServer(backend: LocalBackend): Server { +export function createMCPServer( + backend: LocalBackend, + options: { repositoryPolicy?: McpRepositoryPolicy } = {}, +): Server { + const readOnly = resolveMcpReadOnlyMode(); + if (!options.repositoryPolicy && mcpRepositoryPolicyConfigured()) { + throw new Error('Configured MCP repository policy must be validated before server creation.'); + } + const repositoryPolicy = options.repositoryPolicy ?? McpRepositoryPolicy.unrestricted(); + const scopedBackend = repositoryPolicy.scopeBackend(backend); const require = createRequire(import.meta.url); const pkgVersion: string = require('../../package.json').version; const server = new Server( @@ -113,7 +137,11 @@ export function createMCPServer(backend: LocalBackend): Server { // Handle list resource templates request (for dynamic resources) server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => { - const templates = getResourceTemplates(); + const templates = getResourceTemplates().filter( + (template) => + readOnlyResourceTemplateAllowed(template.uriTemplate, readOnly) && + repositoryPolicy.resourceTemplateAllowed(template.uriTemplate), + ); return { resourceTemplates: templates.map((t) => ({ uriTemplate: t.uriTemplate, @@ -129,7 +157,12 @@ export function createMCPServer(backend: LocalBackend): Server { const { uri } = request.params; try { - const content = await readResource(uri, backend); + assertMcpReadOnlyResource(uri, readOnly); + repositoryPolicy.assertResourceUri(uri); + const content = filterMcpReadOnlyResourceContent( + await readResource(uri, scopedBackend), + readOnly, + ); return { contents: [ { @@ -154,20 +187,30 @@ export function createMCPServer(backend: LocalBackend): Server { // Handle list tools request server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: GITNEXUS_TOOLS.map((tool) => ({ - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema, - annotations: tool.annotations, - })), + tools: GITNEXUS_TOOLS.filter( + (tool) => + (!readOnly || MCP_READ_ONLY_TOOLS.has(tool.name)) && + repositoryPolicy.toolAllowed(tool.name), + ) + .map((tool) => toolForReadOnlyMcp(repositoryPolicy.toolForMcp(tool), readOnly)) + .map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + annotations: tool.annotations, + })), })); // Handle tool calls — append next-step hints to guide agent workflow server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; + let maxTokens: number | undefined; try { - const result = await backend.callTool(name, args); + const typedArgs = args as Record | undefined; + assertMcpReadOnlyToolCall(name, typedArgs, readOnly); + maxTokens = resolveMcpMaxTokens(name, typedArgs); + const result = await scopedBackend.callTool(name, withoutMcpBudgetArg(typedArgs)); const resultText = typeof result === 'string' ? result : JSON.stringify(result, null, 2); const hint = getNextStepHint(name, args as Record | undefined); @@ -175,7 +218,7 @@ export function createMCPServer(backend: LocalBackend): Server { content: [ { type: 'text', - text: resultText + hint, + text: applyMcpMaxTokens(resultText + hint, maxTokens), }, ], }; @@ -185,7 +228,7 @@ export function createMCPServer(backend: LocalBackend): Server { content: [ { type: 'text', - text: `Error: ${message}`, + text: applyMcpMaxTokens(`Error: ${message}`, maxTokens), }, ], isError: true, @@ -315,8 +358,12 @@ export function installSignalShutdown( on('SIGTERM', () => void shutdown(SHUTDOWN_EXIT_CODES.SIGTERM)); } -export async function startMCPServer(backend: LocalBackend): Promise { - const server = createMCPServer(backend); +export async function startMCPServer( + backend: LocalBackend, + repositoryPolicy?: McpRepositoryPolicy, +): Promise { + const validatedRepositoryPolicy = repositoryPolicy ?? (await createMcpRepositoryPolicy(backend)); + const server = createMCPServer(backend, { repositoryPolicy: validatedRepositoryPolicy }); // Idempotent global sentinel install. cli/mcp.ts calls this first thing // (before warnMissingOptionalGrammars / backend.init can emit to stdout); diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 704b553c8..a34587e5d 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -182,6 +182,12 @@ SERVICE: optional monorepo path prefix (POSIX-style, case-sensitive segments). W description: 'Include full symbol source code (default: false)', default: false, }, + maxTokens: { + type: 'integer', + minimum: 1, + description: + 'Maximum estimated tokens in the complete formatted MCP response. Explicit request overrides GITNEXUS_MCP_DEFAULT_MAX_TOKENS.', + }, repo: { type: 'string', description: @@ -297,6 +303,10 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep description: 'Direct symbol UID from prior tool results (zero-ambiguity lookup)', }, file_path: { type: 'string', description: 'File path to disambiguate common names' }, + file: { + type: 'string', + description: 'Compatibility alias for file_path; values must agree when both are present', + }, kind: { type: 'string', description: @@ -307,6 +317,12 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep description: 'Include full symbol source code (default: false)', default: false, }, + maxTokens: { + type: 'integer', + minimum: 1, + description: + 'Maximum estimated tokens in the complete formatted MCP response. Explicit request overrides GITNEXUS_MCP_DEFAULT_MAX_TOKENS.', + }, repo: { type: 'string', description: @@ -461,6 +477,14 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep type: 'object', properties: { target: { type: 'string', description: 'Name of function, class, or file to analyze' }, + name: { + type: 'string', + description: 'Compatibility alias for target; all supplied target aliases must agree', + }, + symbol: { + type: 'string', + description: 'Compatibility alias for target; all supplied target aliases must agree', + }, target_uid: { type: 'string', description: @@ -564,6 +588,12 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep 'When true, returns target, summary, risk, byDepthCounts, affected_processes, and affected_modules — omits byDepth. Single-repo only; ignored in group mode (@groupName). Use for hub symbols to get actionable signal without output explosion.', default: false, }, + maxTokens: { + type: 'integer', + minimum: 1, + description: + 'Maximum estimated tokens in the complete formatted MCP response. Explicit request overrides GITNEXUS_MCP_DEFAULT_MAX_TOKENS.', + }, timeoutMs: { type: 'number', description: @@ -578,7 +608,7 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep maximum: 3600000, }, }, - required: ['target', 'direction'], + required: ['direction'], }, }, { diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index 218c6e211..50adf103d 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -857,7 +857,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => // Initialize MCP backend (multi-repo, shared across all MCP sessions) const backend = new LocalBackend(); await backend.init(); - const cleanupMcp = mountMCPEndpoints(app, backend); + const cleanupMcp = await mountMCPEndpoints(app, backend); const jobManager = new JobManager(); // Backstop: remove any upload staging dirs orphaned by a previous crash. diff --git a/gitnexus/src/server/mcp-http.ts b/gitnexus/src/server/mcp-http.ts index ccfd71adf..cf17bf763 100644 --- a/gitnexus/src/server/mcp-http.ts +++ b/gitnexus/src/server/mcp-http.ts @@ -11,10 +11,15 @@ import type { Express, Request, Response } from 'express'; import { createStreamableHttpHandler } from '../mcp/http-transport.js'; import type { LocalBackend } from '../mcp/local/local-backend.js'; +import { createMcpRepositoryPolicy } from '../mcp/repository-policy.js'; import { logger } from '../core/logger.js'; -export function mountMCPEndpoints(app: Express, backend: LocalBackend): () => Promise { - const { handler, cleanup } = createStreamableHttpHandler(backend); +export async function mountMCPEndpoints( + app: Express, + backend: LocalBackend, +): Promise<() => Promise> { + const repositoryPolicy = await createMcpRepositoryPolicy(backend); + const { handler, cleanup } = createStreamableHttpHandler(backend, { repositoryPolicy }); app.all('/api/mcp', (req: Request, res: Response) => { void handler(req, res).catch((err: unknown) => { diff --git a/gitnexus/test/fixtures/php-captures-golden/expected-captures.json b/gitnexus/test/fixtures/php-captures-golden/expected-captures.json index ecbe428c8..06ba54705 100644 --- a/gitnexus/test/fixtures/php-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/php-captures-golden/expected-captures.json @@ -5,11 +5,11 @@ }, "php-abstract-dispatch/src/Repositories/SqlRepository.php": { "captureGroups": 14, - "digest": "5905bb450b29d4e186d74b50f70f07a54c8e0d4b8c3748c998c1579e72283215" + "digest": "584da0b8d38ba3b2e45513e24ee17e0366bcfe48e353c274daed367250a0ccd9" }, "php-abstract-dispatch/src/app.php": { "captureGroups": 10, - "digest": "52ce761f1d53a56034124fa5e674863bce9092c1b523f6d71139c4f335e8b9f0" + "digest": "af85999f2ddf2bf718cc419553efd09425919835460bd6739277cc570cb2d3c4" }, "php-alias-imports/app/Models/Repo.php": { "captureGroups": 14, @@ -21,7 +21,7 @@ }, "php-alias-imports/app/Services/Main.php": { "captureGroups": 15, - "digest": "bf8e1b8079956e7ef9ebfcb3ca38f547e9760da5c83856e93163efe6961067b9" + "digest": "685798f8164a494d7dc794cf6076ff5ccade0e79fe570947a824be0099011739" }, "php-ambiguous/app/Models/Dispatchable.php": { "captureGroups": 6, @@ -41,7 +41,7 @@ }, "php-ambiguous/app/Services/UserHandler.php": { "captureGroups": 12, - "digest": "1c47e7020939a6cd6bb18f3732b51a16f4c75ded0fe9883a87d6706a2c624d1f" + "digest": "87bbd866d8748b7cd4932aea209abc1e17f9eca518c0a5bb0dc9572cc5cd95be" }, "php-anonymous-class/anon.php": { "captureGroups": 5, @@ -61,15 +61,15 @@ }, "php-app/app/Models/BaseModel.php": { "captureGroups": 18, - "digest": "be7ca5be7e28417afdef2e45c8cfed9e04594b341b0d676e7a00fdd84c94e42a" + "digest": "7d5a013f34f909846e3a91cef4b3daa8638034e5c6939d90ccd7dae34c8f9272" }, "php-app/app/Models/User.php": { "captureGroups": 27, - "digest": "b6b08be1af66cbd757cfd715389725952c0e2a8e5e910ed715594c0b07570a37" + "digest": "718b9ba0238e2139ff34027d68cb135c4698a2fc401a4f85200864b1e27a9a78" }, "php-app/app/Services/UserService.php": { "captureGroups": 38, - "digest": "80b8557e183052f25222cfda8879e08b1613752001f58670ce7b3dbf4b8bee28" + "digest": "a911c5af5042738b1ca9aeb71dad06f4918d8c41e84b6a27ffaa0eadfe56ca22" }, "php-app/app/Traits/HasTimestamps.php": { "captureGroups": 11, @@ -89,7 +89,7 @@ }, "php-assignment-chain/app/Services/AppService.php": { "captureGroups": 15, - "digest": "36c5358d7f724cc0906e087828c86d9a29fe0629e2bad6105967fe8d671aa6fe" + "digest": "0e9a9ef52a987dbd12c9478a75d5019c2a0059ab4b862924597d2ecd32f03aa7" }, "php-call-result-binding/App.php": { "captureGroups": 23, @@ -97,7 +97,7 @@ }, "php-calls/app/Services/UserService.php": { "captureGroups": 7, - "digest": "94f1cdfb0c444dc9e8116d1bbf824dc88584046539b2cbc48297729eb49e41a1" + "digest": "84e2e65cbb4026ef9e67ac2710d1304c8dffe335f2b1b1cb70123157a04acf50" }, "php-calls/app/Utils/OneArg/log.php": { "captureGroups": 5, @@ -109,7 +109,7 @@ }, "php-child-extends-parent/src/App.php": { "captureGroups": 11, - "digest": "0c8a7c7edaa20009b71f22fef98230119a4738d2a21c8b88d64047c3c932fd36" + "digest": "6e8b1d8f2e9c02eebcf8e2b6cacc15dfbd48c9fc03011c03d70afd265c38f3dc" }, "php-child-extends-parent/src/Child.php": { "captureGroups": 5, @@ -125,7 +125,7 @@ }, "php-constructor-calls/app.php": { "captureGroups": 8, - "digest": "23ef0f05446126892e598872a0b3c3d2f96e3b0dc50e301f1fa784a56ebc81d4" + "digest": "0f3e6cf60248ae02ca0a744d3fa4f67b2d8421858c29243c4fcaf60763c7ac3d" }, "php-constructor-promotion-fields/Models.php": { "captureGroups": 22, @@ -145,7 +145,7 @@ }, "php-constructor-type-inference/app/Services/AppService.php": { "captureGroups": 15, - "digest": "26181127fe9e9bf04431a7cc801624bde9dd284049627bb7ca0a4c9234141eec" + "digest": "5aee8297c1f3e032523fe871571ee4410422b7ee932b259e9f36b0cab4d79c8f" }, "php-coverage/enum-and-anon.php": { "captureGroups": 9, @@ -153,7 +153,7 @@ }, "php-coverage/multi-import.php": { "captureGroups": 7, - "digest": "4d72fbfba40f2e083aa55d1c48bb500aeb7dd615de18b48b00eaca5b26a2001e" + "digest": "e3746c5f7a68dc4dd9d658159077eb272573e27dfc29fedeadfbd6ab7ef35a10" }, "php-deep-field-chain/Models.php": { "captureGroups": 26, @@ -245,7 +245,7 @@ }, "php-fqn-cross-namespace/app/Services/Service.php": { "captureGroups": 15, - "digest": "94a2bec57ccde7aa663ce2027c7f36151d18ee257830663365bc14f9d4d5f703" + "digest": "e711548813d38a0db268d89a7edff1d31bbaa9ff0399898e09f8e93bdbc1c785" }, "php-grandparent-resolution/app/Models/A.php": { "captureGroups": 9, @@ -265,7 +265,7 @@ }, "php-grandparent-resolution/app/Services/App.php": { "captureGroups": 12, - "digest": "5d0c3524e1ca41ee57bd05fd0a2831804598fca8079ea4ece38045d9c4bb1113" + "digest": "7273ba524f587c01ca3ba9ededfdfef635c20fd9c6ade95225e81aff07ace6a6" }, "php-grouped-imports/app/Models/Repo.php": { "captureGroups": 7, @@ -277,11 +277,11 @@ }, "php-grouped-imports/app/Services/Main.php": { "captureGroups": 15, - "digest": "b315d87f85f0899ed3883ec529b86a57f2d1a88d41b2b615e707307e2889c049" + "digest": "aef9ddf30722a053664637b7fefdfcdd541baf09b24e65a3a0d80fe920189828" }, "php-local-shadow/app/Services/Main.php": { "captureGroups": 9, - "digest": "b4b3e35399501d98521dc9d9281a4e5c94449b580cc6cb9b3ed4187e79ee2c07" + "digest": "52549cd82f7e2e69fb8bb81bca4e5e3487e216f4305023b77579de92374b9ee3" }, "php-local-shadow/app/Utils/Logger.php": { "captureGroups": 5, @@ -293,7 +293,7 @@ }, "php-member-calls/app/Services/UserService.php": { "captureGroups": 11, - "digest": "6f6d5d34edd1cd4e32ea77db7e4b07b73de9ca09bb658123455820af8228d0cc" + "digest": "dfe3ef69a8d6dbd95cf5a6bdc754914973b7208ad0c9575bf2595a0f84ac22cf" }, "php-method-chain-binding/App.php": { "captureGroups": 48, @@ -309,7 +309,7 @@ }, "php-method-enrichment/src/app.php": { "captureGroups": 11, - "digest": "52791f6945c8c4ae083b816bd3af239bce44f0e97cbf80cdc119f4f366015138" + "digest": "ebfa35aa3eb065977f922ae72f353d5bf31dd49956c3e9aaef349718e555b7a7" }, "php-mro-arity-mismatch/app/Models/ChildModel.php": { "captureGroups": 16, @@ -325,11 +325,11 @@ }, "php-mro-arity-mismatch/app/Services/Caller.php": { "captureGroups": 22, - "digest": "d51fdbcf226f31f9220a506cceb9f538642ff99d238a1828b6b43fce6193f664" + "digest": "20f76e10fc598152597ba8df71deccb698761938bfde8cf7c3437044c8fcdcf1" }, "php-namespace-fallback-isolation/src/App/Caller.php": { "captureGroups": 13, - "digest": "d5040d068fd8227388da7f25cc471f154adb37cd6bbfb78b5cac00f44bf45734" + "digest": "48f132404e2d00efbf49302a69e5ab31540eb0eac71cd2fca43a8a0eb4533d98" }, "php-namespace-fallback-isolation/src/App/Utils/Caller.php": { "captureGroups": 8, @@ -353,7 +353,7 @@ }, "php-nullable-receiver/app/Services/AppService.php": { "captureGroups": 13, - "digest": "cea6ae3f9e32a3e0448a279034bcf039b1d5af6334ab1b1ae43d9c55b6ca094d" + "digest": "52e6db35e8fa4174ce698fec802b9b034dcda8f49fd7ab98cd9e884a6bd9777f" }, "php-overload-dispatch/src/Services/Formatter.php": { "captureGroups": 7, @@ -365,7 +365,7 @@ }, "php-overload-dispatch/src/app.php": { "captureGroups": 10, - "digest": "d11621fbaec9f5015e61f35b5c2747b9f2da090a346adc5fcc10f191af61f048" + "digest": "d29646b0c2d1e072ee0265acf641f99f3c443b57859c2582b782d5a485bfb089" }, "php-parent-resolution/app/Models/BaseModel.php": { "captureGroups": 7, @@ -421,7 +421,7 @@ }, "php-receiver-resolution/app/Services/AppService.php": { "captureGroups": 13, - "digest": "59783c7af75e9075f00f425984ca1ba7a4c556bb0301e2eb2c3fdcaa94cd547f" + "digest": "5e92226af09405c7fc4a02d3aeafcedc8462f3e525b8f67b8509f38091488505" }, "php-response-shapes/api/items.php": { "captureGroups": 11, @@ -445,7 +445,7 @@ }, "php-return-type/app/Services/UserService.php": { "captureGroups": 17, - "digest": "fcc2d78ac4bdde1ac5179e6623a35299465bcbf9bb016375100bcb636624d043" + "digest": "ac4851815d7a450ab92f7f68ccadabf219d2534b99ef1b65ced0a344188f2a69" }, "php-self-this-resolution/app/Models/Repo.php": { "captureGroups": 7, @@ -481,7 +481,7 @@ }, "php-transitive-traits/app/Models/Consumer.php": { "captureGroups": 18, - "digest": "c4524f4fa18f6e7f0fd76a11920a6a65ab547ab4cf0fa5799b42b7678e14db08" + "digest": "19b4d42452d84a4da0d1f05a03880561cee4d77ae0efd382351933875787ad96" }, "php-transitive-traits/app/Traits/TraitA.php": { "captureGroups": 8, @@ -501,7 +501,7 @@ }, "php-typed-properties/app/Services/UserService.php": { "captureGroups": 12, - "digest": "2ec84482a3e2332f3f15ebb3f2d8d01d1d56e62da256127ece571a8c2e0c070c" + "digest": "9f0540a4f7f322ee96dad4437c4e41e087f29a6993d2659fc48bd26e3277e963" }, "php-typed-property-dedup/app/Models/UserRepo.php": { "captureGroups": 7, @@ -509,7 +509,7 @@ }, "php-typed-property-dedup/app/Services/Mixed.php": { "captureGroups": 14, - "digest": "9f4ba6bd183c20acaad547b6a713e80498eb70eaabaa7b416650bb64098bde0d" + "digest": "97d09b46f6e66bef700f4686e5988c98372853203885b9a2d99dafce6fdc8343" }, "php-unresolved-receiver-arity/app/Models/Handler.php": { "captureGroups": 21, @@ -529,7 +529,7 @@ }, "php-use-function-const/app/Services/Calculator.php": { "captureGroups": 15, - "digest": "d6996da68f917c3ae6b52b530d166e8266f5053ce6c9b76e1643cb61edcafa38" + "digest": "b2206861c8550b6d2c7610ff167c42f4236c7249c87584b418e05f687233ad22" }, "php-use-function-const/app/Utils/helpers.php": { "captureGroups": 6, @@ -537,7 +537,7 @@ }, "php-variadic-arity-minimum/app/Services/Caller.php": { "captureGroups": 29, - "digest": "d5669fe609abae6b7db19c15c0cb795859e0b4b0570ae83fbe80a392f3775ca8" + "digest": "4ac7be9ff21c52b76630cdcfde0eb41af43e126c2de5db327e9f82c46c3aabbc" }, "php-variadic-arity-minimum/app/Utils/Logger.php": { "captureGroups": 18, @@ -545,7 +545,7 @@ }, "php-variadic-resolution/app/Services/AppService.php": { "captureGroups": 9, - "digest": "8b5298358fba8f578b2470f9d93ffbc1089d1ef3208c1142185880b4dc174751" + "digest": "6591007d2f4ba85b25a59c11c3ae200452fbad23cacb1ebc04291a074b534dd8" }, "php-variadic-resolution/app/Utils/Logger.php": { "captureGroups": 7, diff --git a/gitnexus/test/fixtures/pipeline-golden/mini-repo/expected-graph.json b/gitnexus/test/fixtures/pipeline-golden/mini-repo/expected-graph.json index 67bb7e888..48e06aea3 100644 --- a/gitnexus/test/fixtures/pipeline-golden/mini-repo/expected-graph.json +++ b/gitnexus/test/fixtures/pipeline-golden/mini-repo/expected-graph.json @@ -24,5 +24,5 @@ "MEMBER_OF": 12, "STEP_IN_PROCESS": 12 }, - "edgeDigest": "1e80aba78cb1784276d387e9debd006ae4e82e60ce33c59e338aac4886d98f61" + "edgeDigest": "02858462a2acca13f09b7ae2a81d56fe858e67064552ea966cd9ca242371e029" } diff --git a/gitnexus/test/integration/cfg/__snapshots__/pipeline-pdg.test.ts.snap b/gitnexus/test/integration/cfg/__snapshots__/pipeline-pdg.test.ts.snap index 06e4b033e..f1da88949 100644 --- a/gitnexus/test/integration/cfg/__snapshots__/pipeline-pdg.test.ts.snap +++ b/gitnexus/test/integration/cfg/__snapshots__/pipeline-pdg.test.ts.snap @@ -18,7 +18,7 @@ exports[`U7 — C-family worker-mode --pdg pipeline > C#: --pdg off is byte-iden "Namespace": 1, "Process": 1, }, - "edgeDigest": "2271af66531e4fb54afb2d6e0a024abc536e2109f445e0ecff9868c01661ebfa", + "edgeDigest": "0497d26dbe060d36426bf10cde5db490a6d82c013e0835296723a4c00ef7442a", "relationships": 38, "symbols": 24, } @@ -66,7 +66,7 @@ exports[`U7 — C-family worker-mode --pdg pipeline > Go: --pdg off is byte-iden "File": 1, "Function": 28, }, - "edgeDigest": "731ee1aa406fe7a96c5747dc2e5059f9079fd883dfca70a1933d49ce7f161a99", + "edgeDigest": "33164ebef537538cce43ab1a518d8a5d4944d6d9ad059973b974d3b0fea7caaa", "relationships": 64, "symbols": 37, } @@ -86,7 +86,7 @@ exports[`U7 — C-family worker-mode --pdg pipeline > Java: --pdg off is byte-id "File": 1, "Method": 21, }, - "edgeDigest": "b5e4c1459c59f385949964c3e4e479e67c98a2eaa7e102f4acacb108a9ccec29", + "edgeDigest": "488a3f80683f3e2fd0160847f22537cdd1d48f1e8f34c929562854459abfe03a", "relationships": 46, "symbols": 27, } diff --git a/gitnexus/test/integration/cli-e2e.test.ts b/gitnexus/test/integration/cli-e2e.test.ts index 3cbfbddb5..957a3f4e7 100644 --- a/gitnexus/test/integration/cli-e2e.test.ts +++ b/gitnexus/test/integration/cli-e2e.test.ts @@ -210,8 +210,10 @@ function runEvalServerHostFlagTest( spawnArgs: string[], opts: { timeoutMsg: string; + extraEnv?: Record; onStdout: (params: { stdoutBuffer: string; + stderrBuffer: string; isSettled: () => boolean; settle: (fn: () => void) => void; resolve: () => void; @@ -223,7 +225,7 @@ function runEvalServerHostFlagTest( const child = spawn(process.execPath, [...CLI_SPAWN_PREFIX, 'eval-server', ...spawnArgs], { cwd: MINI_REPO, stdio: ['ignore', 'pipe', 'pipe'], - env: cliEnv(), + env: cliEnv(opts.extraEnv), }); let stdoutBuffer = ''; @@ -255,6 +257,7 @@ function runEvalServerHostFlagTest( try { await opts.onStdout({ stdoutBuffer, + stderrBuffer, isSettled: () => settled, settle, resolve, @@ -1416,10 +1419,25 @@ describe('CLI end-to-end', () => { // Original flag registration test by Val Vladescu (PR #1602). describe('eval-server --host flag', { retry: 2 }, () => { + it('refuses an unauthenticated non-loopback bind before emitting READY', () => { + const result = runCliWithEnv( + ['eval-server', '--port', '0', '--host', '0.0.0.0', '--idle-timeout', '3'], + MINI_REPO, + { GITNEXUS_AUTH_TOKEN: '' }, + 30000, + ); + const output = `${result.stdout}\n${result.stderr}`; + + expect(result.status).toBe(1); + expect(output).toMatch(/non-loopback.*GITNEXUS_AUTH_TOKEN/is); + expect(output).not.toContain('GITNEXUS_EVAL_SERVER_READY:'); + }, 35000); + it('emits READY signal containing the bound host 127.0.0.1', () => { return runEvalServerHostFlagTest( ['--port', '0', '--host', '127.0.0.1', '--idle-timeout', '3'], { + extraEnv: { GITNEXUS_AUTH_TOKEN: '' }, timeoutMsg: 'eval-server did not emit READY signal within 30s', onStdout({ stdoutBuffer, settle, resolve, reject }) { if (!stdoutBuffer.includes('GITNEXUS_EVAL_SERVER_READY:')) return; @@ -1439,12 +1457,26 @@ describe('CLI end-to-end', () => { ); }, 35000); - it('binds to 0.0.0.0 and serves /health on 127.0.0.1 (cross-container use case)', () => { + it('binds to ::1 without a token when IPv6 loopback is available', () => { + return runEvalServerHostFlagTest(['--port', '0', '--host', '::1', '--idle-timeout', '3'], { + extraEnv: { GITNEXUS_AUTH_TOKEN: '' }, + timeoutMsg: 'eval-server --host ::1 did not emit READY signal within 30s', + onStdout({ stdoutBuffer, settle, resolve }) { + if (stdoutBuffer.includes('GITNEXUS_EVAL_SERVER_READY:[::1]:')) { + settle(resolve); + } + }, + }); + }, 35000); + + it('requires the configured bearer token on a 0.0.0.0 bind', () => { + const authToken = 'integration-secret-token'; return runEvalServerHostFlagTest( ['--port', '0', '--host', '0.0.0.0', '--idle-timeout', '3'], { + extraEnv: { GITNEXUS_AUTH_TOKEN: authToken }, timeoutMsg: 'eval-server --host 0.0.0.0 did not emit READY signal within 30s', - async onStdout({ stdoutBuffer, isSettled, settle, resolve, reject }) { + async onStdout({ stdoutBuffer, stderrBuffer, isSettled, settle, resolve, reject }) { const readyLine = stdoutBuffer .split('\n') .find((l) => l.startsWith('GITNEXUS_EVAL_SERVER_READY:0.0.0.0:')); @@ -1459,19 +1491,42 @@ describe('CLI end-to-end', () => { return; } - // A server bound to 0.0.0.0 must be reachable on 127.0.0.1 from the same host try { - const res = await fetch(`http://127.0.0.1:${boundPort}/health`); - if (res.status === 200) { + const url = `http://127.0.0.1:${boundPort}/health`; + const missing = await fetch(url); + const wrong = await fetch(url, { + headers: { Authorization: 'Bearer wrong-token' }, + }); + const correct = await fetch(url, { + headers: { Authorization: `Bearer ${authToken}` }, + }); + const responseText = `${await missing.text()}${await wrong.text()}${await correct.text()}`; + + if ( + missing.status === 401 && + wrong.status === 401 && + correct.status === 200 && + missing.headers.get('www-authenticate') === 'Bearer' && + wrong.headers.get('www-authenticate') === 'Bearer' && + !responseText.includes(authToken) && + !stdoutBuffer.includes(authToken) && + !stderrBuffer.includes(authToken) + ) { settle(resolve); } else { - settle(() => reject(new Error(`/health returned ${res.status}, expected 200`))); + settle(() => + reject( + new Error( + `/health auth statuses were ${missing.status}/${wrong.status}/${correct.status}; expected 401/401/200`, + ), + ), + ); } } catch (err) { settle(() => reject( new Error( - `eval-server bound to 0.0.0.0 but /health unreachable on 127.0.0.1:${boundPort}: ${err}`, + `authenticated eval-server health probe failed on 127.0.0.1:${boundPort}: ${err}`, ), ), ); @@ -1485,6 +1540,7 @@ describe('CLI end-to-end', () => { return runEvalServerHostFlagTest( ['--port', '0', '--host', 'localhost', '--idle-timeout', '3'], { + extraEnv: { GITNEXUS_AUTH_TOKEN: '' }, timeoutMsg: 'eval-server --host localhost did not emit READY signal within 30s', async onStdout({ stdoutBuffer, isSettled, settle, resolve, reject }) { const readyLine = stdoutBuffer diff --git a/gitnexus/test/integration/local-backend-calltool.test.ts b/gitnexus/test/integration/local-backend-calltool.test.ts index 0da59583a..d352df04a 100644 --- a/gitnexus/test/integration/local-backend-calltool.test.ts +++ b/gitnexus/test/integration/local-backend-calltool.test.ts @@ -102,6 +102,31 @@ withTestLbugDB( expect(depNames).toContain('login'); }); + it.each(['name', 'symbol'] as const)( + 'impact tool resolves the %s compatibility alias against a real index', + async (alias) => { + const result = await backend.callTool('impact', { + [alias]: 'validate', + direction: 'upstream', + }); + expect(result).not.toHaveProperty('error'); + expect(result.target?.name).toBe('validate'); + const directDeps = result.byDepth[1] || result.byDepth['1'] || []; + expect(directDeps.map((d: any) => d.name)).toContain('login'); + }, + ); + + it('context tool resolves the file compatibility alias against a real index', async () => { + const result = await backend.callTool('context', { + name: 'authenticate', + file: 'src/base.ts', + }); + expect(result).not.toHaveProperty('error'); + expect(result.status).toBe('found'); + expect(result.symbol?.name).toBe('authenticate'); + expect(result.symbol?.filePath).toBe('src/base.ts'); + }); + it('query tool returns results for keyword search', async () => { const result = await backend.callTool('query', { query: 'login' }); expect(result).not.toHaveProperty('error'); diff --git a/gitnexus/test/integration/resolvers/php.test.ts b/gitnexus/test/integration/resolvers/php.test.ts index 73b8f5bab..d25d7da67 100644 --- a/gitnexus/test/integration/resolvers/php.test.ts +++ b/gitnexus/test/integration/resolvers/php.test.ts @@ -1621,6 +1621,11 @@ describe('PHP cross-file binding propagation', () => { (e) => e.sourceFilePath.includes('Main') && e.targetFilePath.includes('UserFactory'), ); expect(edge).toBeDefined(); + + const unrelatedEdge = imports.find( + (e) => e.sourceFilePath.includes('Main') && e.targetFilePath.endsWith('/Models/User.php'), + ); + expect(unrelatedEdge).toBeUndefined(); }); it('resolves $u->save() in run() to User#save via cross-file return type propagation', () => { diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index afee977fd..eb0b94e26 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -327,6 +327,138 @@ describe('LocalBackend.callTool', () => { ); }); + it.each(['name', 'symbol'] as const)( + 'normalizes impact.%s to target once before local dispatch', + async (alias) => { + const impactSpy = vi + .spyOn(backend as any, 'impact') + .mockResolvedValue({ status: 'normalized' }); + + const result = await backend.callTool('impact', { + [alias]: ' validate ', + direction: 'upstream', + }); + + expect(result).toEqual({ status: 'normalized' }); + const dispatched = impactSpy.mock.calls[0][1] as Record; + expect(dispatched.target).toBe('validate'); + expect(dispatched).not.toHaveProperty('name'); + expect(dispatched).not.toHaveProperty('symbol'); + }, + ); + + it('normalizes context.file to file_path once before local dispatch', async () => { + const contextSpy = vi + .spyOn(backend as any, 'context') + .mockResolvedValue({ status: 'normalized' }); + + const result = await backend.callTool('context', { + name: 'validate', + file: ' src/auth.ts ', + }); + + expect(result).toEqual({ status: 'normalized' }); + const dispatched = contextSpy.mock.calls[0][1] as Record; + expect(dispatched.file_path).toBe('src/auth.ts'); + expect(dispatched).not.toHaveProperty('file'); + }); + + it('treats undefined optional alias keys from CLI callers as absent', async () => { + const contextSpy = vi + .spyOn(backend as any, 'context') + .mockResolvedValue({ status: 'normalized' }); + + const result = await backend.callTool('context', { + name: 'validate', + file_path: undefined, + file: undefined, + }); + + expect(result).toEqual({ status: 'normalized' }); + expect(contextSpy.mock.calls[0][1]).toMatchObject({ name: 'validate' }); + }); + + it('allows agreeing canonical and alias values after trimming', async () => { + const impactSpy = vi + .spyOn(backend as any, 'impact') + .mockResolvedValue({ status: 'normalized' }); + + await backend.callTool('impact', { + target: 'validate', + name: ' validate ', + symbol: 'validate', + direction: 'upstream', + }); + + expect(impactSpy.mock.calls[0][1]).toMatchObject({ target: 'validate' }); + }); + + it.each([ + ['impact', { target: 'validate', name: 'login', direction: 'upstream' }], + ['impact', { name: 'validate', symbol: 'login', direction: 'upstream' }], + ['context', { name: 'validate', file_path: 'src/auth.ts', file: 'src/login.ts' }], + ])('rejects conflicting %s aliases before repository resolution', async (method, params) => { + const resolveSpy = vi.spyOn(backend, 'resolveRepo'); + + const result = await backend.callTool(method, params); + + expect(result.error).toMatch(/conflicting mcp parameters/i); + expect(resolveSpy).not.toHaveBeenCalled(); + }); + + it.each([ + ['impact', { target: '', direction: 'upstream' }], + ['impact', { name: 42, direction: 'upstream' }], + ['context', { name: 'validate', file: ' ' }], + ['context', { name: 'validate', file: null }], + ])('rejects invalid %s aliases before repository resolution', async (method, params) => { + const resolveSpy = vi.spyOn(backend, 'resolveRepo'); + + const result = await backend.callTool(method, params); + + expect(result.error).toMatch(/non-empty string/i); + expect(resolveSpy).not.toHaveBeenCalled(); + }); + + it('rejects a missing impact target before repository resolution', async () => { + const resolveSpy = vi.spyOn(backend, 'resolveRepo'); + + const result = await backend.callTool('impact', { direction: 'upstream' }); + + expect(result.error).toMatch(/requires target, name, symbol, or target_uid/i); + expect(resolveSpy).not.toHaveBeenCalled(); + }); + + it('preserves target_uid-only impact dispatch', async () => { + const impactSpy = vi + .spyOn(backend as any, 'impact') + .mockResolvedValue({ status: 'normalized' }); + + await backend.callTool('impact', { + target_uid: 'Function:src/auth.ts:validate', + direction: 'upstream', + }); + + expect(impactSpy.mock.calls[0][1]).toMatchObject({ + target_uid: 'Function:src/auth.ts:validate', + }); + }); + + it('normalizes impact aliases before @group forwarding', async () => { + resolveAtMemberMock.mockResolvedValue({ ok: true, repoPath: '/tmp/test-project' }); + const groupImpactSpy = vi + .spyOn(backend.getGroupService(), 'groupImpact') + .mockResolvedValue({ status: 'normalized' } as any); + + await backend.callTool('impact', { + symbol: 'validate', + direction: 'upstream', + repo: '@grp', + }); + + expect(groupImpactSpy.mock.calls[0][0]).toMatchObject({ target: 'validate' }); + }); + it('dispatches query tool', async () => { (executeParameterized as any).mockResolvedValue([]); const result = await backend.callTool('query', { query: 'auth' }); diff --git a/gitnexus/test/unit/community-processor.test.ts b/gitnexus/test/unit/community-processor.test.ts index 7f62861d3..c0e5ff14c 100644 --- a/gitnexus/test/unit/community-processor.test.ts +++ b/gitnexus/test/unit/community-processor.test.ts @@ -102,6 +102,26 @@ describe('community-processor', () => { expect(projection.symbolCount).toBe(3); }); + it('produces the same projection regardless of graph insertion order', () => { + const first = createKnowledgeGraph(); + for (const id of ['fn:c', 'fn:a', 'fn:b']) { + first.addNode(makeNode(id, id.slice(3))); + } + first.addRelationship(makeRel('rel:ac', 'fn:a', 'fn:c')); + first.addRelationship(makeRel('rel:ab', 'fn:a', 'fn:b')); + first.addRelationship(makeRel('rel:bc', 'fn:b', 'fn:c')); + + const second = createKnowledgeGraph(); + for (const id of ['fn:b', 'fn:c', 'fn:a']) { + second.addNode(makeNode(id, id.slice(3))); + } + second.addRelationship(makeRel('rel:bc', 'fn:c', 'fn:b')); + second.addRelationship(makeRel('rel:ab', 'fn:b', 'fn:a')); + second.addRelationship(makeRel('rel:ac', 'fn:c', 'fn:a')); + + expect(buildCommunityProjection(second)).toEqual(buildCommunityProjection(first)); + }); + it('exports a deterministic undirected CSR adjacency', () => { const projection = { nodes: [ diff --git a/gitnexus/test/unit/eval-server-auth.test.ts b/gitnexus/test/unit/eval-server-auth.test.ts new file mode 100644 index 000000000..b0ee55bb4 --- /dev/null +++ b/gitnexus/test/unit/eval-server-auth.test.ts @@ -0,0 +1,126 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + assertSecureEvalServerBinding, + isEvalServerBearerAuthorized, + isEvalServerLoopbackHost, + resolveEvalServerAuthToken, + resolveEvalServerAuthTokenForHost, + resolveEvalServerBindHost, +} from '../../src/cli/eval-server.js'; + +describe('eval-server bearer authentication', () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + }); + + it('resolves a trimmed token and treats blank values as absent', () => { + expect(resolveEvalServerAuthToken({ GITNEXUS_AUTH_TOKEN: ' secret-value ' })).toBe( + 'secret-value', + ); + expect(resolveEvalServerAuthToken({ GITNEXUS_AUTH_TOKEN: '' })).toBeUndefined(); + expect(resolveEvalServerAuthToken({ GITNEXUS_AUTH_TOKEN: ' ' })).toBeUndefined(); + }); + + it('loads .env.local before .env while preserving explicit shell values', () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'gitnexus-eval-auth-')); + tempDirs.push(cwd); + writeFileSync(path.join(cwd, '.env'), 'GITNEXUS_AUTH_TOKEN=from-env\n'); + writeFileSync(path.join(cwd, '.env.local'), 'GITNEXUS_AUTH_TOKEN=from-local\n'); + + expect(resolveEvalServerAuthToken({}, cwd)).toBe('from-local'); + expect(resolveEvalServerAuthToken({ GITNEXUS_AUTH_TOKEN: 'from-shell' }, cwd)).toBe( + 'from-shell', + ); + expect(resolveEvalServerAuthToken({ GITNEXUS_AUTH_TOKEN: '' }, cwd)).toBeUndefined(); + }); + + it('defers an unreadable env file on loopback and stays fail-closed for remote binds', () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'gitnexus-eval-auth-')); + tempDirs.push(cwd); + mkdirSync(path.join(cwd, '.env.local')); + + const loopback = resolveEvalServerAuthTokenForHost('127.0.0.1', {}, cwd); + expect(loopback.token).toBeUndefined(); + expect(loopback.warning).toMatch(/Unable to read eval-server authentication/i); + expect(loopback.warning).toMatch(/loopback/i); + + expect(() => resolveEvalServerAuthTokenForHost('0.0.0.0', {}, cwd)).toThrow( + /Unable to read eval-server authentication/i, + ); + }); + + it('resolves the token for a host without touching files when the shell provides it', () => { + const resolved = resolveEvalServerAuthTokenForHost('0.0.0.0', { + GITNEXUS_AUTH_TOKEN: 'from-shell', + }); + expect(resolved).toEqual({ token: 'from-shell' }); + }); + + it('falls back to .env when .env.local is absent', () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'gitnexus-eval-auth-')); + tempDirs.push(cwd); + writeFileSync(path.join(cwd, '.env'), 'GITNEXUS_AUTH_TOKEN="from env"\n'); + + expect(resolveEvalServerAuthToken({}, cwd)).toBe('from env'); + }); + + it('resolves DNS bind names to the concrete IPv4 used for the security decision', async () => { + const resolveHostname = async (hostname: string) => { + expect(hostname).toBe('devbox.local'); + return '192.168.1.50'; + }; + + await expect(resolveEvalServerBindHost('devbox.local', resolveHostname)).resolves.toBe( + '192.168.1.50', + ); + await expect(resolveEvalServerBindHost('devbox.local', async () => '::1')).resolves.toBeNull(); + await expect(resolveEvalServerBindHost('not a hostname', resolveHostname)).resolves.toBeNull(); + }); + + it('preserves literal IP addresses without a DNS lookup', async () => { + let lookupCalled = false; + await expect( + resolveEvalServerBindHost('10.0.0.2', async () => { + lookupCalled = true; + return '127.0.0.1'; + }), + ).resolves.toBe('10.0.0.2'); + expect(lookupCalled).toBe(false); + }); + + it.each(['127.0.0.1', '127.0.0.2', 'localhost', '::1'])('classifies %s as loopback', (host) => { + expect(isEvalServerLoopbackHost(host)).toBe(true); + }); + + it.each(['0.0.0.0', '::', '192.168.1.50', '2001:db8::1', 'localhost.evil.test'])( + 'classifies %s as non-loopback', + (host) => { + expect(isEvalServerLoopbackHost(host)).toBe(false); + }, + ); + + it('allows loopback without a token and requires one for non-loopback binds', () => { + expect(() => assertSecureEvalServerBinding('127.0.0.1', undefined)).not.toThrow(); + expect(() => assertSecureEvalServerBinding('::1', undefined)).not.toThrow(); + expect(() => assertSecureEvalServerBinding('0.0.0.0', 'secret-value')).not.toThrow(); + expect(() => assertSecureEvalServerBinding('192.168.1.50', undefined)).toThrow( + /non-loopback.*GITNEXUS_AUTH_TOKEN/i, + ); + }); + + it('accepts only the exact Bearer header when a token is configured', () => { + const token = 'secret-value'; + expect(isEvalServerBearerAuthorized(undefined, undefined)).toBe(true); + expect(isEvalServerBearerAuthorized(`Bearer ${token}`, token)).toBe(true); + expect(isEvalServerBearerAuthorized(undefined, token)).toBe(false); + expect(isEvalServerBearerAuthorized(`Bearer wrong`, token)).toBe(false); + expect(isEvalServerBearerAuthorized(token, token)).toBe(false); + expect(isEvalServerBearerAuthorized(`bearer ${token}`, token)).toBe(false); + expect(isEvalServerBearerAuthorized([`Bearer ${token}`], token)).toBe(false); + }); +}); diff --git a/gitnexus/test/unit/filesystem-walker-order.test.ts b/gitnexus/test/unit/filesystem-walker-order.test.ts new file mode 100644 index 000000000..7b75128a4 --- /dev/null +++ b/gitnexus/test/unit/filesystem-walker-order.test.ts @@ -0,0 +1,39 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { glob } from 'glob'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('glob', () => ({ glob: vi.fn() })); +vi.mock('../../src/config/ignore-service.js', () => ({ + createIgnoreFilter: vi.fn(async () => []), +})); + +import { walkRepositoryPaths } from '../../src/core/ingestion/filesystem-walker.js'; + +const temporaryRoots: string[] = []; + +afterEach(async () => { + vi.mocked(glob).mockReset(); + await Promise.all( + temporaryRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })), + ); +}); + +describe('walkRepositoryPaths ordering', () => { + it('returns accepted files in canonical path order when glob order is unstable', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-scan-order-')); + temporaryRoots.push(root); + await Promise.all( + ['zeta.ts', 'alpha.ts', 'middle.ts'].map((file) => + fs.writeFile(path.join(root, file), `export const ${file[0]} = true;\n`), + ), + ); + vi.mocked(glob).mockResolvedValue(['zeta.ts', 'alpha.ts', 'middle.ts']); + + const result = await walkRepositoryPaths(root); + + expect(result.map((entry) => entry.path)).toEqual(['alpha.ts', 'middle.ts', 'zeta.ts']); + }); +}); diff --git a/gitnexus/test/unit/mcp-http-transport.test.ts b/gitnexus/test/unit/mcp-http-transport.test.ts index 105caff2a..7cdf39f9f 100644 --- a/gitnexus/test/unit/mcp-http-transport.test.ts +++ b/gitnexus/test/unit/mcp-http-transport.test.ts @@ -638,18 +638,18 @@ describe('createSseHandlers', () => { // ─── mountMCPEndpoints refactor safety ─────────────────────────────── describe('mountMCPEndpoints', () => { - it('returns a cleanup function', () => { + it('returns a cleanup function', async () => { const backend = createMockBackend(); const mockApp = { all: vi.fn(), }; - const cleanup = mountMCPEndpoints(mockApp as never, backend as never); + const cleanup = await mountMCPEndpoints(mockApp as never, backend as never); expect(typeof cleanup).toBe('function'); }); - it('registers the /api/mcp route', () => { + it('registers the /api/mcp route', async () => { const backend = createMockBackend(); const allCalls: Array<[string, ...unknown[]]> = []; const mockApp = { @@ -658,7 +658,7 @@ describe('mountMCPEndpoints', () => { }), }; - mountMCPEndpoints(mockApp as never, backend as never); + await mountMCPEndpoints(mockApp as never, backend as never); const registeredPaths = allCalls.map(([path]) => path); expect(registeredPaths).toContain('/api/mcp'); @@ -670,7 +670,7 @@ describe('mountMCPEndpoints', () => { all: vi.fn(), }; - const cleanup = mountMCPEndpoints(mockApp as never, backend as never); + const cleanup = await mountMCPEndpoints(mockApp as never, backend as never); await expect(cleanup()).resolves.not.toThrow(); }); diff --git a/gitnexus/test/unit/mcp-output-budget.test.ts b/gitnexus/test/unit/mcp-output-budget.test.ts new file mode 100644 index 000000000..8e568f014 --- /dev/null +++ b/gitnexus/test/unit/mcp-output-budget.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { + applyMcpMaxTokens, + MCP_TRUNCATION_MARKER, + resolveMcpMaxTokens, + withoutMcpBudgetArg, +} from '../../src/mcp/output-budget.js'; + +describe('MCP output budget helpers', () => { + it('returns the original string byte-for-byte without a configured budget', () => { + const text = 'alpha😀omega'; + expect(applyMcpMaxTokens(text, undefined)).toBe(text); + }); + + it('uses the complete marker and stays within a one-token budget', () => { + const text = applyMcpMaxTokens('this response is too long', 1); + expect(text).toBe(MCP_TRUNCATION_MARKER); + expect(Buffer.byteLength(text, 'utf8')).toBe(4); + }); + + it('never splits a multi-byte Unicode code point', () => { + const text = applyMcpMaxTokens('😀😀😀😀', 3); + expect(text.endsWith(MCP_TRUNCATION_MARKER)).toBe(true); + expect(text).not.toContain('\uFFFD'); + expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(12); + }); + + it('rejects malformed environment defaults for budgeted tools', () => { + expect(() => + resolveMcpMaxTokens('query', undefined, { + GITNEXUS_MCP_DEFAULT_MAX_TOKENS: '1.5', + }), + ).toThrow(/positive integer/i); + }); + + it('lets a valid explicit value override a malformed environment default', () => { + expect( + resolveMcpMaxTokens( + 'impact', + { maxTokens: 17 }, + { + GITNEXUS_MCP_DEFAULT_MAX_TOKENS: 'invalid', + }, + ), + ).toBe(17); + }); + + it('ignores the environment default for tools without output budgets', () => { + expect( + resolveMcpMaxTokens('cypher', undefined, { + GITNEXUS_MCP_DEFAULT_MAX_TOKENS: 'invalid', + }), + ).toBeUndefined(); + }); + + it('removes only the transport-level maxTokens argument', () => { + const args = { search_query: 'auth', maxTokens: 20, repo: 'app' }; + expect(withoutMcpBudgetArg(args)).toEqual({ search_query: 'auth', repo: 'app' }); + expect(args).toEqual({ search_query: 'auth', maxTokens: 20, repo: 'app' }); + }); +}); diff --git a/gitnexus/test/unit/mcp-read-only.test.ts b/gitnexus/test/unit/mcp-read-only.test.ts new file mode 100644 index 000000000..16a203b95 --- /dev/null +++ b/gitnexus/test/unit/mcp-read-only.test.ts @@ -0,0 +1,251 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { createMCPServer } from '../../src/mcp/server.js'; +import type { LocalBackend } from '../../src/mcp/local/local-backend.js'; + +const READ_ONLY_TOOLS = [ + 'api_impact', + 'check', + 'context', + 'detect_changes', + 'explain', + 'impact', + 'list_repos', + 'pdg_query', + 'query', + 'route_map', + 'shape_check', + 'tool_map', + 'trace', +]; + +function createMockBackend() { + return { + callTool: vi.fn().mockResolvedValue({ result: 'ok' }), + listRepos: vi.fn().mockResolvedValue([]), + resolveRepo: vi + .fn() + .mockResolvedValue({ name: 'test', repoPath: '/tmp/test', lastCommit: 'abc' }), + getContext: vi.fn().mockReturnValue(null), + queryClusters: vi.fn().mockResolvedValue({ clusters: [] }), + queryProcesses: vi.fn().mockResolvedValue({ processes: [] }), + queryClusterDetail: vi.fn().mockResolvedValue({ error: 'not found' }), + queryProcessDetail: vi.fn().mockResolvedValue({ error: 'not found' }), + readGroupContractsResource: vi.fn().mockResolvedValue('contracts'), + readGroupStatusResource: vi.fn().mockResolvedValue('status'), + disconnect: vi.fn().mockResolvedValue(undefined), + }; +} + +async function connect(backend = createMockBackend()) { + const server = createMCPServer(backend as unknown as LocalBackend); + const client = new Client({ name: 'read-only-test-client', version: '0.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + return { + backend, + client, + close: async () => { + await client.close(); + await server.close(); + }, + }; +} + +function enableReadOnly(): void { + vi.stubEnv('GITNEXUS_MCP_READ_ONLY', '1'); +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('MCP read-only mode', () => { + it('discovers only proven single-repository read tools', async () => { + enableReadOnly(); + const session = await connect(); + try { + const response = await session.client.listTools(); + expect(response.tools.map((tool) => tool.name).sort()).toEqual(READ_ONLY_TOOLS); + for (const tool of response.tools) { + expect(tool.description).not.toMatch(/GROUP MODE|CROSS-REPO|@/); + const properties = tool.inputSchema.properties as Record< + string, + { description?: string } | undefined + >; + const repo = properties.repo; + if (repo) expect(repo.description).not.toContain('@group'); + expect(properties.subgroup).toBeUndefined(); + expect(properties.crossDepth).toBeUndefined(); + } + } finally { + await session.close(); + } + }); + + it.each(['rename', 'group_sync', 'group_list', 'unknown_dynamic_tool'])( + 'rejects hidden tool %s before backend dispatch', + async (name) => { + enableReadOnly(); + const session = await connect(); + try { + const response = await session.client.callTool({ name, arguments: {} }); + expect(response.isError).toBe(true); + expect(response.content[0]).toMatchObject({ type: 'text' }); + expect((response.content[0] as { text: string }).text).toMatch(/read-only mode/i); + expect(session.backend.callTool).not.toHaveBeenCalled(); + } finally { + await session.close(); + } + }, + ); + + it.each(['CREATE (n:Injected)', 'MATCH (n) DETACH DELETE n', 'DROP TABLE Node'])( + 'rejects raw cypher before backend dispatch: %s', + async (statement) => { + enableReadOnly(); + const session = await connect(); + try { + const response = await session.client.callTool({ + name: 'cypher', + arguments: { repo: 'test', statement }, + }); + expect(response.isError).toBe(true); + expect((response.content[0] as { text: string }).text).toMatch(/read-only mode/i); + expect(session.backend.callTool).not.toHaveBeenCalled(); + } finally { + await session.close(); + } + }, + ); + + it.each(['query', 'context', 'impact', 'trace'])( + 'rejects @group routing through %s before backend dispatch', + async (name) => { + enableReadOnly(); + const session = await connect(); + try { + const response = await session.client.callTool({ + name, + arguments: { repo: ' @portfolio/service-a ', target: 'auth', name: 'auth' }, + }); + expect(response.isError).toBe(true); + expect((response.content[0] as { text: string }).text).toMatch(/group.*read-only mode/i); + expect(session.backend.callTool).not.toHaveBeenCalled(); + } finally { + await session.close(); + } + }, + ); + + it.each([ + ['impact', { target: 'auth', direction: 'upstream', crossDepth: 5 }], + ['impact', { target: 'auth', direction: 'upstream', subgroup: 'services' }], + ])('rejects group-only arguments before backend dispatch: %s %o', async (name, args) => { + enableReadOnly(); + const session = await connect(); + try { + const response = await session.client.callTool({ name, arguments: args }); + expect(response.isError).toBe(true); + expect((response.content[0] as { text: string }).text).toMatch(/read-only mode/i); + expect(session.backend.callTool).not.toHaveBeenCalled(); + } finally { + await session.close(); + } + }); + + it.each(['search', 'explore', 'overview'])('preserves legacy read alias %s', async (name) => { + enableReadOnly(); + const session = await connect(); + try { + const response = await session.client.callTool({ name, arguments: { repo: 'test' } }); + expect(response.isError).not.toBe(true); + expect(session.backend.callTool).toHaveBeenCalledWith(name, { repo: 'test' }); + } finally { + await session.close(); + } + }); + + it.each([ + 'gitnexus://group/acme/status', + 'GITNEXUS://GROUP/acme/status', + 'gitnexus://user@group/acme/status', + ])('omits group resource templates and rejects disguised group resource read %s', async (uri) => { + enableReadOnly(); + const session = await connect(); + try { + const templates = await session.client.listResourceTemplates(); + expect(templates.resourceTemplates.map((item) => item.uriTemplate)).not.toContain( + 'gitnexus://group/{name}/contracts', + ); + expect(templates.resourceTemplates.map((item) => item.uriTemplate)).not.toContain( + 'gitnexus://group/{name}/status', + ); + + const resource = await session.client.readResource({ uri }); + expect(resource.contents[0]).toMatchObject({ mimeType: 'text/plain' }); + expect((resource.contents[0] as { text: string }).text).toMatch(/group.*read-only mode/i); + expect(session.backend.readGroupStatusResource).not.toHaveBeenCalled(); + } finally { + await session.close(); + } + }); + + it('leaves normal-mode discovery and dispatch unchanged', async () => { + const session = await connect(); + try { + const tools = await session.client.listTools(); + expect(tools.tools.map((tool) => tool.name)).toEqual( + expect.arrayContaining(['cypher', 'rename', 'group_list', 'group_sync']), + ); + + const response = await session.client.callTool({ + name: 'cypher', + arguments: { statement: 'MATCH (n) RETURN n LIMIT 1' }, + }); + expect(response.isError).not.toBe(true); + expect(session.backend.callTool).toHaveBeenCalledWith('cypher', { + statement: 'MATCH (n) RETURN n LIMIT 1', + }); + } finally { + await session.close(); + } + }); + + it('scrubs hidden tools and group routes from generated resource discovery', async () => { + enableReadOnly(); + const backend = createMockBackend(); + backend.listRepos.mockResolvedValue([ + { + name: 'test', + path: '/tmp/test', + indexedAt: '2026-01-01', + lastCommit: 'abc', + stats: { nodes: 2, edges: 1, processes: 0 }, + }, + ]); + backend.getContext.mockReturnValue({ + projectName: 'test', + stats: { fileCount: 1, functionCount: 2, processCount: 0 }, + }); + const session = await connect(backend); + try { + for (const uri of ['gitnexus://setup', 'gitnexus://repo/test/context']) { + const resource = await session.client.readResource({ uri }); + const text = (resource.contents[0] as { text: string }).text; + expect(text).not.toMatch(/(?:^\s*-\s+|^\|\s*`)(?:rename|cypher)/mu); + expect(text).not.toContain('gitnexus://group/'); + } + } finally { + await session.close(); + } + }); + + it.each(['true', 'banana'])('fails startup for malformed read-only mode %s', (value) => { + vi.stubEnv('GITNEXUS_MCP_READ_ONLY', value); + expect(() => createMCPServer(createMockBackend() as unknown as LocalBackend)).toThrow( + /GITNEXUS_MCP_READ_ONLY must be 0 or 1/i, + ); + }); +}); diff --git a/gitnexus/test/unit/mcp-repository-policy.test.ts b/gitnexus/test/unit/mcp-repository-policy.test.ts new file mode 100644 index 000000000..d5809d9bc --- /dev/null +++ b/gitnexus/test/unit/mcp-repository-policy.test.ts @@ -0,0 +1,379 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import type { LocalBackend, RepoListing } from '../../src/mcp/local/local-backend.js'; +import { createMcpRepositoryPolicy } from '../../src/mcp/repository-policy.js'; +import { createMCPServer } from '../../src/mcp/server.js'; +import { createStreamableHttpHandler, startMcpHttpServer } from '../../src/mcp/http-transport.js'; +import { mountMCPEndpoints } from '../../src/server/mcp-http.js'; + +const REPOS: RepoListing[] = [ + { + name: 'Alpha', + path: '/repos/alpha', + indexedAt: '2026-01-01', + lastCommit: 'a'.repeat(40), + }, + { + name: 'Beta', + path: '/repos/beta', + indexedAt: '2026-01-02', + lastCommit: 'b'.repeat(40), + }, + { + name: 'Duplicate', + path: '/repos/duplicate-one', + indexedAt: '2026-01-03', + lastCommit: 'c'.repeat(40), + }, + { + name: 'duplicate', + path: '/repos/duplicate-two', + indexedAt: '2026-01-04', + lastCommit: 'd'.repeat(40), + }, +]; + +function createBackend(repos = REPOS) { + return { + listRepos: vi.fn().mockResolvedValue(repos.map((repo) => ({ ...repo }))), + callTool: vi.fn().mockImplementation(async (name: string, args: Record) => ({ + name, + args, + })), + resolveRepo: vi.fn().mockImplementation(async (repo?: string) => ({ + name: repos.find((entry) => entry.path === repo)?.name ?? repo ?? repos[0]?.name, + repoPath: repo ?? repos[0]?.path, + lastCommit: 'a'.repeat(40), + })), + getContext: vi.fn().mockReturnValue(null), + queryClusters: vi.fn().mockResolvedValue({ clusters: [] }), + queryProcesses: vi.fn().mockResolvedValue({ processes: [] }), + queryClusterDetail: vi.fn().mockResolvedValue({ error: 'not found' }), + queryProcessDetail: vi.fn().mockResolvedValue({ error: 'not found' }), + readGroupContractsResource: vi.fn().mockResolvedValue('contracts'), + readGroupStatusResource: vi.fn().mockResolvedValue('status'), + } as unknown as LocalBackend; +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('MCP repository policy', () => { + it('trims, resolves, and deduplicates configured repository specifiers', async () => { + const backend = createBackend(); + const policy = await createMcpRepositoryPolicy(backend, { + GITNEXUS_MCP_ALLOWED_REPOS: ' Alpha, /repos/beta, alpha, /repos/alpha ', + GITNEXUS_MCP_DEFAULT_REPO: ' ALPHA ', + }); + const scoped = policy.scopeBackend(backend); + + const repos = await scoped.listRepos(); + expect(repos.map((repo) => repo.name)).toEqual(['Alpha', 'Beta']); + + await scoped.callTool('query', { search_query: 'auth' }); + expect(backend.callTool).toHaveBeenLastCalledWith('query', { + search_query: 'auth', + repo: '/repos/alpha', + }); + + await scoped.callTool('context', { name: 'auth', repo: ' beta ' }); + expect(backend.callTool).toHaveBeenLastCalledWith('context', { + name: 'auth', + repo: '/repos/beta', + }); + }); + + it('filters list_repos before applying pagination and totals', async () => { + const alpha = REPOS[0]; + if (!alpha) throw new Error('Alpha fixture is required'); + const backend = createBackend([ + { + ...alpha, + siblings: [{ name: 'Duplicate', path: '/repos/duplicate-one', lastCommit: 'c' }], + }, + ...REPOS.slice(1), + ]); + const policy = await createMcpRepositoryPolicy(backend, { + GITNEXUS_MCP_ALLOWED_REPOS: 'Beta,Alpha', + }); + const scoped = policy.scopeBackend(backend); + + const page = (await scoped.callTool('list_repos', { limit: 1, offset: 0 })) as { + repositories: RepoListing[]; + pagination: { total: number; returned: number; hasMore: boolean; nextOffset?: number }; + }; + expect(page.repositories.map((repo) => repo.name)).toEqual(['Alpha']); + expect(page.repositories[0]?.siblings).toBeUndefined(); + expect(page.pagination).toMatchObject({ + total: 2, + returned: 1, + hasMore: true, + nextOffset: 1, + }); + expect(backend.callTool).not.toHaveBeenCalled(); + }); + + it('uses the only allowed repository as the implicit default', async () => { + const backend = createBackend(); + const policy = await createMcpRepositoryPolicy(backend, { + GITNEXUS_MCP_ALLOWED_REPOS: 'Beta', + }); + await policy.scopeBackend(backend).callTool('search', { query: 'auth' }); + expect(backend.callTool).toHaveBeenCalledWith('search', { + query: 'auth', + repo: '/repos/beta', + }); + }); + + it('requires an explicit repo when multiple repositories are allowed without a default', async () => { + const backend = createBackend(); + const policy = await createMcpRepositoryPolicy(backend, { + GITNEXUS_MCP_ALLOWED_REPOS: 'Alpha,Beta', + }); + await expect( + policy.scopeBackend(backend).callTool('query', { search_query: 'auth' }), + ).rejects.toThrow(/explicit repo.*multiple repositories are allowed/i); + expect(backend.callTool).not.toHaveBeenCalled(); + }); + + it('fails startup when the default is outside the allowlist after canonical resolution', async () => { + const backend = createBackend(); + await expect( + createMcpRepositoryPolicy(backend, { + GITNEXUS_MCP_ALLOWED_REPOS: 'Alpha', + GITNEXUS_MCP_DEFAULT_REPO: 'Beta', + }), + ).rejects.toThrow(/default repository is not in the configured allowlist/i); + }); + + it.each([ + [{ GITNEXUS_MCP_ALLOWED_REPOS: 'Missing' }, 'invalid'], + [{ GITNEXUS_MCP_ALLOWED_REPOS: 'Duplicate' }, 'ambiguous'], + [{ GITNEXUS_MCP_DEFAULT_REPO: 'Duplicate' }, 'ambiguous'], + ])('fails startup with a sanitized %s configuration error', async (env, reason) => { + const backend = createBackend(); + let message = ''; + try { + await createMcpRepositoryPolicy(backend, env); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + expect(message).toMatch(new RegExp(reason, 'i')); + expect(message).not.toContain('/repos/'); + expect(message).not.toContain('Alpha'); + expect(message).not.toContain('Beta'); + }); + + it('allows a duplicate-name repository when configured by its unique path', async () => { + const backend = createBackend(); + const policy = await createMcpRepositoryPolicy(backend, { + GITNEXUS_MCP_ALLOWED_REPOS: '/repos/duplicate-two', + GITNEXUS_MCP_DEFAULT_REPO: '/repos/duplicate-two', + }); + await policy.scopeBackend(backend).callTool('overview', {}); + expect(backend.callTool).toHaveBeenCalledWith('overview', { repo: '/repos/duplicate-two' }); + }); + + it('rejects hidden and ambiguous selections without revealing registry contents', async () => { + const backend = createBackend(); + const policy = await createMcpRepositoryPolicy(backend, { + GITNEXUS_MCP_ALLOWED_REPOS: 'Alpha', + }); + const scoped = policy.scopeBackend(backend); + + for (const repo of ['Beta', 'Duplicate', '/repos/duplicate-two']) { + await expect(scoped.callTool('context', { name: 'auth', repo })).rejects.toThrow( + /repository is not available through this MCP server/i, + ); + } + expect(backend.callTool).not.toHaveBeenCalled(); + }); + + it('enforces the policy on resources and group methods', async () => { + const backend = createBackend(); + const policy = await createMcpRepositoryPolicy(backend, { + GITNEXUS_MCP_ALLOWED_REPOS: 'Alpha', + }); + const scoped = policy.scopeBackend(backend); + + await expect(scoped.resolveRepo('Beta')).rejects.toThrow(/not available/i); + await expect(scoped.readGroupStatusResource('portfolio')).rejects.toThrow( + /group.*unavailable/i, + ); + await expect(scoped.readGroupContractsResource('portfolio', {})).rejects.toThrow( + /group.*unavailable/i, + ); + await expect(scoped.callTool('group_list', {})).rejects.toThrow(/group.*unavailable/i); + await expect( + scoped.callTool('query', { repo: '@portfolio', search_query: 'auth' }), + ).rejects.toThrow(/group.*unavailable/i); + expect(backend.readGroupStatusResource).not.toHaveBeenCalled(); + }); + + it('enforces the allowlist on repo-scoped query methods without the resource guard', async () => { + const backend = createBackend(); + const policy = await createMcpRepositoryPolicy(backend, { + GITNEXUS_MCP_ALLOWED_REPOS: 'Alpha', + }); + const scoped = policy.scopeBackend(backend); + + expect(() => scoped.queryClusters('Beta')).toThrow(/not available/i); + expect(() => scoped.queryProcesses('Beta')).toThrow(/not available/i); + expect(() => scoped.queryClusterDetail('area', 'Beta')).toThrow(/not available/i); + expect(() => scoped.queryProcessDetail('proc', 'Beta')).toThrow(/not available/i); + + await scoped.queryClusters(); + expect(backend.queryClusters).toHaveBeenCalledWith('/repos/alpha', undefined); + await scoped.queryClusterDetail('area'); + expect(backend.queryClusterDetail).toHaveBeenCalledWith('area', '/repos/alpha'); + }); + + it.each(['GITNEXUS://GROUP/acme/status', 'gitnexus://user@group/acme/status'])( + 'rejects disguised group resource URI %s', + async (uri) => { + const backend = createBackend(); + const policy = await createMcpRepositoryPolicy(backend, { + GITNEXUS_MCP_ALLOWED_REPOS: 'Alpha', + }); + expect(() => policy.assertResourceUri(uri)).toThrow(/group.*unavailable/i); + }, + ); + + it.each([{ GITNEXUS_MCP_ALLOWED_REPOS: ' ' }, { GITNEXUS_MCP_DEFAULT_REPO: ' ' }])( + 'fails closed for explicitly blank repository configuration', + async (env) => { + await expect(createMcpRepositoryPolicy(createBackend(), env)).rejects.toThrow( + /must not be blank/i, + ); + }, + ); + + it('is transparent when no repository policy is configured', async () => { + const backend = createBackend(); + const policy = await createMcpRepositoryPolicy(backend, {}); + await policy.scopeBackend(backend).callTool('query', { search_query: 'auth' }); + expect(backend.callTool).toHaveBeenCalledWith('query', { search_query: 'auth' }); + expect(await policy.scopeBackend(backend).listRepos()).toHaveLength(REPOS.length); + }); + + it('uses a configured default without restricting explicit dynamic selections', async () => { + const backend = createBackend(); + const policy = await createMcpRepositoryPolicy(backend, { + GITNEXUS_MCP_DEFAULT_REPO: 'Alpha', + }); + const scoped = policy.scopeBackend(backend); + + await scoped.callTool('query', { search_query: 'auth' }); + expect(backend.callTool).toHaveBeenLastCalledWith('query', { + search_query: 'auth', + repo: '/repos/alpha', + }); + + await scoped.callTool('query', { search_query: 'auth', repo: 'newly-indexed' }); + expect(backend.callTool).toHaveBeenLastCalledWith('query', { + search_query: 'auth', + repo: 'newly-indexed', + }); + }); + + it('enforces one policy across MCP tools, aliases, discovery, and resources', async () => { + const backend = createBackend(); + const policy = await createMcpRepositoryPolicy(backend, { + GITNEXUS_MCP_ALLOWED_REPOS: 'Alpha', + GITNEXUS_MCP_DEFAULT_REPO: 'Alpha', + }); + const server = createMCPServer(backend, { repositoryPolicy: policy }); + const client = new Client({ name: 'repo-policy-client', version: '0.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + try { + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + + const tools = await client.listTools(); + expect(tools.tools.map((tool) => tool.name)).not.toContain('group_list'); + expect(tools.tools.map((tool) => tool.name)).not.toContain('group_sync'); + for (const tool of tools.tools) { + expect(tool.description).not.toMatch(/GROUP MODE|CROSS-REPO|@/); + } + + const templates = await client.listResourceTemplates(); + expect( + templates.resourceTemplates.every((item) => !item.uriTemplate.includes('/group/')), + ).toBe(true); + + const repos = await client.callTool({ name: 'list_repos', arguments: {} }); + const reposText = (repos.content[0] as { text: string }).text; + expect(reposText).toContain('Alpha'); + expect(reposText).not.toContain('Beta'); + expect(reposText).not.toContain('Duplicate'); + + const query = await client.callTool({ + name: 'query', + arguments: { search_query: 'auth' }, + }); + expect(query.isError).not.toBe(true); + expect(backend.callTool).toHaveBeenLastCalledWith('query', { + search_query: 'auth', + repo: '/repos/alpha', + }); + + const hiddenAlias = await client.callTool({ + name: 'search', + arguments: { query: 'auth', repo: 'Beta' }, + }); + expect(hiddenAlias.isError).toBe(true); + expect((hiddenAlias.content[0] as { text: string }).text).toMatch(/not available/i); + + const reposResource = await client.readResource({ uri: 'gitnexus://repos' }); + const resourceText = (reposResource.contents[0] as { text: string }).text; + expect(resourceText).toContain('Alpha'); + expect(resourceText).not.toContain('Beta'); + + const setupResource = await client.readResource({ uri: 'gitnexus://setup' }); + const setupText = (setupResource.contents[0] as { text: string }).text; + expect(setupText).toContain('Alpha'); + expect(setupText).not.toContain('Beta'); + + const hiddenResource = await client.readResource({ + uri: 'gitnexus://repo/Beta/schema', + }); + expect((hiddenResource.contents[0] as { text: string }).text).toMatch(/not available/i); + } finally { + await client.close(); + await server.close(); + } + }); + + it('refuses direct server construction when configured policy was not prevalidated', () => { + vi.stubEnv('GITNEXUS_MCP_ALLOWED_REPOS', 'Alpha'); + expect(() => createMCPServer(createBackend())).toThrow(/must be validated/i); + }); + + it('fails standalone HTTP startup before binding when registry policy is invalid', async () => { + vi.stubEnv('GITNEXUS_MCP_ALLOWED_REPOS', 'Missing'); + await expect( + startMcpHttpServer(createBackend(), { host: '127.0.0.1', port: 0 }), + ).rejects.toThrow(/invalid repository selection/i); + }); + + it('fails embedded HTTP startup before registering a route when policy is invalid', async () => { + vi.stubEnv('GITNEXUS_MCP_ALLOWED_REPOS', 'Missing'); + const app = { all: vi.fn() }; + + await expect(mountMCPEndpoints(app as never, createBackend())).rejects.toThrow( + /invalid repository selection/i, + ); + expect(app.all).not.toHaveBeenCalled(); + }); + + it('rejects a custom HTTP server factory that would bypass configured policy', () => { + vi.stubEnv('GITNEXUS_MCP_ALLOWED_REPOS', 'Alpha'); + expect(() => + createStreamableHttpHandler(createBackend(), { + createServer: () => createMCPServer(createBackend()), + }), + ).toThrow(/cannot bypass configured repository policy/i); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/node-lookup-determinism.test.ts b/gitnexus/test/unit/scope-resolution/node-lookup-determinism.test.ts new file mode 100644 index 000000000..852ab09f7 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/node-lookup-determinism.test.ts @@ -0,0 +1,94 @@ +import type { NodeLabel } from 'gitnexus-shared'; +import { describe, expect, it } from 'vitest'; + +import { createKnowledgeGraph } from '../../../src/core/graph/graph.js'; +import { createSemanticModel } from '../../../src/core/ingestion/model/semantic-model.js'; +import { mergeChunkResults } from '../../../src/core/ingestion/parsing-processor.js'; +import { + buildGraphNodeLookup, + qualifiedKey, + simpleKey, +} from '../../../src/core/ingestion/scope-resolution/graph-bridge/node-lookup.js'; +import type { ParseWorkerResult } from '../../../src/core/ingestion/workers/parse-worker.js'; + +const FILE = 'src/service.ts'; + +interface Candidate { + id: string; + startLine?: number; +} + +function buildLookup(candidates: readonly Candidate[]) { + const graph = createKnowledgeGraph(); + const nodes = candidates.map( + (candidate) => + ({ + id: candidate.id, + label: 'Method' as NodeLabel, + properties: { + name: 'save', + qualifiedName: 'Service.save', + filePath: FILE, + ...(candidate.startLine !== undefined ? { startLine: candidate.startLine } : {}), + }, + }) satisfies ParseWorkerResult['nodes'][number], + ); + const result: ParseWorkerResult = { + nodes, + relationships: [], + symbols: [], + calls: [], + assignments: [], + routes: [], + fetchCalls: [], + fetchWrapperDefs: [], + decoratorRoutes: [], + routerIncludes: [], + routerImports: [], + toolDefs: [], + ormQueries: [], + constructorBindings: [], + fileScopeBindings: [], + parsedFiles: [], + skippedLanguages: {}, + fileCount: 1, + }; + + mergeChunkResults(graph, createSemanticModel().symbols, [result]); + return buildGraphNodeLookup(graph); +} + +describe('parse-result graph insertion determinism', () => { + it('selects the earliest source definition regardless of worker result order', () => { + const early = { id: `Method:${FILE}:Service.save#1`, startLine: 10 }; + const late = { id: `Method:${FILE}:Service.save#2`, startLine: 20 }; + + const lateFirst = buildLookup([late, early]); + const earlyFirst = buildLookup([early, late]); + + for (const key of [simpleKey(FILE, 'save'), qualifiedKey(FILE, 'Method', 'Service.save')]) { + expect(lateFirst.get(key)).toBe(early.id); + expect(earlyFirst.get(key)).toBe(early.id); + } + }); + + it('uses the stable node id when source positions are identical', () => { + const first = { id: `Method:${FILE}:Service.save#1`, startLine: 10 }; + const second = { id: `Method:${FILE}:Service.save#2`, startLine: 10 }; + + const firstLookup = buildLookup([second, first]); + const secondLookup = buildLookup([first, second]); + + expect(firstLookup.get(simpleKey(FILE, 'save'))).toBe(first.id); + expect(secondLookup.get(simpleKey(FILE, 'save'))).toBe(first.id); + }); + + it('uses the stable node id when source positions are unavailable', () => { + const first = { id: `Method:${FILE}:Service.save#1` }; + const second = { id: `Method:${FILE}:Service.save#2` }; + + const lookup = buildLookup([second, first]); + + expect(lookup.get(simpleKey(FILE, 'save'))).toBe(first.id); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/php/php-import-target.test.ts b/gitnexus/test/unit/scope-resolution/php/php-import-target.test.ts new file mode 100644 index 000000000..e917ed57e --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/php/php-import-target.test.ts @@ -0,0 +1,159 @@ +import type { ParsedFile, ParsedImport, SymbolDefinition } from 'gitnexus-shared'; +import { describe, expect, it } from 'vitest'; + +import type { ComposerConfig } from '../../../../src/core/ingestion/language-config.js'; +import { resolvePhpImportTargetInternal } from '../../../../src/core/ingestion/languages/php/import-target.js'; + +const composerConfig: ComposerConfig = { psr4: new Map([['App', 'app']]) }; + +function parsedFile(filePath: string, definitions: readonly SymbolDefinition[]): ParsedFile { + return { filePath, localDefs: definitions } as ParsedFile; +} + +function definition( + filePath: string, + type: SymbolDefinition['type'], + name: string, +): SymbolDefinition { + return { + nodeId: `def:${filePath}:${type}:${name}`, + filePath, + type, + qualifiedName: name, + }; +} + +const functionImport: ParsedImport = { + kind: 'named', + localName: 'getUser', + importedName: 'getUser', + targetRaw: 'App\\Models\\getUser', + importedSymbolKind: 'function', +}; + +describe('resolvePhpImportTargetInternal declaration selection', () => { + it('finds a unique function declaration when the symbol name is not a filename', () => { + const user = '/repo/app/Models/User.php'; + const factory = '/repo/app/Models/UserFactory.php'; + const parsedFiles = [ + parsedFile(user, [definition(user, 'Class', 'User')]), + parsedFile(factory, [definition(factory, 'Function', 'getUser')]), + ]; + + expect( + resolvePhpImportTargetInternal( + functionImport.targetRaw, + '/repo/app/Main.php', + new Set(parsedFiles.map((parsed) => parsed.filePath)), + composerConfig, + { parsedFiles, parsedImport: functionImport }, + ), + ).toBe(factory); + }); + + it('reuses directory selection without leaking candidates across namespaces', () => { + const models = '/repo/app/Models/functions.php'; + const services = '/repo/app/Services/functions.php'; + const parsedFiles = [ + parsedFile(models, [definition(models, 'Function', 'getUser')]), + parsedFile(services, [definition(services, 'Function', 'getUser')]), + ]; + + const first = resolvePhpImportTargetInternal( + functionImport.targetRaw, + '/repo/app/Main.php', + new Set(parsedFiles.map((parsed) => parsed.filePath)), + composerConfig, + { parsedFiles, parsedImport: functionImport }, + ); + const second = resolvePhpImportTargetInternal( + functionImport.targetRaw, + '/repo/app/Main.php', + new Set(parsedFiles.map((parsed) => parsed.filePath)), + composerConfig, + { parsedFiles, parsedImport: functionImport }, + ); + + expect(first).toBe(models); + expect(second).toBe(models); + }); + + it('fails closed when the namespace has duplicate function declarations', () => { + const first = '/repo/app/Models/First.php'; + const second = '/repo/app/Models/Second.php'; + const parsedFiles = [ + parsedFile(first, [definition(first, 'Function', 'getUser')]), + parsedFile(second, [definition(second, 'Function', 'getUser')]), + ]; + + expect( + resolvePhpImportTargetInternal( + functionImport.targetRaw, + '/repo/app/Main.php', + new Set(parsedFiles.map((parsed) => parsed.filePath)), + composerConfig, + { parsedFiles, parsedImport: functionImport }, + ), + ).toBeNull(); + }); + + it('never resolves into a different root that shares a directory suffix', () => { + const app = '/repo/app/Models/functions.php'; + const vendor = '/repo/vendor/pkg/app/Models/helpers.php'; + const parsedFiles = [ + parsedFile(app, []), + parsedFile(vendor, [definition(vendor, 'Function', 'getUser')]), + ]; + + const result = resolvePhpImportTargetInternal( + functionImport.targetRaw, + '/repo/app/Main.php', + new Set(parsedFiles.map((parsed) => parsed.filePath)), + composerConfig, + { parsedFiles, parsedImport: functionImport }, + ); + + expect(result).not.toBe(vendor); + }); + + it('stays out of suffix-colliding roots even when both declare the function', () => { + const app = '/repo/app/Models/functions.php'; + const vendor = '/repo/vendor/pkg/app/Models/helpers.php'; + const parsedFiles = [ + parsedFile(app, [definition(app, 'Function', 'getUser')]), + parsedFile(vendor, [definition(vendor, 'Function', 'getUser')]), + ]; + + const result = resolvePhpImportTargetInternal( + functionImport.targetRaw, + '/repo/app/Main.php', + new Set(parsedFiles.map((parsed) => parsed.filePath)), + composerConfig, + { parsedFiles, parsedImport: functionImport }, + ); + + expect(result).not.toBe(vendor); + }); + + it('resolves a constant only when its namespace directory has one candidate file', () => { + const constants = '/repo/app/Config/constants.php'; + const parsedFiles = [parsedFile(constants, [])]; + const parsedImport: ParsedImport = { + kind: 'named', + localName: 'MAX_RETRIES', + importedName: 'MAX_RETRIES', + targetRaw: 'App\\Config\\MAX_RETRIES', + importedSymbolKind: 'const', + }; + + expect( + resolvePhpImportTargetInternal( + parsedImport.targetRaw, + '/repo/app/Main.php', + new Set([constants]), + composerConfig, + { parsedFiles, parsedImport }, + ), + ).toBe(constants); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/rust/rust-range-binding-order.test.ts b/gitnexus/test/unit/scope-resolution/rust/rust-range-binding-order.test.ts new file mode 100644 index 000000000..734f1d992 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/rust/rust-range-binding-order.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import type { ParsedFile, ScopeResolutionIndexes } from 'gitnexus-shared'; +import { extractParsedFile } from '../../../../src/core/ingestion/scope-extractor-bridge.js'; +import { rustScopeResolver } from '../../../../src/core/ingestion/languages/rust/scope-resolver.js'; +import { populateRustRangeBindings } from '../../../../src/core/ingestion/languages/rust/range-binding.js'; + +/** + * Regression coverage for #2481: field and identity-method type bindings must + * be published for the whole workspace before any file resolves its pending + * assignments. Before the two-phase split, an importer processed ahead of its + * defining file missed those bindings purely because of file order. + */ + +interface ResolverLike { + languageProvider: Parameters[0]; + populateOwners: (p: ParsedFile) => void; +} + +function parse(src: string, path: string): ParsedFile { + const resolver = rustScopeResolver as unknown as ResolverLike; + const parsed = extractParsedFile(resolver.languageProvider, src, path); + if (parsed === undefined) throw new Error(`scope extraction failed for ${path}`); + resolver.populateOwners(parsed); + return parsed; +} + +function makeEmptyIndexes(): ScopeResolutionIndexes { + return { + bindings: new Map(), + bindingAugmentations: new Map(), + imports: [], + scopeTree: { roots: [] }, + methodDispatch: new Map(), + sccs: [], + } as unknown as ScopeResolutionIndexes; +} + +function boundTypeOf(parsed: ParsedFile, variableName: string): string | undefined { + for (const scope of parsed.scopes) { + const binding = scope.typeBindings.get(variableName); + if (binding !== undefined) return binding.rawName; + } + return undefined; +} + +const DEFINER = `pub struct City { + pub name: String, +} + +impl City { + pub fn save(&self) {} +} +`; + +const IMPORTER = `fn make_city() -> City { + City { name: String::new() } +} + +fn run() { + let city = make_city(); + let copy = city.clone(); + let label = city.name; + copy.save(); + let _ = label; +} +`; + +describe('populateRustRangeBindings publish order (#2481)', () => { + it('binds cross-file member types when the importer is processed before the definer', () => { + const importer = parse(IMPORTER, 'src/app.rs'); + const definer = parse(DEFINER, 'src/city.rs'); + const fileContents = new Map([ + ['src/app.rs', IMPORTER], + ['src/city.rs', DEFINER], + ]); + + populateRustRangeBindings([importer, definer], makeEmptyIndexes(), { fileContents }); + + expect(boundTypeOf(importer, 'copy')).toBe('City'); + expect(boundTypeOf(importer, 'label')).toBe('String'); + }); + + it('produces the same bindings when the definer is processed first', () => { + const definer = parse(DEFINER, 'src/city.rs'); + const importer = parse(IMPORTER, 'src/app.rs'); + const fileContents = new Map([ + ['src/city.rs', DEFINER], + ['src/app.rs', IMPORTER], + ]); + + populateRustRangeBindings([definer, importer], makeEmptyIndexes(), { fileContents }); + + expect(boundTypeOf(importer, 'copy')).toBe('City'); + expect(boundTypeOf(importer, 'label')).toBe('String'); + }); +}); diff --git a/gitnexus/test/unit/server.test.ts b/gitnexus/test/unit/server.test.ts index f2ac995da..8160f5626 100644 --- a/gitnexus/test/unit/server.test.ts +++ b/gitnexus/test/unit/server.test.ts @@ -43,6 +43,27 @@ function createMockBackend(overrides: Record = {}): any { }; } +async function callToolThroughServer( + backend: ReturnType, + name: string, + args: Record, +): Promise<{ text: string; isError: boolean }> { + const server = createMCPServer(backend); + const client = new Client({ name: 'budget-test-client', version: '0.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + try { + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + const response = await client.callTool({ name, arguments: args }); + const text = response.content.find((item) => item.type === 'text')?.text; + if (typeof text !== 'string') throw new Error('Expected an MCP text response'); + return { text, isError: response.isError === true }; + } finally { + await client.close(); + await server.close(); + } +} + // ─── createMCPServer ───────────────────────────────────────────────── describe('createMCPServer', () => { @@ -105,6 +126,124 @@ describe('getNextStepHint (via tool call response)', () => { }); }); +describe('MCP output budgets', () => { + it('leaves the complete formatted response unchanged when no budget is configured', async () => { + const previous = process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS; + delete process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS; + try { + const backend = createMockBackend({ + callTool: vi.fn().mockResolvedValue({ payload: 'complete' }), + }); + const { text, isError } = await callToolThroughServer(backend, 'query', { + search_query: 'auth', + }); + expect(isError).toBe(false); + expect(text).toContain('"payload": "complete"'); + expect(text).toContain('**Next:**'); + expect(text.endsWith('\n…')).toBe(false); + } finally { + if (previous === undefined) delete process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS; + else process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS = previous; + } + }); + + it('applies explicit maxTokens to the complete response deterministically and UTF-8 safely', async () => { + const backend = createMockBackend({ + callTool: vi.fn().mockResolvedValue({ payload: '😀'.repeat(100) }), + }); + const args = { search_query: 'auth', maxTokens: 8 }; + + const first = await callToolThroughServer(backend, 'query', args); + const second = await callToolThroughServer(backend, 'query', args); + + expect(first.isError).toBe(false); + expect(first.text).toBe(second.text); + expect(Buffer.byteLength(first.text, 'utf8')).toBeLessThanOrEqual(8 * 4); + expect(first.text.endsWith('\n…')).toBe(true); + expect(first.text).not.toContain('\uFFFD'); + expect(backend.callTool).toHaveBeenCalledWith('query', { search_query: 'auth' }); + }); + + it('uses the environment default when maxTokens is omitted', async () => { + const previous = process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS; + process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS = '8'; + try { + const backend = createMockBackend({ + callTool: vi.fn().mockResolvedValue({ payload: 'x'.repeat(200) }), + }); + const { text } = await callToolThroughServer(backend, 'context', { name: 'auth' }); + expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(8 * 4); + expect(text.endsWith('\n…')).toBe(true); + } finally { + if (previous === undefined) delete process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS; + else process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS = previous; + } + }); + + it('lets an explicit request override the environment default', async () => { + const previous = process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS; + process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS = '1'; + try { + const backend = createMockBackend({ + callTool: vi.fn().mockResolvedValue({ payload: 'complete' }), + }); + const { text } = await callToolThroughServer(backend, 'impact', { + target: 'auth', + direction: 'upstream', + maxTokens: 200, + }); + expect(text).toContain('"payload": "complete"'); + expect(text).toContain('**Next:**'); + expect(text.endsWith('\n…')).toBe(false); + } finally { + if (previous === undefined) delete process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS; + else process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS = previous; + } + }); + + it('rejects a non-positive explicit maxTokens before backend execution', async () => { + const backend = createMockBackend(); + const { text, isError } = await callToolThroughServer(backend, 'query', { + search_query: 'auth', + maxTokens: 0, + }); + expect(isError).toBe(true); + expect(text).toMatch(/maxTokens.*positive integer/i); + expect(backend.callTool).not.toHaveBeenCalled(); + }); + + it('rejects an invalid environment default before backend execution', async () => { + const previous = process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS; + process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS = 'invalid'; + try { + const backend = createMockBackend(); + const { text, isError } = await callToolThroughServer(backend, 'query', { + search_query: 'auth', + }); + expect(isError).toBe(true); + expect(text).toMatch(/GITNEXUS_MCP_DEFAULT_MAX_TOKENS.*positive integer/i); + expect(backend.callTool).not.toHaveBeenCalled(); + } finally { + if (previous === undefined) delete process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS; + else process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS = previous; + } + }); + + it('applies a valid budget to backend error text', async () => { + const backend = createMockBackend({ + callTool: vi.fn().mockRejectedValue(new Error('😀'.repeat(100))), + }); + const { text, isError } = await callToolThroughServer(backend, 'context', { + name: 'auth', + maxTokens: 8, + }); + expect(isError).toBe(true); + expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(8 * 4); + expect(text.endsWith('\n…')).toBe(true); + expect(text).not.toContain('\uFFFD'); + }); +}); + // ─── Tool handler error handling ────────────────────────────────────── describe('server error handling', () => { diff --git a/gitnexus/test/unit/tools.test.ts b/gitnexus/test/unit/tools.test.ts index 19aa57bf7..23bb79606 100644 --- a/gitnexus/test/unit/tools.test.ts +++ b/gitnexus/test/unit/tools.test.ts @@ -128,6 +128,12 @@ describe('GITNEXUS_TOOLS', () => { expect(contextTool.inputSchema.required).toEqual([]); }); + it('context tool advertises file as a compatibility alias for file_path', () => { + const contextTool = GITNEXUS_TOOLS.find((t) => t.name === 'context')!; + expect(contextTool.inputSchema.properties.file_path).toBeDefined(); + expect(contextTool.inputSchema.properties.file).toMatchObject({ type: 'string' }); + }); + it('api_impact tool expresses the route-or-file requirement via anyOf (#2308)', () => { const apiImpactTool = GITNEXUS_TOOLS.find((t) => t.name === 'api_impact')!; expect(apiImpactTool.inputSchema.anyOf).toEqual([ @@ -138,10 +144,15 @@ describe('GITNEXUS_TOOLS', () => { expect(apiImpactTool.inputSchema.required).toEqual([]); }); - it('impact tool requires target and direction', () => { + it('impact tool requires direction and advertises target, name, or symbol without combinators', () => { const impactTool = GITNEXUS_TOOLS.find((t) => t.name === 'impact')!; - expect(impactTool.inputSchema.required).toContain('target'); expect(impactTool.inputSchema.required).toContain('direction'); + expect(impactTool.inputSchema.required).not.toContain('target'); + expect(impactTool.inputSchema.properties.name).toMatchObject({ type: 'string' }); + expect(impactTool.inputSchema.properties.symbol).toMatchObject({ type: 'string' }); + expect(impactTool.inputSchema).not.toHaveProperty('anyOf'); + expect(impactTool.inputSchema).not.toHaveProperty('oneOf'); + expect(impactTool.inputSchema).not.toHaveProperty('allOf'); }); it('impact tool advertises the PDG-only `line` statement anchor (integer, min 0, not required)', () => { @@ -172,6 +183,16 @@ describe('GITNEXUS_TOOLS', () => { expect(impactTool.description).toContain('truncatedBy'); }); + it.each(['query', 'context', 'impact'])( + '%s advertises an optional positive maxTokens budget', + (name) => { + const tool = GITNEXUS_TOOLS.find((definition) => definition.name === name)!; + const maxTokens = tool.inputSchema.properties.maxTokens; + expect(maxTokens).toMatchObject({ type: 'integer', minimum: 1 }); + expect(tool.inputSchema.required).not.toContain('maxTokens'); + }, + ); + it('rename tool requires new_name', () => { const renameTool = GITNEXUS_TOOLS.find((t) => t.name === 'rename')!; expect(renameTool.inputSchema.required).toContain('new_name');