From f99dde8aa386cf6720d5b8e8ec43a0e365f04e4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Fri, 2 Oct 2026 23:57:23 +0100 Subject: [PATCH] fix(mcp): discover positional SDK tool registrations (#3450) --- gitnexus/bench/scope-capture/baselines.json | 3 +- .../src/core/ingestion/language-provider.ts | 11 +- .../core/ingestion/languages/typescript.ts | 3 + .../languages/typescript/tool-definitions.ts | 365 +++++++++++++++ .../ingestion/pipeline-phases/processes.ts | 1 + .../core/ingestion/pipeline-phases/tools.ts | 2 + .../core/ingestion/workers/parse-worker.ts | 25 + gitnexus/src/storage/parse-cache.ts | 9 +- .../typescript-mcp-tools/src/handlers.ts | 1 + .../typescript-mcp-tools/src/server.js | 5 + .../typescript-mcp-tools/src/server.ts | 52 +++ .../typescript-mcp-tools/src/tools.ts | 4 + .../resolvers/typescript-mcp-tools.test.ts | 283 +++++++++++ .../test/unit/incremental-parse-cache.test.ts | 9 +- .../test/unit/tool-process-linking.test.ts | 76 +-- .../unit/typescript-tool-definitions.test.ts | 441 ++++++++++++++++++ 16 files changed, 1252 insertions(+), 38 deletions(-) create mode 100644 gitnexus/src/core/ingestion/languages/typescript/tool-definitions.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/handlers.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/server.js create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/server.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/tools.ts create mode 100644 gitnexus/test/integration/resolvers/typescript-mcp-tools.test.ts create mode 100644 gitnexus/test/unit/typescript-tool-definitions.test.ts diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 8cfddc5a7..60ffca32d 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -188,7 +188,8 @@ "capture_groups_fp": 680 }, "typescript": { - "fingerprint": "77c9b4ea654123a64972db8348190ec250b669472b150db65ea8c7f20b355467", + "fingerprint": "a7972d87abd253583dafa37443bee8ddf5c635d41b4dfbb0c831efda71d82b47", + "_rebaselined_3446_mcp_tools_fixture": "#3446 adds typescript-mcp-tools/src/{handlers,server,tools}.ts: corpus growth only, with fixture_count 171 -> 174 and capture_groups_fp 2753 -> 2894. Excluding only that fixture directory restores the prior fingerprint 77c9b4ea654123a64972db8348190ec250b669472b150db65ea8c7f20b355467 exactly. The scope emitter, synthetic capture counts (4503/14403), scaling budget, and other language baselines are unchanged.", "_rebaselined_3190": "Capture matches now retain explicit ESM export/private evidence, including synthesized default HOCs; CommonJS surfaces remain undecided. Capture group counts unchanged. Scaling budget unchanged.", "scaling_budget": 1.5, "_rebaselined_2934_import_type_only": "#2934: `import-decomposer.ts` attaches a presence-only `@import.type-only` synthetic capture to specifiers `tsc` erases, so `check --cycles` can stop counting type-only edges as initialization cycles. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE \u2014 the tag is added to import matches that already existed, never a new match, the same shape as the #2747 receiver-chain rebaseline. Every count is unchanged: capture_groups_fp 2414, fixture_count 155, capture_groups_small/large 4503/14403 (those measure the SYNTHETIC scaling source, which has no imports at all). The fingerprint moves because `canonicalizeMatch` in measure.mjs hashes every TAG on every match, synthetics included, so one extra presence-only tag on an existing match rewrites that match's canonical string. Attribution is exact, not inferred: neutralizing ONLY the `m['@import.type-only'] = \u2026` assignment in import-decomposer.ts and re-running returns the fingerprint to c2fbf8a89e5686dd\u2026 byte-for-byte, so nothing else in the TypeScript capture stream moved. All 14 other languages report ok. Scaling 0.997 < 1.5. NOTE ON THE CONTROL: javascript did not move (2026993b\u2026, 43 fixtures), but it is a WEAK control here \u2014 `import type` is TypeScript-only syntax, so a JS corpus cannot express the construct and could not have drifted either way. It evidences no collateral damage, not the correctness of the TS change; the exact-attribution check above is what does that. Prior c2fbf8a89e5686dd1ff3659b20d41d8b05ebcc9790356e3653ee0c8ca5d365c8 -> f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f.", diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 90122aacc..718d8693d 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -46,7 +46,7 @@ import type { RepoConstants, } from './route-extractors/constant-resolver.js'; import type Parser from 'tree-sitter'; -import type { ExtractedDecoratorRoute } from './workers/parse-worker.js'; +import type { ExtractedDecoratorRoute, ExtractedToolDef } from './workers/parse-worker.js'; import type { SemanticModel } from './model/semantic-model.js'; /** What a provider's {@link LanguageProviderConfig.resolveRouteHandler} can see. */ @@ -546,6 +546,15 @@ interface LanguageProviderConfig { */ readonly extractTextRoutes?: (filePath: string, content: string) => ExtractedRoute[]; + /** Extract tool registrations after captures, using only emitted callable identities. + * The map keys are declaration-name AST node IDs, local to this parsed tree. */ + readonly extractToolDefinitions?: ( + tree: Parser.Tree, + filePath: string, + lineOffset: number, + callableBindings: ReadonlyMap, + ) => ExtractedToolDef[]; + /** * Extract routes that a parsed file declares in its own AST. * diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index 5251910cb..c3509ea5e 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -130,6 +130,7 @@ import { extractDataRouteTableRoutes } from '../route-extractors/data-route-tabl import { extractNestRoutes } from '../route-extractors/nest.js'; import { extractTrpcRoutes, shouldScanForTrpcRoutes } from '../route-extractors/trpc.js'; import { extractConvexEndpointProperties } from './typescript/convex-endpoint-metadata.js'; +import { extractToolDefinitions } from './typescript/tool-definitions.js'; const extractJsTsRoutes = (...args: Parameters) => [ ...extractDispatchGuardRoutes(...args), @@ -494,6 +495,7 @@ export const typescriptProvider = defineLanguage({ // Content-based (not AST): tRPC procedure routers are scanned from source text. // Path-gate lives here (language provider), not in the shared parse worker. extractTextRoutes: extractJsTsTextRoutes, + extractToolDefinitions, }); export const javascriptProvider = defineLanguage({ @@ -577,4 +579,5 @@ export const javascriptProvider = defineLanguage({ extractDecoratorRoutes: extractJsTsRoutes, // Content-based (not AST): tRPC procedure routers are scanned from source text. extractTextRoutes: extractJsTsTextRoutes, + extractToolDefinitions, }); diff --git a/gitnexus/src/core/ingestion/languages/typescript/tool-definitions.ts b/gitnexus/src/core/ingestion/languages/typescript/tool-definitions.ts new file mode 100644 index 000000000..307998c56 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/tool-definitions.ts @@ -0,0 +1,365 @@ +import type Parser from 'tree-sitter'; +import type { SyntaxNode } from 'tree-sitter'; +import type { ExtractedToolDef } from '../../workers/parse-worker.js'; +import { plainString, propertyName } from '../../route-extractors/data-route-table.js'; + +interface Scope { + parent?: Scope; + functionScope: boolean; + bindings: Map; +} + +interface Binding { + name: SyntaxNode; + scope: Scope; + kind: 'unknown' | 'sdk' | 'sdk-namespace' | 'variable' | 'parameter' | 'function'; + value?: SyntaxNode; + type?: SyntaxNode; + typeOnly?: boolean; + immutable?: boolean; + invalid?: boolean; +} + +const FUNCTIONS = new Set([ + 'function_declaration', + 'generator_function_declaration', + 'function_expression', + 'generator_function', + 'arrow_function', + 'method_definition', +]); +const BLOCKS = new Set([ + 'statement_block', + 'for_statement', + 'for_in_statement', + 'switch_body', + 'catch_clause', + 'class_body', +]); +const SDK_MODULES = new Set([ + '@modelcontextprotocol/sdk/server/mcp.js', + '@modelcontextprotocol/sdk/server/mcp', +]); + +function lookup(scope: Scope, name: string): Binding | undefined { + for (let current: Scope | undefined = scope; current; current = current.parent) { + const binding = current.bindings.get(name); + if (binding) return binding; + } +} + +/** Only binding/assignment patterns: never descend into keys, types or defaults. + * Member assignment targets are reported separately from binding names. */ +function patternNames(pattern: SyntaxNode, onMember?: (member: SyntaxNode) => void): SyntaxNode[] { + const names: SyntaxNode[] = []; + const pending = [pattern]; + while (pending.length) { + const node = pending.pop()!; + if (node.type === 'identifier' || node.type === 'shorthand_property_identifier_pattern') { + names.push(node); + } else if (node.type === 'member_expression' || node.type === 'subscript_expression') { + onMember?.(node); + } else if (node.type === 'pair_pattern') { + const value = node.childForFieldName('value'); + if (value) pending.push(value); + } else if (node.type === 'assignment_pattern' || node.type === 'object_assignment_pattern') { + const left = node.childForFieldName('left'); + if (left) pending.push(left); + } else if ( + node.type === 'array_pattern' || + node.type === 'object_pattern' || + node.type === 'rest_pattern' + ) { + pending.push(...node.namedChildren); + } + } + return names; +} + +function declare(scope: Scope, name: SyntaxNode, details: Partial = {}): void { + const previous = scope.bindings.get(name.text); + if (previous) { + previous.invalid = true; + } else { + scope.bindings.set(name.text, { name, scope, kind: 'unknown', ...details }); + } +} + +function variableScope(scope: Scope): Scope { + while (!scope.functionScope && scope.parent) scope = scope.parent; + return scope; +} + +function collectBindings(root: SyntaxNode) { + const moduleScope: Scope = { functionScope: true, bindings: new Map() }; + const scopes = new Map(); + const calls: SyntaxNode[] = []; + const writes: SyntaxNode[] = []; + const pending = [{ node: root, scope: moduleScope }]; + while (pending.length) { + const entry = pending.pop()!; + const node = entry.node; + let scope = entry.scope; + const isFunction = FUNCTIONS.has(node.type); + const name = node.childForFieldName('name'); + if (node.type === 'function_declaration' || node.type === 'generator_function_declaration') { + if (name) declare(scope, name, { kind: 'function', value: node, immutable: true }); + } else if ( + [ + 'class_declaration', + 'interface_declaration', + 'type_alias_declaration', + 'enum_declaration', + ].includes(node.type) + ) { + if (name) declare(scope, name); + } + if ( + isFunction || + BLOCKS.has(node.type) || + node.type === 'class' || + node.type === 'class_declaration' + ) { + scope = { parent: scope, functionScope: isFunction, bindings: new Map() }; + } + scopes.set(node.id, scope); + + if (node.type === 'class' && name) declare(scope, name); + + if (isFunction) { + if (name && (node.type === 'function_expression' || node.type === 'generator_function')) { + declare(scope, name, { kind: 'function', value: node, immutable: true }); + } + const parameters = node.childForFieldName('parameters'); + const single = node.childForFieldName('parameter'); + for (const parameter of parameters?.namedChildren ?? (single ? [single] : [])) { + const pattern = parameter.childForFieldName('pattern') ?? parameter; + const type = parameter.childForFieldName('type')?.namedChildren[0]; + for (const bindingName of patternNames(pattern)) { + declare(scope, bindingName, { + kind: 'parameter', + ...(pattern.type === 'identifier' && type ? { type } : {}), + }); + } + } + } else if (node.type === 'import_statement') { + const source = node.childForFieldName('source'); + const sdk = source !== null && SDK_MODULES.has(plainString(source) ?? ''); + const typeOnly = node.children.some((child) => child.type === 'type'); + const clause = node.namedChildren.find((child) => child.type === 'import_clause'); + for (const child of clause?.namedChildren ?? []) { + if (child.type === 'identifier') declare(scope, child); + else if (child.type === 'namespace_import') { + const local = child.namedChildren[0]; + if (local) declare(scope, local, { kind: sdk ? 'sdk-namespace' : 'unknown', typeOnly }); + } else if (child.type === 'named_imports') { + for (const specifier of child.namedChildren) { + const imported = specifier.childForFieldName('name'); + const local = specifier.childForFieldName('alias') ?? imported; + if (local) + declare(scope, local, { + kind: sdk && imported?.text === 'McpServer' ? 'sdk' : 'unknown', + typeOnly: typeOnly || specifier.children.some((part) => part.type === 'type'), + }); + } + } + } + } else if (node.type === 'variable_declarator') { + if (name) { + const target = node.parent?.type === 'variable_declaration' ? variableScope(scope) : scope; + for (const bindingName of patternNames(name)) { + declare(target, bindingName, { + kind: 'variable', + immutable: node.parent?.childForFieldName('kind')?.type === 'const', + ...(name.type === 'identifier' + ? { value: node.childForFieldName('value') ?? undefined } + : {}), + }); + } + } + } else if (node.type === 'catch_clause') { + const parameter = node.childForFieldName('parameter'); + if (parameter) for (const bindingName of patternNames(parameter)) declare(scope, bindingName); + } else if (node.type === 'for_in_statement') { + const left = node.childForFieldName('left'); + const kind = node.childForFieldName('kind'); + if (left && kind) { + const target = kind.type === 'var' ? variableScope(scope) : scope; + for (const bindingName of patternNames(left)) declare(target, bindingName); + } else if (left) writes.push(left); + } else if (node.type === 'type_parameter') { + if (name) declare(scope, name); + } + if (node.type === 'call_expression') calls.push(node); + if (node.type === 'assignment_expression' || node.type === 'augmented_assignment_expression') { + const left = node.childForFieldName('left'); + if (left) writes.push(left); + } else if ( + node.type === 'update_expression' || + (node.type === 'unary_expression' && node.children.some((child) => child.type === 'delete')) + ) { + const argument = node.childForFieldName('argument'); + if (argument) writes.push(argument); + } + for (let index = node.namedChildCount - 1; index >= 0; index--) { + pending.push({ node: node.namedChild(index)!, scope }); + } + } + // Resolve writes after declarations so later declarations also shadow outer names. + while (writes.length) { + let target = writes.pop()!; + const scope = scopes.get(target.id)!; + const members: Array = []; + while (target.type === 'member_expression' || target.type === 'subscript_expression') { + const object = target.childForFieldName('object'); + if (!object) break; + const property = target.childForFieldName('property'); + const index = target.childForFieldName('index'); + members.push(property ? propertyName(property) : index ? plainString(index) : null); + target = object; + } + // Nested member targets must pass the same guard as direct property writes. + for (const name of patternNames(target, (member) => writes.push(member))) { + const binding = lookup(scope, name.text); + // Lifecycle callbacks and other known properties do not replace the receiver + // or its registration methods. Unknown keys and constructor mutations remain unsafe. + if ( + binding?.kind !== 'sdk' && + binding?.kind !== 'sdk-namespace' && + members.length > 0 && + members.every((member) => member !== null) && + !['tool', 'registerTool', '__proto__'].includes(members[members.length - 1]!) + ) + continue; + if (binding) binding.invalid = true; + } + } + return { scopes, calls }; +} + +function sdkBinding(node: SyntaxNode, scope: Scope, forType = false): boolean { + let kind: Binding['kind'] = 'sdk'; + if (node.type === (forType ? 'nested_type_identifier' : 'member_expression')) { + const namespace = node.childForFieldName(forType ? 'module' : 'object'); + const member = node.childForFieldName(forType ? 'name' : 'property'); + if (namespace?.type !== 'identifier' || member?.text !== 'McpServer') return false; + node = namespace; + kind = 'sdk-namespace'; + } + if (node.type !== 'identifier' && node.type !== 'type_identifier') return false; + const binding = lookup(scope, node.text); + return binding?.kind === kind && !binding.invalid && (forType || !binding.typeOnly); +} + +function sdkReceiver(node: SyntaxNode, scope: Scope, scopes: ReadonlyMap): boolean { + if (node.type !== 'identifier') return false; + const binding = lookup(scope, node.text); + if (!binding || binding.invalid) return false; + if (binding.kind === 'parameter' && binding.type) { + return sdkBinding(binding.type, binding.scope, true); + } + const value = binding.value; + if (binding.kind !== 'variable' || value?.type !== 'new_expression') return false; + if (value.endIndex > node.startIndex && variableScope(binding.scope) === variableScope(scope)) + return false; + const constructor = value.childForFieldName('constructor'); + return constructor !== null && sdkBinding(constructor, scopes.get(value.id)!); +} + +/** An unknown later property can replace description; a later explicit property restores proof. */ +function descriptionFromConfig(config: SyntaxNode): string { + let description = ''; + if (config.type !== 'object') return description; + for (const child of config.namedChildren) { + if (child.type === 'comment') continue; + const key = child.childForFieldName('key') ?? child.childForFieldName('name'); + const name = + child.type === 'shorthand_property_identifier' ? child.text : key && propertyName(key); + if (child.type === 'pair' && name === 'description') { + const value = child.childForFieldName('value'); + description = value ? (plainString(value) ?? '') : ''; + } else if (!name || name === 'description') { + description = ''; + } + } + return description; +} + +function handlerNodeId( + node: SyntaxNode, + scope: Scope, + callableBindings: ReadonlyMap | undefined, +): string | undefined { + if (node.type !== 'identifier') return undefined; + const binding = lookup(scope, node.text); + if (!binding || binding.invalid) return undefined; + if (binding.kind === 'variable') { + const value = binding.value; + if ( + !binding.immutable || + !value || + (value.type !== 'arrow_function' && value.type !== 'function_expression') + ) + return undefined; + if (value.endIndex > node.startIndex && variableScope(binding.scope) === variableScope(scope)) + return undefined; + } else if (binding.kind !== 'function') return undefined; + return callableBindings?.get(binding.name.id); +} + +/** Direct SDK registrations only; no wrapper, alias-chain or runtime-value inference. */ +export function extractToolDefinitions( + tree: Parser.Tree, + filePath: string, + lineOffset = 0, + callableBindings?: ReadonlyMap, +): ExtractedToolDef[] { + // Ordinary files need no lexical walk; every supported receiver originates here. + const importsSdk = tree.rootNode.namedChildren.some((node) => { + if (node.type !== 'import_statement') return false; + const source = node.childForFieldName('source'); + return source !== null && SDK_MODULES.has(plainString(source) ?? ''); + }); + if (!importsSdk) return []; + + const { scopes, calls } = collectBindings(tree.rootNode); + const definitions: ExtractedToolDef[] = []; + for (const call of calls) { + const callee = call.childForFieldName('function'); + if (call.hasError || callee?.type !== 'member_expression') continue; + const receiver = callee.childForFieldName('object'); + const method = callee.childForFieldName('property'); + if ( + !receiver || + method?.type !== 'property_identifier' || + (method.text !== 'registerTool' && method.text !== 'tool') || + !sdkReceiver(receiver, scopes.get(call.id)!, scopes) + ) + continue; + const args = + call + .childForFieldName('arguments') + ?.namedChildren.filter((child) => child.type !== 'comment') ?? []; + if (args.some((arg) => arg.type === 'spread_element')) continue; + if (method.text === 'registerTool' ? args.length !== 3 : args.length < 2 || args.length > 5) + continue; + const toolName = plainString(args[0]); + if (toolName === null) continue; + const description = + method.text === 'registerTool' + ? descriptionFromConfig(args[1]) + : args.length > 2 + ? (plainString(args[1]) ?? '') + : ''; + const handler = handlerNodeId(args[args.length - 1], scopes.get(call.id)!, callableBindings); + definitions.push({ + filePath, + toolName, + description, + lineNumber: call.startPosition.row + 1 + lineOffset, + ...(handler !== undefined ? { handlerNodeId: handler } : {}), + allowFileFallback: false, + }); + } + return definitions; +} diff --git a/gitnexus/src/core/ingestion/pipeline-phases/processes.ts b/gitnexus/src/core/ingestion/pipeline-phases/processes.ts index 6be8839c3..75f8a0b48 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/processes.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/processes.ts @@ -323,6 +323,7 @@ export const processesPhase: PipelinePhase = { const toolsByHandlerId = new Map(); const toolsWithoutHandlerByFile = new Map(); for (const td of toolDefs) { + if (!td.handlerNodeId && td.allowFileFallback === false) continue; const key = td.handlerNodeId ?? td.filePath; const targetMap = td.handlerNodeId ? toolsByHandlerId : toolsWithoutHandlerByFile; let list = targetMap.get(key); diff --git a/gitnexus/src/core/ingestion/pipeline-phases/tools.ts b/gitnexus/src/core/ingestion/pipeline-phases/tools.ts index 32a0ae71f..296d0a42a 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/tools.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/tools.ts @@ -22,6 +22,7 @@ export interface ToolDef { filePath: string; description: string; handlerNodeId?: string; + allowFileFallback?: false; } export interface ToolsOutput { @@ -51,6 +52,7 @@ export const toolsPhase: PipelinePhase = { filePath: td.filePath, description: td.description, ...(handlerNodeId !== undefined ? { handlerNodeId } : {}), + ...(td.allowFileFallback === false ? { allowFileFallback: false as const } : {}), }); } diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index e6333c65d..18b65eba1 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -439,6 +439,8 @@ export interface ExtractedToolDef { description: string; lineNumber: number; handlerNodeId?: string; + /** Unresolved registrations must not inherit unrelated same-file flows. */ + allowFileFallback?: false; } export interface ExtractedORMQuery { @@ -1685,6 +1687,7 @@ const processFileGroup = ( // node id → graph node id for classes THIS file's capture loop materialized. // Keyed by in-memory AST identity (never persisted); filled below. const classOwnersByNodeId = new Map(); + const callableBindings = new Map(); // #2687: ONE pass over `matches` yields both suppression sets — the // definition-name claims by rank (callable > Property > value), so the dedup @@ -3123,6 +3126,11 @@ const processFileGroup = ( }), }); + // Keep actual emitted identities; providers must not reconstruct graph IDs. + if (nameNode && (nodeLabel === 'Function' || nodeLabel === 'Method')) { + callableBindings.set(nameNode.id, nodeId); + } + // enclosingClassId already computed above (before nodeId generation) const ownerId = enclosingClassId ?? objectLiteralOwnerInfo?.ownerId; @@ -3225,6 +3233,23 @@ const processFileGroup = ( } } + if (provider.extractToolDefinitions) { + // Distinct lexical declarations can share a graph ID (for example, sibling + // block-scoped functions). Such IDs cannot prove which handler owns a tool. + const seenCallableIds = new Set(); + const ambiguousCallableIds = new Set(); + for (const nodeId of callableBindings.values()) { + if (seenCallableIds.has(nodeId)) ambiguousCallableIds.add(nodeId); + seenCallableIds.add(nodeId); + } + for (const [bindingId, nodeId] of callableBindings) { + if (ambiguousCallableIds.has(nodeId)) callableBindings.delete(bindingId); + } + result.toolDefs.push( + ...provider.extractToolDefinitions(tree, file.path, lineOffset, callableBindings), + ); + } + // Extract framework routes via provider detection (e.g., Laravel routes.php) if (provider.isRouteFile?.(file.path)) { const extractedRoutes = extractLaravelRoutes(tree, file.path); diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 37eb0baf7..77e37a250 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -822,7 +822,14 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid // `handlerReceiver` hint. Warm v123 Go worker results carry no routes. // v125 (#3402): Go route hints now honor lexical declarations and captured writes; // namespace imports retain whether their local name comes from the package clause. -const SCHEMA_BUMP = 125; +// v126 (#3446): SDK positional tool registrations now emit tool definitions, +// exact handler identities, and an opt-out from unrelated file-level flows. +// Warm v125 worker results omit these definitions and must be re-extracted. +// v127 (#3450): Destructured member writes invalidate SDK registration evidence. +// Warm v126 worker results can retain false tools after a method replacement. +// v128 (#3450): SDK namespace imports now prove positional tool receivers. +// Warm v127 worker results omit these definitions and must be re-extracted. +const SCHEMA_BUMP = 128; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/handlers.ts b/gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/handlers.ts new file mode 100644 index 000000000..e30cb154d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/handlers.ts @@ -0,0 +1 @@ +export function importedHandler() { return 'imported'; } diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/server.js b/gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/server.js new file mode 100644 index 000000000..eedf3488f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/server.js @@ -0,0 +1,5 @@ +import { McpServer as Server } from '@modelcontextprotocol/sdk/server/mcp.js'; + +const server = new Server({ name: 'javascript', version: '1' }); +function jsPing() { return 'pong'; } +server.registerTool('js_ping', { description: 'Ping JavaScript' }, jsPing); diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/server.ts b/gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/server.ts new file mode 100644 index 000000000..4a9c954f8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/server.ts @@ -0,0 +1,52 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { importedHandler } from './handlers.js'; + +const server = new McpServer({ name: 'fixture', version: '1' }); + +function formatSearch(query: string) { return query; } +function lookupSearch(query: string) { return formatSearch(query); } +export function searchFiles(query: string) { return lookupSearch(query); } + +function formatFile(file: string) { return file; } +function lookupFile(file: string) { return formatFile(file); } +export const readFile = (file: string) => lookupFile(file); + +function formatOther() { return 'other'; } +function lookupOther() { return formatOther(); } +export function unrelatedEntry() { return lookupOther(); } + +server.registerTool('search-files', { description: 'Search files' }, searchFiles); +server.tool('read_file', 'Read a file', {}, readFile); +server.registerTool('inline_callback', {}, async () => 'inline'); +server.tool('imported_callback', importedHandler); + +function install(server: McpServer, searchFiles: () => string) { + server.registerTool('parameter_callback', {}, searchFiles); +} + +let mutable = () => 'mutable'; +server.tool('mutable_callback', mutable); + +function replaced() { return 'before'; } +replaced = () => 'after'; +server.tool('reassigned_callback', replaced); + +{ + const searchFiles = 'not callable'; + server.tool('shadowed_callback', searchFiles); +} + +const alias = readFile; +server.tool('alias_callback', alias); + +const expressionHandler = function () { return 'expression'; }; +server.registerTool('function_expression_tool', {}, expressionHandler); + +{ + const handler = () => lookupSearch('first'); + server.tool('first_block_callback', handler); +} +{ + const handler = () => lookupFile('second'); + server.tool('second_block_callback', handler); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/tools.ts b/gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/tools.ts new file mode 100644 index 000000000..0013ccfab --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/tools.ts @@ -0,0 +1,4 @@ +export const tools = [ + { name: 'manifest_tool', description: 'Existing object manifest', inputSchema: {} }, + { name: 'read_file', description: 'Duplicate manifest entry', inputSchema: {} }, +]; diff --git a/gitnexus/test/integration/resolvers/typescript-mcp-tools.test.ts b/gitnexus/test/integration/resolvers/typescript-mcp-tools.test.ts new file mode 100644 index 000000000..fba040759 --- /dev/null +++ b/gitnexus/test/integration/resolvers/typescript-mcp-tools.test.ts @@ -0,0 +1,283 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { + loadParseCache, + PARSE_CACHE_VERSION, + pruneCache, + saveParseCache, + type ParseCache, +} from '../../../src/storage/parse-cache.js'; +import { + getDurableParsedFileDir, + pruneAndSaveDurableParsedFileStore, +} from '../../../src/storage/parsedfile-store.js'; +import { + FIXTURES, + findDanglingEdges, + getNodesByLabel, + getNodesByLabelFull, + getRelationships, + runPipelineFromRepo, + type PipelineResult, +} from './helpers.js'; + +describe('JavaScript and TypeScript SDK tool registrations', () => { + let result: PipelineResult; + const unresolved = [ + 'inline_callback', + 'imported_callback', + 'parameter_callback', + 'mutable_callback', + 'reassigned_callback', + 'shadowed_callback', + 'alias_callback', + 'first_block_callback', + 'second_block_callback', + ]; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'typescript-mcp-tools'), () => {}); + }, 60000); + + it.each(['ts', 'js'])( + 'does not emit tools for a destructured method replacement in %s', + async (extension) => { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-mcp-tool-write-')); + try { + fs.writeFileSync( + path.join(repo, `server.${extension}`), + ` + import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + const replaced = new McpServer({ name: 'replaced', version: '1' }); + ({ registerTool: replaced.registerTool } = { registerTool: () => undefined }); + replaced.registerTool('fake', {}, () => ({ content: [] })); + const actual = new McpServer({ name: 'actual', version: '1' }); + actual.registerTool('real', {}, () => ({ content: [] })); + `, + ); + const pipeline = await runPipelineFromRepo(repo, () => {}, { workerPoolSize: 1 }); + expect(getNodesByLabel(pipeline, 'Tool')).toEqual(['real']); + expect(findDanglingEdges(pipeline, ['HANDLES_TOOL', 'ENTRY_POINT_OF'])).toEqual([]); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } + }, + ); + + it.each(['ts', 'js'])( + 'preserves namespace registrations through cold/warm %s parsing', + async (extension) => { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-mcp-namespace-')); + const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-mcp-namespace-cache-')); + try { + fs.writeFileSync( + path.join(repo, `server.${extension}`), + ` + import * as SDK from '@modelcontextprotocol/sdk/server/mcp.js'; + const server = new SDK.McpServer({}); + function handleNamespace() { return { content: [] }; } + server.registerTool('namespace', { description: 'Namespace tool' }, handleNamespace); + const replaced = new SDK.McpServer({}); + ({ method: replaced.registerTool } = other); + replaced.registerTool('fake', {}, handleNamespace); + ${extension === 'ts' ? "function install(typed: SDK.McpServer) { typed.tool('typed_namespace', handleNamespace); }" : ''} + `, + ); + const cache: ParseCache = { + version: PARSE_CACHE_VERSION, + entries: new Map(), + usedKeys: new Set(), + storagePath: storageDir, + onDiskKeys: new Set(), + }; + const cold = await runPipelineFromRepo(repo, () => {}, { + parseCache: cache, + workerPoolSize: 1, + }); + expect(cold.usedWorkerPool).toBe(true); + pruneCache(cache, cache.usedKeys); + const keys = await saveParseCache(storageDir, cache); + await pruneAndSaveDurableParsedFileStore( + getDurableParsedFileDir(storageDir), + PARSE_CACHE_VERSION, + new Set(keys), + ); + const warmCache = await loadParseCache(storageDir); + expect(warmCache).not.toBeNull(); + const warm = await runPipelineFromRepo(repo, () => {}, { + parseCache: warmCache!, + workerPoolSize: 1, + }); + expect(warm.usedWorkerPool).toBe(false); + for (const pipeline of [cold, warm]) { + expect(getNodesByLabel(pipeline, 'Tool')).toEqual( + extension === 'ts' ? ['namespace', 'typed_namespace'] : ['namespace'], + ); + expect( + getNodesByLabelFull(pipeline, 'Tool').find((tool) => tool.name === 'namespace') + ?.properties.description, + ).toBe('Namespace tool'); + expect( + getRelationships(pipeline, 'HANDLES_TOOL').filter( + (edge) => edge.target === 'namespace', + ), + ).toMatchObject([{ source: 'handleNamespace', sourceLabel: 'Function' }]); + expect(findDanglingEdges(pipeline, ['HANDLES_TOOL', 'ENTRY_POINT_OF'])).toEqual([]); + } + expect(getNodesByLabelFull(warm, 'Tool')).toEqual(getNodesByLabelFull(cold, 'Tool')); + expect(getRelationships(warm, 'HANDLES_TOOL')).toEqual( + getRelationships(cold, 'HANDLES_TOOL'), + ); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + fs.rmSync(storageDir, { recursive: true, force: true }); + } + }, + 120_000, + ); + + it('discovers ordinary server files alongside deduplicated object manifests', () => { + expect(getNodesByLabel(result, 'Tool')).toEqual( + [ + ...unresolved, + 'search-files', + 'read_file', + 'function_expression_tool', + 'js_ping', + 'manifest_tool', + ].sort(), + ); + const tools = new Map( + getNodesByLabelFull(result, 'Tool').map((tool) => [tool.name, tool.properties]), + ); + expect(tools.get('search-files')).toMatchObject({ + filePath: 'src/server.ts', + description: 'Search files', + }); + expect(tools.get('read_file')).toMatchObject({ + filePath: 'src/server.ts', + description: 'Read a file', + }); + expect(tools.get('js_ping')).toMatchObject({ + filePath: 'src/server.js', + description: 'Ping JavaScript', + }); + expect(tools.get('manifest_tool')).toMatchObject({ + filePath: 'src/tools.ts', + description: 'Existing object manifest', + }); + expect(tools.get('inline_callback')?.description).toBe(''); + }); + + it('uses actual emitted callable nodes for supported local handlers', () => { + const edges = getRelationships(result, 'HANDLES_TOOL'); + for (const [tool, handler] of [ + ['search-files', 'searchFiles'], + ['read_file', 'readFile'], + ['function_expression_tool', 'expressionHandler'], + ['js_ping', 'jsPing'], + ]) { + expect(edges.filter((edge) => edge.target === tool)).toMatchObject([ + { source: handler, sourceLabel: 'Function' }, + ]); + } + expect(findDanglingEdges(result, ['HANDLES_TOOL', 'ENTRY_POINT_OF'])).toEqual([]); + }); + + it('links each same-file named handler only to its own execution flow', () => { + const edges = getRelationships(result, 'ENTRY_POINT_OF').filter( + (edge) => edge.sourceLabel === 'Tool', + ); + for (const [tool, handler] of [ + ['search-files', 'searchFiles'], + ['read_file', 'readFile'], + ]) { + const flows = edges.filter((edge) => edge.source === tool); + expect(flows).toHaveLength(1); + const process = result.graph.getNode(flows[0].rel.targetId)!; + const entry = result.graph.getNode(process.properties.entryPointId as string)!; + expect(entry.properties.name).toBe(handler); + } + }); + + it('keeps unresolved callbacks at file attribution without unrelated same-file flows', () => { + const handles = getRelationships(result, 'HANDLES_TOOL'); + const flows = getRelationships(result, 'ENTRY_POINT_OF'); + expect( + getNodesByLabelFull(result, 'Process').some((process) => { + const entry = result.graph.getNode(process.properties.entryPointId as string); + return entry?.properties.name === 'unrelatedEntry'; + }), + ).toBe(true); + for (const name of unresolved) { + expect(handles.filter((edge) => edge.target === name)).toMatchObject([ + { sourceLabel: 'File', sourceFilePath: 'src/server.ts' }, + ]); + expect(flows.filter((edge) => edge.sourceLabel === 'Tool' && edge.source === name)).toEqual( + [], + ); + } + }); + + it('preserves tool metadata, handler identities, and flow attribution on warm replay', async () => { + const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-mcp-tools-cache-')); + try { + const cold: ParseCache = { + version: PARSE_CACHE_VERSION, + entries: new Map(), + usedKeys: new Set(), + storagePath: storageDir, + onDiskKeys: new Set(), + }; + const fixture = path.join(FIXTURES, 'typescript-mcp-tools'); + const initial = await runPipelineFromRepo(fixture, () => {}, { + parseCache: cold, + workerPoolSize: 1, + }); + expect(initial.usedWorkerPool).toBe(true); + pruneCache(cold, cold.usedKeys); + const savedKeys = await saveParseCache(storageDir, cold); + await pruneAndSaveDurableParsedFileStore( + getDurableParsedFileDir(storageDir), + PARSE_CACHE_VERSION, + new Set(savedKeys), + ); + const warm = await loadParseCache(storageDir); + expect(warm).not.toBeNull(); + const replay = await runPipelineFromRepo(fixture, () => {}, { + parseCache: warm!, + workerPoolSize: 1, + }); + expect(replay.usedWorkerPool).toBe(false); + + const project = (pipeline: PipelineResult) => ({ + tools: getNodesByLabelFull(pipeline, 'Tool'), + edges: [ + ...getRelationships(pipeline, 'HANDLES_TOOL'), + ...getRelationships(pipeline, 'ENTRY_POINT_OF').filter( + (edge) => edge.sourceLabel === 'Tool', + ), + ] + .map(({ rel, source }) => ({ + type: rel.type, + source, + sourceId: rel.sourceId, + targetId: rel.targetId, + })) + .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))), + }); + const expected = project(initial); + expect(expected.tools).toHaveLength(unresolved.length + 5); + expect(project(replay)).toEqual(expected); + for (const name of unresolved) { + expect( + expected.edges.filter((edge) => edge.type === 'ENTRY_POINT_OF' && edge.source === name), + ).toEqual([]); + } + } finally { + fs.rmSync(storageDir, { recursive: true, force: true }); + } + }, 120_000); +}); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index b86c19926..fb2916ab4 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -302,8 +302,11 @@ describe('PARSE_CACHE_VERSION', () => { // Moved 121 -> 122 for #3414 restoring helper calls. // Moved 122 -> 123 for #3408 FastAPI nested router-prefix capture fields. // Moved 123 -> 124 for #3402 Go gin/echo decorator routes. - it('pins SCHEMA_BUMP to 125 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965, #3390, #3398, #3396, #3394, #3399, #3414, #3408, #3402)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(125); + // Moved 125 -> 126 for #3446: SDK positional tool definitions and attribution. + // Moved 126 -> 127 for #3450: reject destructured SDK registration-method writes. + // Moved 127 -> 128 for #3450: recognize SDK namespace imports. + it('pins SCHEMA_BUMP to 128 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965, #3390, #3398, #3396, #3394, #3399, #3414, #3408, #3402, #3446, #3450)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(128); expect(PARSE_CACHE_BUCKET_COUNT).toBe(128); // The PREVIOUS version must fail the reuse gate, not merely differ from the // current one — a hardcoded number outside the conflict hunk rebases cleanly @@ -313,7 +316,7 @@ describe('PARSE_CACHE_VERSION', () => { 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, - 123, 124, + 123, 124, 125, 126, 127, ]) { expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken); } diff --git a/gitnexus/test/unit/tool-process-linking.test.ts b/gitnexus/test/unit/tool-process-linking.test.ts index 6c536bb38..d28dd9329 100644 --- a/gitnexus/test/unit/tool-process-linking.test.ts +++ b/gitnexus/test/unit/tool-process-linking.test.ts @@ -55,41 +55,50 @@ function addCall(graph: KnowledgeGraph, sourceId: string, targetId: string) { } describe('Tool handler and process linking phases', () => { - it('falls back to the file node when a parsed tool handler is missing from the graph', async () => { - const graph = createKnowledgeGraph(); - addNode(graph, 'File:src/tools.py', 'File', 'tools.py', 'src/tools.py'); + it.each([undefined, false] as const)( + 'retains file attribution policy %s when a parsed handler is missing', + async (allowFileFallback) => { + const graph = createKnowledgeGraph(); + addNode(graph, 'File:src/tools.py', 'File', 'tools.py', 'src/tools.py'); - const output = await toolsPhase.execute( - makeCtx(graph), - new Map([ - [ - 'parse', - phaseResult('parse', { - allToolDefs: [ - { - filePath: 'src/tools.py', - toolName: 'stale_tool', - description: 'Stale handler', - lineNumber: 1, - handlerNodeId: 'Function:src/tools.py:missing', - }, - ], - allPaths: [], - }), - ], - ]), - ); + const output = await toolsPhase.execute( + makeCtx(graph), + new Map([ + [ + 'parse', + phaseResult('parse', { + allToolDefs: [ + { + filePath: 'src/tools.py', + toolName: 'stale_tool', + description: 'Stale handler', + lineNumber: 1, + handlerNodeId: 'Function:src/tools.py:missing', + ...(allowFileFallback === false ? { allowFileFallback } : {}), + }, + ], + allPaths: [], + }), + ], + ]), + ); - expect(output.toolDefs).toEqual([ - { name: 'stale_tool', filePath: 'src/tools.py', description: 'Stale handler' }, - ]); + expect(output.toolDefs).toEqual([ + { + name: 'stale_tool', + filePath: 'src/tools.py', + description: 'Stale handler', + ...(allowFileFallback === false ? { allowFileFallback } : {}), + }, + ]); - const edge = graph.relationships.find((rel) => rel.type === 'HANDLES_TOOL'); - expect(edge).toMatchObject({ - sourceId: 'File:src/tools.py', - targetId: 'Tool:stale_tool', - }); - }); + const edge = graph.relationships.find((rel) => rel.type === 'HANDLES_TOOL'); + expect(edge).toMatchObject({ + sourceId: 'File:src/tools.py', + targetId: 'Tool:stale_tool', + }); + }, + ); it('does not attach file-level fallback tools to handler-specific processes', async () => { const graph = createKnowledgeGraph(); @@ -110,6 +119,7 @@ describe('Tool handler and process linking phases', () => { addNode(graph, fileLeaf, 'Function', 'fileLeaf', filePath); addNode(graph, 'Tool:alpha', 'Tool', 'alpha', filePath); addNode(graph, 'Tool:fallback_tool', 'Tool', 'fallback_tool', filePath); + addNode(graph, 'Tool:unresolved_tool', 'Tool', 'unresolved_tool', filePath); addCall(graph, alpha, alphaHelper); addCall(graph, alphaHelper, alphaLeaf); addCall(graph, fileEntry, fileHelper); @@ -127,6 +137,7 @@ describe('Tool handler and process linking phases', () => { toolDefs: [ { name: 'alpha', filePath, description: '', handlerNodeId: alpha }, { name: 'fallback_tool', filePath, description: '' }, + { name: 'unresolved_tool', filePath, description: '', allowFileFallback: false }, ], }), ], @@ -147,5 +158,6 @@ describe('Tool handler and process linking phases', () => { expect(linkedEntriesByTool.get('Tool:alpha')).toEqual([alpha]); expect(linkedEntriesByTool.get('Tool:fallback_tool')).toEqual([fileEntry]); + expect(linkedEntriesByTool.has('Tool:unresolved_tool')).toBe(false); }); }); diff --git a/gitnexus/test/unit/typescript-tool-definitions.test.ts b/gitnexus/test/unit/typescript-tool-definitions.test.ts new file mode 100644 index 000000000..e248a682c --- /dev/null +++ b/gitnexus/test/unit/typescript-tool-definitions.test.ts @@ -0,0 +1,441 @@ +import { describe, expect, it } from 'vitest'; +import Parser from 'tree-sitter'; +import JavaScript from 'tree-sitter-javascript'; +import TypeScript from 'tree-sitter-typescript'; +import { extractToolDefinitions } from '../../src/core/ingestion/languages/typescript/tool-definitions.js'; + +const tsParser = new Parser(); +tsParser.setLanguage(TypeScript.typescript); +const jsParser = new Parser(); +jsParser.setLanguage(JavaScript); + +const sdkImport = `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`; +const server = `${sdkImport}\nconst server = new McpServer({ name: 'example', version: '1' });`; +const extract = (source: string, parser = tsParser, filePath = 'src/server.ts', offset = 0) => + extractToolDefinitions(parser.parse(source), filePath, offset); +const metadata = (source: string) => + extract(source).map(({ toolName, description }) => ({ toolName, description })); + +describe('SDK tool registration extraction', () => { + it.each([ + ['TypeScript', tsParser, 'src/server.ts'], + ['JavaScript', jsParser, 'src/server.js'], + ] as const)( + 'extracts modern %s registrations in ordinary server files', + (_language, parser, filePath) => { + expect( + extract( + `${server}\nserver.registerTool('search', { description: 'Search files' }, handler);`, + parser, + filePath, + 10, + ), + ).toEqual([ + { + filePath, + toolName: 'search', + description: 'Search files', + lineNumber: 13, + allowFileFallback: false, + }, + ]); + }, + ); + + it.each([ + ['TypeScript', tsParser, 'src/server.ts'], + ['JavaScript', jsParser, 'src/server.js'], + ] as const)('recognizes namespace imports in %s', (_language, parser, filePath) => { + expect( + extract( + ` + import * as SDK from '@modelcontextprotocol/sdk/server/mcp.js'; + const server = new SDK.McpServer({}); + server.registerTool('modern', { description: 'Namespace tool' }, handler); + server.tool('legacy', handler); + `, + parser, + filePath, + ).map(({ toolName }) => toolName), + ).toEqual(['modern', 'legacy']); + }); + + it.each(['', 'type '])('recognizes %snamespace imports in directly typed helpers', (typeOnly) => { + expect( + metadata(` + import ${typeOnly}* as SDK from '@modelcontextprotocol/sdk/server/mcp'; + function install(server: SDK.McpServer) { server.tool('typed', handler); } + `), + ).toEqual([{ toolName: 'typed', description: '' }]); + }); + + it.each([ + "function install(SDK) { const server = new SDK.McpServer({}); server.tool('fake', handler); }", + "function install(server: SDK.McpServer) { server.tool('fake', handler); }", + "SDK = other; const server = new SDK.McpServer({}); server.tool('fake', handler);", + "SDK.McpServer = other; const server = new SDK.McpServer({}); server.tool('fake', handler);", + "({ value: SDK.McpServer } = other); const server = new SDK.McpServer({}); server.tool('fake', handler);", + "SDK.McpServer.prototype.tool = other; const server = new SDK.McpServer({}); server.tool('fake', handler);", + "SDK[key] = other; const server = new SDK.McpServer({}); server.tool('fake', handler);", + "const server = new SDK.McpServer({}); ({ method: server.tool } = other); server.tool('fake', handler);", + "const alias = SDK; const server = new alias.McpServer({}); server.tool('fake', handler);", + "const server = new SDK.OtherServer({}); server.tool('fake', handler);", + ])('rejects unproven namespace receivers: %s', (source) => { + expect( + metadata(`import * as SDK from '@modelcontextprotocol/sdk/server/mcp.js'; ${source}`), + ).toEqual([]); + }); + + it('rejects type-only namespace construction and unrelated namespace imports', () => { + expect( + metadata(` + import type * as SDK from '@modelcontextprotocol/sdk/server/mcp.js'; + import * as Other from 'unrelated'; + const first = new SDK.McpServer({}); first.tool('type-only', handler); + const second = new Other.McpServer({}); second.tool('unrelated', handler); + `), + ).toEqual([]); + }); + + it('does not require description or inputSchema, and ignores nested descriptions', () => { + expect( + metadata(`${server} + server.registerTool('empty', {}, () => {}); + server.registerTool('nested', { inputSchema: { description: 'Schema decoy' } }, handler); + server.registerTool('dynamic-description', { description: getDescription() }, handler); + `), + ).toEqual([ + { toolName: 'empty', description: '' }, + { toolName: 'nested', description: '' }, + { toolName: 'dynamic-description', description: '' }, + ]); + }); + + it('recognizes legacy callback-last overloads with descriptions, schemas and annotations', () => { + expect( + metadata(`${server} + server.tool('bare', handler); + server.tool('described', 'Human description', handler); + server.tool('schema', { query: z.string().describe('Field decoy') }, handler); + server.tool('annotated', { readOnlyHint: true }, handler); + server.tool('full', 'Full description', { query: z.string() }, { readOnlyHint: true }, handler); + `), + ).toEqual([ + { toolName: 'bare', description: '' }, + { toolName: 'described', description: 'Human description' }, + { toolName: 'schema', description: '' }, + { toolName: 'annotated', description: '' }, + { toolName: 'full', description: 'Full description' }, + ]); + }); + + it('decodes static names and quoted description keys without distance or property-order limits', () => { + expect( + metadata(`${server} + server.registerTool(\`find\\x2ditems!?\`, { + inputSchema: { description: 'Nested decoy', example: '${'x'.repeat(2000)}' }, + 'descr\\u0069ption': 'Line\\nwith \\"quotes\\" and \\u{1F680}', + }, handler); + server.tool('legacy\\u002fname', \`Static description\`, handler); + `), + ).toEqual([ + { toolName: 'find-items!?', description: 'Line\nwith "quotes" and 🚀' }, + { toolName: 'legacy/name', description: 'Static description' }, + ]); + }); + + it('recognizes SDK aliases, locally constructed instances and directly typed helper parameters', () => { + expect( + metadata(` + import { McpServer as Server } from '@modelcontextprotocol/sdk/server/mcp.js'; + function install(server: Server) { server.registerTool('helper', {}, handler); } + const add = (server: Server) => server.tool('arrow-helper', handler); + function start() { + const local = new Server({ name: 'local', version: '1' }); + local.registerTool('local', {}, handler); + } + `), + ).toEqual([ + { toolName: 'helper', description: '' }, + { toolName: 'arrow-helper', description: '' }, + { toolName: 'local', description: '' }, + ]); + }); + + it('ignores dynamic names, comments, string decoys and unrelated receivers', () => { + expect( + metadata(`${server} + // server.registerTool('comment', { description: 'decoy' }, handler); + const decoy = "server.tool('string', handler)"; + server.registerTool(runtimeName, {}, handler); + server.tool(\`dynamic-\${runtimeName}\`, handler); + server.registerTool('prefix' + suffix, {}, handler); + unrelated.registerTool('unrelated', {}, handler); + const alias = server; + alias.tool('alias', handler); + `), + ).toEqual([]); + }); + + it('rejects shadowed SDK names and receivers, including declarations later in the scope', () => { + expect( + metadata(`${server} + function parameter(server) { server.tool('parameter', handler); } + function constructor(McpServer) { + const fake = new McpServer(); + fake.tool('constructor', handler); + } + { + server.registerTool('temporal-shadow', {}, handler); + const server = unrelated; + } + function localType() { + class McpServer {} + function helper(server: McpServer) { server.tool('type-shadow', handler); } + } + `), + ).toEqual([]); + }); + + it('rejects reassigned receivers and SDK constructors', () => { + expect( + metadata(`${sdkImport} + let changed = new McpServer(); + changed = unrelated; + changed.tool('changed', handler); + const instance = new McpServer(); + function replace() { McpServer = OtherServer; } + instance.registerTool('constructor-mutated', {}, handler); + `), + ).toEqual([]); + }); + + it('uses decoded type-only imports for helper parameters, but not construction', () => { + expect( + metadata(` + import type { McpServer as Server } from '@modelcontextprotocol/\\u0073dk/server/mcp.js'; + function install(server: Server) { server.tool('typed', handler); } + const fake = new Server(); + fake.tool('type-only-constructor', handler); + `), + ).toEqual([{ toolName: 'typed', description: '' }]); + }); + + it.each(['before', 'after'])('allows SDK lifecycle configuration %s registration', (when) => { + const configure = `server.server.oninitialized = () => {}; server.server.onerror = () => {};`; + const registration = `server.registerTool('visible', {}, handler);`; + expect( + metadata( + `${server}\n${when === 'before' ? configure + registration : registration + configure}`, + ), + ).toEqual([{ toolName: 'visible', description: '' }]); + }); + + describe.each([ + ['TypeScript', tsParser], + ['JavaScript', jsParser], + ] as const)('%s destructuring writes', (_language, parser) => { + it.each([ + ['object member', `({ registerTool: server.registerTool } = replacement);`], + ['array member', `[server.tool] = replacement;`], + ['nested quoted member', `({ nested: [server['registerTool']] } = replacement);`], + ['defaulted member', `({ registerTool: server.registerTool = fallback } = replacement);`], + ['rest member', `[...server.tool] = replacement;`], + ['computed member', `[server[method]] = replacement;`], + ['loop target', `for ({ registerTool: server.registerTool } of replacements) {}`], + ['constructor member', `[McpServer.prototype.registerTool] = replacement;`], + ])('rejects registrations after a write to an %s target', (_name, write) => { + expect( + extract(`${server}\n${write}\nserver.registerTool('fake', {}, handler);`, parser), + ).toEqual([]); + }); + + it('preserves lifecycle writes and ignores pattern keys and default-value reads', () => { + expect( + extract( + `${server} + ({ oninitialized: server.server.oninitialized } = callbacks); + ({ [server.registerTool]: ignored } = source); + ({ untouched = server.registerTool } = source); + server.registerTool('visible', {}, handler); + `, + parser, + ).map((tool) => tool.toolName), + ).toEqual(['visible']); + }); + }); + + it.each([ + [ + 'different package', + `import { McpServer } from 'unrelated'; const server = new McpServer(); server.tool('fake', h);`, + ], + [ + 'destructured parameter', + `${server} function install({ server }) { server.tool('fake', h); }`, + ], + ['destructured local', `${server} { const { other: server } = obj; server.tool('fake', h); }`], + ['catch parameter', `${server} try {} catch (server) { server.tool('fake', h); }`], + ['loop binding', `${server} for (const server of other) { server.tool('fake', h); }`], + [ + 'hoisted var', + `${server} function install() { server.tool('fake', h); { var server = other; } }`, + ], + [ + 'generic type', + `${sdkImport} function install(server: McpServer) { server.tool('fake', h); }`, + ], + [ + 'named class expression', + `${sdkImport} const Other = class McpServer { install() { const server = new McpServer(); server.tool('fake', h); } };`, + ], + ['method write', `${server} server.tool = unrelated; server.tool('fake', h);`], + ['quoted method write', `${server} server['tool'] = unrelated; server.tool('fake', h);`], + ['computed method write', `${server} server[method] = unrelated; server.tool('fake', h);`], + ['method delete', `${server} delete server.registerTool; server.registerTool('fake', {}, h);`], + ['destructured write', `${server} ({ server } = other); server.tool('fake', h);`], + [ + 'spread arguments', + `${server} server.registerTool('fake', ...args); server.tool('fake', ...args);`, + ], + ])('rejects %s', (_name, source) => { + expect(metadata(source)).toEqual([]); + }); + + it('keeps evidence outside shadowing scopes and in closures declared before the instance', () => { + expect( + metadata(`${sdkImport} + function install() { server.tool('closure', handler); } + const server = new McpServer(); + { const server = unrelated; server.tool('decoy', handler); } + server.registerTool('outer', {}, handler); + `), + ).toEqual([ + { toolName: 'closure', description: '' }, + { toolName: 'outer', description: '' }, + ]); + }); + + it('bounds descriptions to top-level properties and respects property overrides', () => { + expect( + metadata(`${server} + server.registerTool('shorthand', { description: 'Kept', title }, handler); + server.registerTool('spread-after', { description: 'Unproven', ...config }, handler); + server.registerTool('spread-before', { ...config, description: 'Known' }, handler); + server.registerTool('last-wins', { description: 'Old', description: 'New' }, handler); + server.registerTool('comments', /* first */ 'not an object', /* callback */ handler); + `), + ).toEqual([ + { toolName: 'shorthand', description: 'Kept' }, + { toolName: 'spread-after', description: '' }, + { toolName: 'spread-before', description: 'Known' }, + { toolName: 'last-wins', description: 'New' }, + { toolName: 'comments', description: '' }, + ]); + }); + + it('resolves hoisted declarations and immutable callable bindings using supplied graph IDs', () => { + const tree = tsParser.parse(`${server} + server.tool('declared', declaration); + function declaration() {} + const arrow = () => {}; + const expression = function () {}; + server.registerTool('arrow', {}, arrow); + server.tool('expression', expression); + server.tool('inline', () => {}); + `); + const bindings = new Map(); + for (const declaration of tree.rootNode.descendantsOfType([ + 'function_declaration', + 'variable_declarator', + ])) { + const name = declaration.childForFieldName('name')!; + bindings.set(name.id, `existing-graph-id:${name.text}`); + } + expect( + extractToolDefinitions(tree, 'server.ts', 0, bindings).map((tool) => [ + tool.toolName, + tool.handlerNodeId, + ]), + ).toEqual([ + ['declared', 'existing-graph-id:declaration'], + ['arrow', 'existing-graph-id:arrow'], + ['expression', 'existing-graph-id:expression'], + ['inline', undefined], + ]); + expect( + extractToolDefinitions(tree, 'server.ts').every((tool) => tool.handlerNodeId === undefined), + ).toBe(true); + }); + + it('uses the nearest callable binding without conflating same-name declarations', () => { + const tree = tsParser.parse(`${server} + function handler() {} + function install() { + const handler = () => {}; + server.tool('inner', handler); + } + server.tool('outer', handler); + `); + const outer = tree.rootNode + .descendantsOfType('function_declaration')[0] + .childForFieldName('name')!; + const inner = tree.rootNode + .descendantsOfType('variable_declarator') + .find((node) => node.childForFieldName('name')?.text === 'handler')! + .childForFieldName('name')!; + const bindings = new Map([ + [outer.id, 'emitted-outer'], + [inner.id, 'emitted-inner'], + ]); + expect( + extractToolDefinitions(tree, 'server.ts', 0, bindings).map((tool) => [ + tool.toolName, + tool.handlerNodeId, + ]), + ).toEqual([ + ['inner', 'emitted-inner'], + ['outer', 'emitted-outer'], + ]); + }); + + it('keeps ambiguous, shadowed, mutable and noncallable handler bindings unresolved', () => { + const tree = tsParser.parse(`${server} + import { imported } from './handlers'; + function handler() {} + function parameter(handler) { server.tool('parameter', handler); } + { const handler = 42; server.tool('shadowed', handler); } + const alias = handler; + server.tool('alias', alias); + server.tool('imported', imported); + let mutable = () => {}; + server.tool('mutable', mutable); + const reassigned = () => {}; + ({ reassigned } = replacements); + server.tool('reassigned', reassigned); + function duplicate() {} + function duplicate() {} + server.tool('duplicate', duplicate); + server.tool('before-initialization', later); + const later = () => {}; + `); + const bindings = new Map(); + // Even graph nodes sharing these names cannot establish a safe callback binding. + for (const node of tree.rootNode.descendantsOfType('identifier')) + bindings.set(node.id, `emitted:${node.text}`); + const tools = extractToolDefinitions(tree, 'server.ts', 0, bindings); + expect(tools.map((tool) => tool.toolName)).toEqual([ + 'parameter', + 'shadowed', + 'alias', + 'imported', + 'mutable', + 'reassigned', + 'duplicate', + 'before-initialization', + ]); + expect( + tools.every((tool) => tool.handlerNodeId === undefined && tool.allowFileFallback === false), + ).toBe(true); + }); +});