fix(mcp): discover positional SDK tool registrations (#3450)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run

This commit is contained in:
Gergő Magyar 2026-10-02 23:57:23 +01:00 • committed by GitHub
parent a8f18f00b9
commit f99dde8aa3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1252 additions and 38 deletions

View file

@ -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.",

View file

@ -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<number, string>,
) => ExtractedToolDef[];
/**
* Extract routes that a parsed file declares in its own AST.
*

View file

@ -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<typeof extractDispatchGuardRoutes>) => [
...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,
});

View file

@ -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<string, Binding>;
}
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<Binding> = {}): 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<number, Scope>();
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<string | null> = [];
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<number, Scope>): 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<number, string> | 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<number, string>,
): 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;
}

View file

@ -323,6 +323,7 @@ export const processesPhase: PipelinePhase<ProcessesOutput> = {
const toolsByHandlerId = new Map<string, string[]>();
const toolsWithoutHandlerByFile = new Map<string, string[]>();
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);

View file

@ -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<ToolsOutput> = {
filePath: td.filePath,
description: td.description,
...(handlerNodeId !== undefined ? { handlerNodeId } : {}),
...(td.allowFileFallback === false ? { allowFileFallback: false as const } : {}),
});
}

View file

@ -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<number, string>();
const callableBindings = new Map<number, string>();
// #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<string>();
const ambiguousCallableIds = new Set<string>();
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);

View file

@ -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

View file

@ -0,0 +1 @@
export function importedHandler() { return 'imported'; }

View file

@ -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);

View file

@ -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);
}

View file

@ -0,0 +1,4 @@
export const tools = [
{ name: 'manifest_tool', description: 'Existing object manifest', inputSchema: {} },
{ name: 'read_file', description: 'Duplicate manifest entry', inputSchema: {} },
];

View file

@ -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);
});

View file

@ -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);
}

View file

@ -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);
});
});

View file

@ -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<SDK>(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<McpServer>(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<number, string>();
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<number, string>();
// 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);
});
});