gitnexus 1.6.12-rc.37 → 1.6.12-rc.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -55,6 +55,7 @@ import { stalenessPayload, } from '../../core/staleness-status.js';
55
55
  import { logger } from '../../core/logger.js';
56
56
  import { isLocalEmbeddingRuntimeBlockerMessage, isMissingLocalEmbeddingStackMessage, } from '../../core/embeddings/runtime-support.js';
57
57
  import { LIST_REPOS_DEFAULT_LIMIT, LIST_REPOS_MAX_LIMIT, EXPLAIN_DEFAULT_LIMIT, EXPLAIN_MAX_LIMIT, PDG_QUERY_DEFAULT_LIMIT, PDG_QUERY_MAX_LIMIT, } from '../tools.js';
58
+ import { foldNumericToolArgumentAliases } from '../tool-arguments.js';
58
59
  import { findImportCycles, IMPORT_CYCLE_LIMIT } from '../../core/graph/import-cycles.js';
59
60
  import { decodeTaintPath } from '../../core/ingestion/taint/path-codec.js';
60
61
  import { decodeReachingDefReason } from '../../core/ingestion/cfg/reaching-def-reason-codec.js';
@@ -154,10 +155,8 @@ const TOOL_STRING_ALIASES = {
154
155
  function normalizeToolParams(method, params) {
155
156
  const input = params && typeof params === 'object' ? params : {};
156
157
  const definitions = TOOL_STRING_ALIASES[method];
157
- if (!definitions)
158
- return { params: input };
159
158
  const normalized = { ...input };
160
- for (const { canonical, aliases } of definitions) {
159
+ for (const { canonical, aliases } of definitions ?? []) {
161
160
  const keys = [canonical, ...aliases];
162
161
  const supplied = [];
163
162
  for (const key of keys) {
@@ -191,12 +190,15 @@ function normalizeToolParams(method, params) {
191
190
  if (supplied.length > 0)
192
191
  normalized[canonical] = supplied[0].value;
193
192
  }
193
+ const folded = foldNumericToolArgumentAliases(method, normalized);
194
+ if ('error' in folded)
195
+ return folded;
194
196
  if (method === 'impact' &&
195
- typeof normalized.target !== 'string' &&
196
- (typeof normalized.target_uid !== 'string' || !normalized.target_uid.trim())) {
197
+ typeof folded.params.target !== 'string' &&
198
+ (typeof folded.params.target_uid !== 'string' || !folded.params.target_uid.trim())) {
197
199
  return { error: 'MCP impact requires target, name, symbol, or target_uid.' };
198
200
  }
199
- return { params: normalized };
201
+ return { params: folded.params };
200
202
  }
201
203
  // AI context generation is CLI-only (gitnexus analyze)
202
204
  // import { generateAIContextFiles } from '../../cli/ai-context.js';
@@ -20,6 +20,7 @@ import { getResourceDefinitions, getResourceTemplates, readResource } from './re
20
20
  import { assertMcpReadOnlyResource, assertMcpReadOnlyToolCall, filterMcpReadOnlyResourceContent, MCP_READ_ONLY_TOOLS, readOnlyResourceTemplateAllowed, resolveMcpReadOnlyMode, toolForReadOnlyMcp, } from './read-only-policy.js';
21
21
  import { createMcpRepositoryPolicy, McpRepositoryPolicy, mcpRepositoryPolicyConfigured, } from './repository-policy.js';
22
22
  import { applyMcpMaxTokens, resolveMcpMaxTokens, withoutMcpBudgetArg } from './output-budget.js';
23
+ import { assertKnownMcpToolArguments, schemaSourceToolName } from './tool-arguments.js';
23
24
  /**
24
25
  * Next-step hints appended to tool responses.
25
26
  *
@@ -164,6 +165,12 @@ export function createMCPServer(backend, options = {}) {
164
165
  try {
165
166
  const typedArgs = args;
166
167
  assertMcpReadOnlyToolCall(name, typedArgs, readOnly);
168
+ const schemaSource = schemaSourceToolName(name);
169
+ const advertisedTool = GITNEXUS_TOOLS.find((tool) => tool.name === schemaSource);
170
+ if (advertisedTool) {
171
+ const listed = toolForReadOnlyMcp(repositoryPolicy.toolForMcp(advertisedTool), readOnly);
172
+ assertKnownMcpToolArguments(name, typedArgs, listed.inputSchema.properties);
173
+ }
167
174
  maxTokens = resolveMcpMaxTokens(name, typedArgs);
168
175
  const result = await scopedBackend.callTool(name, withoutMcpBudgetArg(typedArgs));
169
176
  const resultText = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
@@ -0,0 +1,47 @@
1
+ /**
2
+ * MCP tool-argument contract (#3261).
3
+ *
4
+ * `tools/list` advertises `inputSchema`; `tools/call` used to forward any JSON
5
+ * object. A misspelled or CLI-taught key (`depth` instead of `maxDepth`) then
6
+ * produced a well-formed answer computed from the server default — no error,
7
+ * no warning. This module is the single dispatch-time check that the keys a
8
+ * caller sent are ones the advertised schema (or an unpublished handler alias)
9
+ * actually reads.
10
+ */
11
+ /** Legacy MCP names that reuse another tool's advertised schema. */
12
+ export declare const LEGACY_TOOL_SCHEMA_SOURCE: Readonly<Record<string, string>>;
13
+ /**
14
+ * Keys the handler still reads but that must NOT appear in `inputSchema`
15
+ * (#2175: advertising `query` makes Claude Code drop the argument).
16
+ */
17
+ export declare const UNPUBLISHED_TOOL_ARGUMENT_ALIASES: Readonly<Record<string, readonly string[]>>;
18
+ export interface NumericArgumentAlias {
19
+ canonical: string;
20
+ aliases: readonly string[];
21
+ }
22
+ /**
23
+ * Numeric aliases that the backend folds onto the advertised canonical key.
24
+ * `depth` is the CLI flag name for `maxDepth` on impact and trace.
25
+ */
26
+ export declare const TOOL_NUMERIC_ARGUMENT_ALIASES: Readonly<Record<string, readonly NumericArgumentAlias[]>>;
27
+ export declare function schemaSourceToolName(toolName: string): string;
28
+ export declare function advertisedToolPropertyNames(toolName: string): string[] | undefined;
29
+ export declare function suggestKnownToolArgument(unknownKey: string, knownKeys: readonly string[]): string | undefined;
30
+ /**
31
+ * Reject top-level tool arguments that are neither advertised nor an
32
+ * unpublished handler alias. `advertisedProperties` should be the schema the
33
+ * caller actually saw (`tools/list` after read-only / repository-policy
34
+ * scrubbing). When it is omitted, the canonical `GITNEXUS_TOOLS` schema is
35
+ * used. Tools with no schema (legacy `overview`) are left unchecked.
36
+ */
37
+ export declare function assertKnownMcpToolArguments(toolName: string, args: Record<string, unknown> | undefined, advertisedProperties?: Record<string, unknown>): void;
38
+ /**
39
+ * Fold numeric aliases onto their canonical key (e.g. `depth` → `maxDepth`).
40
+ * Conflicting values error; a single agreed value is written to the canonical
41
+ * key and the alias keys are removed so every downstream reader sees one name.
42
+ */
43
+ export declare function foldNumericToolArgumentAliases(toolName: string, params: Record<string, unknown>): {
44
+ params: Record<string, unknown>;
45
+ } | {
46
+ error: string;
47
+ };
@@ -0,0 +1,147 @@
1
+ /**
2
+ * MCP tool-argument contract (#3261).
3
+ *
4
+ * `tools/list` advertises `inputSchema`; `tools/call` used to forward any JSON
5
+ * object. A misspelled or CLI-taught key (`depth` instead of `maxDepth`) then
6
+ * produced a well-formed answer computed from the server default — no error,
7
+ * no warning. This module is the single dispatch-time check that the keys a
8
+ * caller sent are ones the advertised schema (or an unpublished handler alias)
9
+ * actually reads.
10
+ */
11
+ import { GITNEXUS_TOOLS } from './tools.js';
12
+ /** Legacy MCP names that reuse another tool's advertised schema. */
13
+ export const LEGACY_TOOL_SCHEMA_SOURCE = {
14
+ search: 'query',
15
+ explore: 'context',
16
+ };
17
+ /**
18
+ * Keys the handler still reads but that must NOT appear in `inputSchema`
19
+ * (#2175: advertising `query` makes Claude Code drop the argument).
20
+ */
21
+ export const UNPUBLISHED_TOOL_ARGUMENT_ALIASES = {
22
+ query: ['query'],
23
+ cypher: ['query'],
24
+ // Group-mode context still reads `target` as the symbol name; local
25
+ // `name` is the advertised key. Advertising `target` would collide with
26
+ // impact's target vocabulary and is not in tools/list. Legacy `search`
27
+ // and `explore` inherit via schemaSourceToolName.
28
+ context: ['target'],
29
+ };
30
+ /**
31
+ * Numeric aliases that the backend folds onto the advertised canonical key.
32
+ * `depth` is the CLI flag name for `maxDepth` on impact and trace.
33
+ */
34
+ export const TOOL_NUMERIC_ARGUMENT_ALIASES = {
35
+ impact: [{ canonical: 'maxDepth', aliases: ['depth'] }],
36
+ trace: [{ canonical: 'maxDepth', aliases: ['depth'] }],
37
+ };
38
+ export function schemaSourceToolName(toolName) {
39
+ return LEGACY_TOOL_SCHEMA_SOURCE[toolName] ?? toolName;
40
+ }
41
+ export function advertisedToolPropertyNames(toolName) {
42
+ const source = schemaSourceToolName(toolName);
43
+ const tool = GITNEXUS_TOOLS.find((entry) => entry.name === source);
44
+ if (!tool)
45
+ return undefined;
46
+ return Object.keys(tool.inputSchema.properties);
47
+ }
48
+ function normalizeArgumentKey(key) {
49
+ return key.toLowerCase().replace(/_/gu, '');
50
+ }
51
+ export function suggestKnownToolArgument(unknownKey, knownKeys) {
52
+ const needle = normalizeArgumentKey(unknownKey);
53
+ if (!needle)
54
+ return undefined;
55
+ const exact = knownKeys.find((key) => normalizeArgumentKey(key) === needle);
56
+ if (exact)
57
+ return exact;
58
+ const contained = knownKeys.filter((key) => {
59
+ const normalized = normalizeArgumentKey(key);
60
+ return normalized.includes(needle) || needle.includes(normalized);
61
+ });
62
+ return contained.length === 1 ? contained[0] : undefined;
63
+ }
64
+ function formatUnknownArgumentError(toolName, unknownKeys, advertisedKeys) {
65
+ const quoted = unknownKeys.map((key) => `"${key}"`).join(', ');
66
+ const noun = unknownKeys.length === 1 ? 'argument' : 'arguments';
67
+ const verb = unknownKeys.length === 1 ? 'does' : 'do';
68
+ const suggestion = unknownKeys.length === 1 ? suggestKnownToolArgument(unknownKeys[0], advertisedKeys) : undefined;
69
+ if (suggestion) {
70
+ return `Unknown ${noun} ${quoted} for tool "${toolName}". Did you mean "${suggestion}"?`;
71
+ }
72
+ return (`Unknown ${noun} ${quoted} for tool "${toolName}". ` +
73
+ `The advertised inputSchema ${verb} not include ${unknownKeys.length === 1 ? 'this key' : 'these keys'}.`);
74
+ }
75
+ /**
76
+ * Reject top-level tool arguments that are neither advertised nor an
77
+ * unpublished handler alias. `advertisedProperties` should be the schema the
78
+ * caller actually saw (`tools/list` after read-only / repository-policy
79
+ * scrubbing). When it is omitted, the canonical `GITNEXUS_TOOLS` schema is
80
+ * used. Tools with no schema (legacy `overview`) are left unchecked.
81
+ */
82
+ export function assertKnownMcpToolArguments(toolName, args, advertisedProperties) {
83
+ if (!args)
84
+ return;
85
+ const propertyNames = advertisedProperties !== undefined
86
+ ? Object.keys(advertisedProperties)
87
+ : advertisedToolPropertyNames(toolName);
88
+ if (!propertyNames)
89
+ return;
90
+ const unpublished = UNPUBLISHED_TOOL_ARGUMENT_ALIASES[toolName] ??
91
+ UNPUBLISHED_TOOL_ARGUMENT_ALIASES[schemaSourceToolName(toolName)] ??
92
+ [];
93
+ const allowed = new Set([...propertyNames, ...unpublished]);
94
+ const unknownKeys = Object.keys(args).filter((key) => !allowed.has(key));
95
+ if (unknownKeys.length === 0)
96
+ return;
97
+ throw new Error(formatUnknownArgumentError(toolName, unknownKeys, propertyNames));
98
+ }
99
+ /**
100
+ * Fold numeric aliases onto their canonical key (e.g. `depth` → `maxDepth`).
101
+ * Conflicting values error; a single agreed value is written to the canonical
102
+ * key and the alias keys are removed so every downstream reader sees one name.
103
+ */
104
+ export function foldNumericToolArgumentAliases(toolName, params) {
105
+ const definitions = TOOL_NUMERIC_ARGUMENT_ALIASES[toolName];
106
+ if (!definitions)
107
+ return { params };
108
+ const normalized = { ...params };
109
+ for (const { canonical, aliases } of definitions) {
110
+ const keys = [canonical, ...aliases];
111
+ const supplied = [];
112
+ for (const key of keys) {
113
+ if (!Object.prototype.hasOwnProperty.call(normalized, key))
114
+ continue;
115
+ const value = normalized[key];
116
+ if (value === undefined)
117
+ continue;
118
+ if (typeof value !== 'number') {
119
+ return { error: `MCP parameter ${toolName}.${key} must be a number.` };
120
+ }
121
+ // #2279: some MCP adapters materialize an omitted optional number as 0,
122
+ // and a coerced missing value arrives as NaN. Treat both sentinels as
123
+ // absent so they cannot conflict with a real maxDepth or fold onto
124
+ // `params.maxDepth || 3`. The handlers already map a non-positive or
125
+ // non-integer maxDepth to their default; erroring here turned that
126
+ // contract into an error payload instead.
127
+ if (value === 0 || Number.isNaN(value))
128
+ continue;
129
+ supplied.push({ key, value });
130
+ }
131
+ const distinctValues = new Set(supplied.map(({ value }) => value));
132
+ if (distinctValues.size > 1) {
133
+ return {
134
+ error: `Conflicting MCP parameters for ${toolName}.${canonical}: ${supplied
135
+ .map(({ key }) => key)
136
+ .join(', ')} must agree.`,
137
+ };
138
+ }
139
+ // Drop every source key, then write back the single agreed value (if any),
140
+ // so a sentinel 0/NaN never survives on the canonical key.
141
+ for (const key of keys)
142
+ delete normalized[key];
143
+ if (supplied.length > 0)
144
+ normalized[canonical] = supplied[0].value;
145
+ }
146
+ return { params: normalized };
147
+ }
@@ -24,6 +24,7 @@ export interface ToolDefinition {
24
24
  minLength?: number;
25
25
  }>;
26
26
  required: string[];
27
+ additionalProperties?: false;
27
28
  };
28
29
  }
29
30
  /**
package/dist/mcp/tools.js CHANGED
@@ -526,6 +526,12 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep
526
526
  minimum: 1,
527
527
  maximum: IMPACT_MAX_DEPTH,
528
528
  },
529
+ depth: {
530
+ type: 'number',
531
+ description: 'Compatibility alias for maxDepth (CLI --depth). Values must agree when both are present. Literal 0 is an omitted-value compatibility sentinel.',
532
+ minimum: 0,
533
+ maximum: IMPACT_MAX_DEPTH,
534
+ },
529
535
  crossDepth: {
530
536
  type: 'number',
531
537
  description: 'Cross-repository hop depth via contract bridge (default: 1; values above server maximum are clamped)',
@@ -865,6 +871,12 @@ DESTINATION TRACE (cross-repo): for an "@groupName" trace, OMIT to/to_uid/to_fil
865
871
  minimum: 1,
866
872
  maximum: 30,
867
873
  },
874
+ depth: {
875
+ type: 'number',
876
+ description: 'Compatibility alias for maxDepth (CLI --depth). Values must agree when both are present. Literal 0 is an omitted-value compatibility sentinel.',
877
+ minimum: 0,
878
+ maximum: 30,
879
+ },
868
880
  includeTests: {
869
881
  type: 'boolean',
870
882
  description: 'Include test-file symbols in traversal (default: false)',
@@ -922,6 +934,16 @@ export const REPO_SCOPED_TOOLS = new Set([
922
934
  'trace',
923
935
  ]);
924
936
  for (const tool of GITNEXUS_TOOLS) {
937
+ // Advertises a closed schema; tools/call still fail-closes on the scrubbed key list.
938
+ // The unpublished handler aliases in tool-arguments.ts stay off this schema on
939
+ // purpose (#2175), and closing it strands no caller: every alias has an
940
+ // advertised counterpart reaching the same handler — `query` → `search_query`
941
+ // on query, `query` → `statement` on cypher, and `target` → `name` on group
942
+ // context, which local-backend maps to the group target (the group name comes
943
+ // from `repo: "@group"`, not from `name`; see test/unit/mcp/group-repo-routing).
944
+ // A schema-validating client therefore has a valid call for every tool, and
945
+ // advertising the aliases instead would re-break Claude Code on `query`.
946
+ tool.inputSchema.additionalProperties = false;
925
947
  if (!REPO_SCOPED_TOOLS.has(tool.name))
926
948
  continue;
927
949
  if (tool.inputSchema.properties.branch)
@@ -903,12 +903,16 @@ export function parseDiffHunksResult(diffOutput) {
903
903
  // `+++` after the first `@@` of a file is hunk body (`+` plus source text
904
904
  // that itself starts `++ …`), not another file header.
905
905
  let inHunk = false;
906
+ // A deleted file has no new-side coordinates. Its indexed symbols still use
907
+ // the pre-delete file, so map those hunks with the old-side range instead.
908
+ let currentFileDeleted = false;
906
909
  for (const line of diffOutput.split('\n')) {
907
910
  if (line.startsWith(DIFF_GIT_PREFIX)) {
908
911
  // Drop the previous file first: an unparsed header must not leave
909
912
  // `current` live for a later `@@` / quoted `+++` to steal.
910
913
  current = null;
911
914
  inHunk = false;
915
+ currentFileDeleted = false;
912
916
  const filePath = filePathFromGitHeader(line);
913
917
  if (filePath) {
914
918
  current = { filePath, hunks: [] };
@@ -929,6 +933,9 @@ export function parseDiffHunksResult(diffOutput) {
929
933
  files.push(current);
930
934
  }
931
935
  }
936
+ else if (!inHunk && line === '+++ /dev/null') {
937
+ currentFileDeleted = true;
938
+ }
932
939
  else if (!inHunk && line.startsWith('+++ ')) {
933
940
  const filePath = pathFromPlusPlusPlus(line);
934
941
  if (!filePath)
@@ -940,10 +947,12 @@ export function parseDiffHunksResult(diffOutput) {
940
947
  }
941
948
  else if (line.startsWith('@@') && current) {
942
949
  inHunk = true;
943
- const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
950
+ const match = line.match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
944
951
  if (match) {
945
- const start = parseInt(match[1], 10);
946
- const count = match[2] !== undefined ? parseInt(match[2], 10) : 1;
952
+ const sideOffset = currentFileDeleted ? 1 : 3;
953
+ const start = parseInt(match[sideOffset], 10);
954
+ const rawCount = match[sideOffset + 1];
955
+ const count = rawCount !== undefined ? parseInt(rawCount, 10) : 1;
947
956
  if (count > 0) {
948
957
  current.hunks.push({ startLine: start, endLine: start + count - 1 });
949
958
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.12-rc.37",
3
+ "version": "1.6.12-rc.38",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",