@juspay/neurolink 12.9.5 → 12.9.6

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.
@@ -1943,9 +1943,37 @@ export declare class NeuroLink {
1943
1943
  * @param options - Execution options
1944
1944
  * @returns Tool execution result
1945
1945
  */
1946
- executeExternalMCPTool(serverId: string, toolName: string, parameters: JsonObject, options?: {
1946
+ executeExternalMCPTool(serverId: string, requestedToolName: string, parameters: JsonObject, options?: {
1947
1947
  timeout?: number;
1948
1948
  }): Promise<unknown>;
1949
+ /**
1950
+ * Resolve a tool name for a direct external MCP execution
1951
+ * (`executeExternalMCPTool`) against the server's currently discovered
1952
+ * tools.
1953
+ *
1954
+ * `experimental_repairToolCall` only runs inside the AI-SDK's own
1955
+ * streamText/generateText loop (see toolCallRepair.ts), so a near-miss
1956
+ * tool name reaching `executeExternalMCPTool` directly previously had no
1957
+ * recovery at all — just the generic `Tool 'x' not found for server 'y'`
1958
+ * `Error` that `ToolDiscoveryService.executeTool` throws deeper in the
1959
+ * stack. This reuses the same name-matching policy
1960
+ * (`resolveToolName`: case-insensitive exact → unambiguous substring →
1961
+ * Levenshtein) so a repair here is accepted under exactly the rules
1962
+ * already proven for the generation path.
1963
+ *
1964
+ * Exact match is a zero-risk fast path: it is returned unchanged before
1965
+ * any resolution attempt, so every existing caller that already sends a
1966
+ * valid name — including the AI-SDK path's `createExternalMCPTool`, which
1967
+ * always executes with a name it just discovered — sees no behaviour
1968
+ * change.
1969
+ *
1970
+ * Deliberately does NOT attempt a repair when the server is unknown or
1971
+ * not connected: `ExternalServerManager.executeTool` throws distinct,
1972
+ * more accurate errors for those states ("Server 'x' not found" /
1973
+ * "not in connected state"), and resolving against an empty tool list
1974
+ * here would replace those with a misleading "tool not found" instead.
1975
+ */
1976
+ private resolveDirectMcpToolName;
1949
1977
  /**
1950
1978
  * Get all tools from external MCP servers
1951
1979
  * @returns Array of external tool information
package/dist/neurolink.js CHANGED
@@ -89,6 +89,7 @@ import { logger, mcpLogger } from "./utils/logger.js";
89
89
  import { redactUrlCredentials, safeDebugSerialize, sanitizeRecord, stringifyContentSafe, } from "./utils/logSanitize.js";
90
90
  import { extractMcpErrorText } from "./utils/mcpErrorText.js";
91
91
  import { createCustomToolServerInfo, detectCategory, } from "./utils/mcpDefaults.js";
92
+ import { ExternalMcpToolNotFoundError, rankToolNameCandidates, resolveToolName, } from "./utils/toolCallRepair.js";
92
93
  import { resolveModel } from "./utils/modelAliasResolver.js";
93
94
  // Import orchestration components
94
95
  import { ModelRouter } from "./utils/modelRouter.js";
@@ -11732,8 +11733,16 @@ Current user's request: ${currentInput}`;
11732
11733
  * @param options - Execution options
11733
11734
  * @returns Tool execution result
11734
11735
  */
11735
- async executeExternalMCPTool(serverId, toolName, parameters, options) {
11736
+ async executeExternalMCPTool(serverId, requestedToolName, parameters, options) {
11737
+ // Falls back to the requested name so the catch block below still has
11738
+ // something meaningful to log if resolution itself is what throws.
11739
+ let toolName = requestedToolName;
11736
11740
  try {
11741
+ // Direct-boundary name repair (see resolveDirectMcpToolName): an exact
11742
+ // match returns requestedToolName unchanged, so every existing caller
11743
+ // — including the AI-SDK generation path's createExternalMCPTool,
11744
+ // which always calls with an already-discovered name — is unaffected.
11745
+ toolName = this.resolveDirectMcpToolName(serverId, requestedToolName);
11737
11746
  mcpLogger.debug(`[NeuroLink] Executing external MCP tool: ${toolName} on ${serverId}`);
11738
11747
  // BZ-664: Check existing ToolResultCache before executing to avoid
11739
11748
  // duplicate identical calls within the same session.
@@ -11803,6 +11812,65 @@ Current user's request: ${currentInput}`;
11803
11812
  throw error;
11804
11813
  }
11805
11814
  }
11815
+ /**
11816
+ * Resolve a tool name for a direct external MCP execution
11817
+ * (`executeExternalMCPTool`) against the server's currently discovered
11818
+ * tools.
11819
+ *
11820
+ * `experimental_repairToolCall` only runs inside the AI-SDK's own
11821
+ * streamText/generateText loop (see toolCallRepair.ts), so a near-miss
11822
+ * tool name reaching `executeExternalMCPTool` directly previously had no
11823
+ * recovery at all — just the generic `Tool 'x' not found for server 'y'`
11824
+ * `Error` that `ToolDiscoveryService.executeTool` throws deeper in the
11825
+ * stack. This reuses the same name-matching policy
11826
+ * (`resolveToolName`: case-insensitive exact → unambiguous substring →
11827
+ * Levenshtein) so a repair here is accepted under exactly the rules
11828
+ * already proven for the generation path.
11829
+ *
11830
+ * Exact match is a zero-risk fast path: it is returned unchanged before
11831
+ * any resolution attempt, so every existing caller that already sends a
11832
+ * valid name — including the AI-SDK path's `createExternalMCPTool`, which
11833
+ * always executes with a name it just discovered — sees no behaviour
11834
+ * change.
11835
+ *
11836
+ * Deliberately does NOT attempt a repair when the server is unknown or
11837
+ * not connected: `ExternalServerManager.executeTool` throws distinct,
11838
+ * more accurate errors for those states ("Server 'x' not found" /
11839
+ * "not in connected state"), and resolving against an empty tool list
11840
+ * here would replace those with a misleading "tool not found" instead.
11841
+ */
11842
+ resolveDirectMcpToolName(serverId, requestedName) {
11843
+ const server = this.getExternalMCPServer(serverId);
11844
+ if (!server || server.status !== "connected" || !server.client) {
11845
+ return requestedName;
11846
+ }
11847
+ const availableNames = this.getExternalMCPServerTools(serverId).map((tool) => tool.name);
11848
+ if (availableNames.includes(requestedName)) {
11849
+ return requestedName;
11850
+ }
11851
+ const resolution = resolveToolName(requestedName, availableNames);
11852
+ if (!resolution) {
11853
+ throw new ExternalMcpToolNotFoundError(requestedName, serverId, rankToolNameCandidates(requestedName, availableNames));
11854
+ }
11855
+ // Recorded on a short-lived span rather than the method's return value:
11856
+ // executeExternalMCPTool returns the raw upstream tool result verbatim
11857
+ // (widely consumed as-is, e.g. by createExternalMCPTool's execute()),
11858
+ // so wrapping it to carry resolution metadata would be a breaking
11859
+ // change for every existing direct caller.
11860
+ tracers.mcp.startActiveSpan("neurolink.mcp.toolNameRepair", {
11861
+ attributes: {
11862
+ "mcp.server_id": serverId,
11863
+ "mcp.tool_name.requested": requestedName,
11864
+ "mcp.tool_name.resolved": resolution.name,
11865
+ "mcp.tool_name.repair_strategy": resolution.strategy,
11866
+ ...(resolution.score !== undefined
11867
+ ? { "mcp.tool_name.repair_score": resolution.score }
11868
+ : {}),
11869
+ },
11870
+ }, (span) => span.end());
11871
+ mcpLogger.info(`[NeuroLink] Repaired external MCP tool name at direct execution boundary: "${requestedName}" → "${resolution.name}" (${resolution.strategy}) on server '${serverId}'`);
11872
+ return resolution.name;
11873
+ }
11806
11874
  /**
11807
11875
  * Get all tools from external MCP servers
11808
11876
  * @returns Array of external tool information
@@ -422,6 +422,22 @@ export type ToolDiscoveryResult = {
422
422
  /** Server ID */
423
423
  serverId: string;
424
424
  };
425
+ /**
426
+ * Outcome of matching a possibly-misspelled tool name against a list of
427
+ * available tool names (see `resolveToolName` in
428
+ * src/lib/utils/toolCallRepair.ts). Shared between the AI-SDK generation-path
429
+ * repair (`experimental_repairToolCall`) and direct MCP execution boundaries
430
+ * (`NeuroLink.executeExternalMCPTool`) so both recover from the same class of
431
+ * near-miss the same way.
432
+ */
433
+ export type ToolNameResolution = {
434
+ /** The resolved, available tool name. */
435
+ name: string;
436
+ /** Which strategy produced the match, in the order they are attempted. */
437
+ strategy: "case" | "substring" | "levenshtein";
438
+ /** Normalized Levenshtein distance (0–1) — only set when strategy is "levenshtein". */
439
+ score?: number;
440
+ };
425
441
  /**
426
442
  * External MCP tool execution options
427
443
  * Moved from src/lib/mcp/toolDiscoveryService.ts
@@ -1,9 +1,51 @@
1
1
  import type { ToolCallRepairFunction, ToolSet } from "../types/index.js";
2
+ import type { ToolNameResolution } from "../types/index.js";
2
3
  /**
3
4
  * Create an `experimental_repairToolCall` handler for streamText/generateText.
4
5
  * Fully dynamic — reads the tool schema at repair time, no configuration needed.
5
6
  */
6
7
  export declare function createToolCallRepair(): ToolCallRepairFunction<ToolSet>;
8
+ /**
9
+ * Match a possibly-misspelled tool name against a list of available tool
10
+ * names. Strategies (in order): case-insensitive exact → unambiguous
11
+ * substring → Levenshtein.
12
+ *
13
+ * Pulled out of `repairToolName` so the same matching policy can be reused
14
+ * outside the AI-SDK generation loop — `experimental_repairToolCall` only
15
+ * runs inside `streamText`/`generateText`, so a name typo at a direct MCP
16
+ * execution boundary (`NeuroLink.executeExternalMCPTool`) previously had no
17
+ * recovery at all. This function is pure name-matching: no `LanguageModelV3ToolCall`,
18
+ * no logging, so callers with a different call shape can reuse it directly.
19
+ */
20
+ export declare function resolveToolName(calledName: string, availableTools: string[]): ToolNameResolution | null;
21
+ /**
22
+ * Rank every available tool name by similarity to `calledName` (ascending
23
+ * normalized Levenshtein distance) and return the closest `limit`.
24
+ *
25
+ * Used to build the candidate list on `ExternalMcpToolNotFoundError` when
26
+ * `resolveToolName` found no unambiguous match — unlike `resolveToolName`,
27
+ * this makes no accept/reject judgment, it just orders what is available so
28
+ * a caller (human or AI) can pick the right name themselves.
29
+ */
30
+ export declare function rankToolNameCandidates(calledName: string, availableTools: string[], limit?: number): string[];
31
+ /**
32
+ * Thrown at a direct MCP execution boundary (`NeuroLink.executeExternalMCPTool`)
33
+ * when `resolveToolName` cannot find an unambiguous match for a requested
34
+ * tool name against a server's discovered tools. Distinct from the plain
35
+ * `Error` that `ToolDiscoveryService.executeTool` throws deeper in the stack
36
+ * for the same condition, so callers can distinguish "no match — here are
37
+ * the closest names" from every other execution failure programmatically,
38
+ * instead of parsing a message string.
39
+ */
40
+ export declare class ExternalMcpToolNotFoundError extends Error {
41
+ /** The tool name that was requested and could not be resolved. */
42
+ readonly requestedName: string;
43
+ /** The server the tool was requested against. */
44
+ readonly serverId: string;
45
+ /** Closest available tool names on that server, capped (see `rankToolNameCandidates`). */
46
+ readonly candidates: string[];
47
+ constructor(requestedName: string, serverId: string, candidates: string[]);
48
+ }
7
49
  /**
8
50
  * Coerce a value to match the expected schema type.
9
51
  * Handles: string→number, JSON string→object, JSON string→array, value→[value].
@@ -25,31 +25,36 @@ export function createToolCallRepair() {
25
25
  }
26
26
  // ─── Tool Name Repair ──────────────────────────────────────────────
27
27
  /**
28
- * Attempt to match a wrong tool name against available tool names.
29
- * Strategies (in order): case-insensitive exact → substring → Levenshtein.
28
+ * Match a possibly-misspelled tool name against a list of available tool
29
+ * names. Strategies (in order): case-insensitive exact → unambiguous
30
+ * substring → Levenshtein.
31
+ *
32
+ * Pulled out of `repairToolName` so the same matching policy can be reused
33
+ * outside the AI-SDK generation loop — `experimental_repairToolCall` only
34
+ * runs inside `streamText`/`generateText`, so a name typo at a direct MCP
35
+ * execution boundary (`NeuroLink.executeExternalMCPTool`) previously had no
36
+ * recovery at all. This function is pure name-matching: no `LanguageModelV3ToolCall`,
37
+ * no logging, so callers with a different call shape can reuse it directly.
30
38
  */
31
- function repairToolName(toolCall, availableTools) {
32
- const called = toolCall.toolName;
39
+ export function resolveToolName(calledName, availableTools) {
33
40
  // Guard: empty or whitespace-only tool name cannot be meaningfully repaired
34
- if (!called || called.trim().length === 0) {
41
+ if (!calledName || calledName.trim().length === 0) {
35
42
  return null;
36
43
  }
37
44
  // 1. Case-insensitive exact match
38
- const ciMatch = availableTools.find((t) => t.toLowerCase() === called.toLowerCase());
45
+ const ciMatch = availableTools.find((t) => t.toLowerCase() === calledName.toLowerCase());
39
46
  if (ciMatch) {
40
- logger.debug(`[ToolCallRepair] Name repair (case): "${called}" → "${ciMatch}"`);
41
- return { ...toolCall, toolName: ciMatch };
47
+ return { name: ciMatch, strategy: "case" };
42
48
  }
43
49
  // 2. Substring match: "search_file" is substring of "search_files" or vice versa.
44
50
  // Only accept when exactly one tool matches to avoid ambiguous repairs.
45
- const calledLower = called.toLowerCase();
51
+ const calledLower = calledName.toLowerCase();
46
52
  const subCandidates = availableTools.filter((t) => {
47
53
  const tLower = t.toLowerCase();
48
54
  return tLower.includes(calledLower) || calledLower.includes(tLower);
49
55
  });
50
56
  if (subCandidates.length === 1) {
51
- logger.debug(`[ToolCallRepair] Name repair (substring): "${called}" → "${subCandidates[0]}"`);
52
- return { ...toolCall, toolName: subCandidates[0] };
57
+ return { name: subCandidates[0], strategy: "substring" };
53
58
  }
54
59
  // 3. Levenshtein distance — accept if normalized distance < 0.3
55
60
  // Compare by normalized score (not raw edits) so length differences don't skew selection.
@@ -57,7 +62,7 @@ function repairToolName(toolCall, availableTools) {
57
62
  let bestNormalized = Infinity;
58
63
  for (const t of availableTools) {
59
64
  const dist = levenshtein(calledLower, t.toLowerCase());
60
- const maxLen = Math.max(called.length, t.length);
65
+ const maxLen = Math.max(calledName.length, t.length);
61
66
  const normalized = maxLen === 0 ? 0 : dist / maxLen;
62
67
  if (normalized < 0.3 && normalized < bestNormalized) {
63
68
  bestNormalized = normalized;
@@ -65,12 +70,70 @@ function repairToolName(toolCall, availableTools) {
65
70
  }
66
71
  }
67
72
  if (bestMatch) {
68
- logger.debug(`[ToolCallRepair] Name repair (levenshtein ${bestNormalized.toFixed(2)}): "${called}" "${bestMatch}"`);
69
- return { ...toolCall, toolName: bestMatch };
73
+ return { name: bestMatch, strategy: "levenshtein", score: bestNormalized };
70
74
  }
71
- logger.debug(`[ToolCallRepair] Could not repair tool name "${called}". Available: [${availableTools.join(", ")}]`);
72
75
  return null;
73
76
  }
77
+ /**
78
+ * Rank every available tool name by similarity to `calledName` (ascending
79
+ * normalized Levenshtein distance) and return the closest `limit`.
80
+ *
81
+ * Used to build the candidate list on `ExternalMcpToolNotFoundError` when
82
+ * `resolveToolName` found no unambiguous match — unlike `resolveToolName`,
83
+ * this makes no accept/reject judgment, it just orders what is available so
84
+ * a caller (human or AI) can pick the right name themselves.
85
+ */
86
+ export function rankToolNameCandidates(calledName, availableTools, limit = 5) {
87
+ const calledLower = calledName.toLowerCase();
88
+ return [...availableTools]
89
+ .sort((a, b) => levenshtein(calledLower, a.toLowerCase()) -
90
+ levenshtein(calledLower, b.toLowerCase()))
91
+ .slice(0, limit);
92
+ }
93
+ /**
94
+ * Thrown at a direct MCP execution boundary (`NeuroLink.executeExternalMCPTool`)
95
+ * when `resolveToolName` cannot find an unambiguous match for a requested
96
+ * tool name against a server's discovered tools. Distinct from the plain
97
+ * `Error` that `ToolDiscoveryService.executeTool` throws deeper in the stack
98
+ * for the same condition, so callers can distinguish "no match — here are
99
+ * the closest names" from every other execution failure programmatically,
100
+ * instead of parsing a message string.
101
+ */
102
+ export class ExternalMcpToolNotFoundError extends Error {
103
+ /** The tool name that was requested and could not be resolved. */
104
+ requestedName;
105
+ /** The server the tool was requested against. */
106
+ serverId;
107
+ /** Closest available tool names on that server, capped (see `rankToolNameCandidates`). */
108
+ candidates;
109
+ constructor(requestedName, serverId, candidates) {
110
+ const candidateList = candidates.length > 0 ? candidates.join(", ") : "(none registered)";
111
+ super(`Tool '${requestedName}' not found for server '${serverId}'. Closest available: ${candidateList}`);
112
+ this.name = "ExternalMcpToolNotFoundError";
113
+ this.requestedName = requestedName;
114
+ this.serverId = serverId;
115
+ this.candidates = candidates;
116
+ }
117
+ }
118
+ /**
119
+ * Attempt to match a wrong tool name against available tool names and
120
+ * produce a repaired `LanguageModelV3ToolCall` for the AI-SDK generation
121
+ * path. Thin wrapper around `resolveToolName` that restores this function's
122
+ * original debug-log wording so generation-path behaviour is unchanged.
123
+ */
124
+ function repairToolName(toolCall, availableTools) {
125
+ const called = toolCall.toolName;
126
+ const resolution = resolveToolName(called, availableTools);
127
+ if (!resolution) {
128
+ logger.debug(`[ToolCallRepair] Could not repair tool name "${called}". Available: [${availableTools.join(", ")}]`);
129
+ return null;
130
+ }
131
+ const label = resolution.strategy === "levenshtein"
132
+ ? `levenshtein ${resolution.score.toFixed(2)}`
133
+ : resolution.strategy;
134
+ logger.debug(`[ToolCallRepair] Name repair (${label}): "${called}" → "${resolution.name}"`);
135
+ return { ...toolCall, toolName: resolution.name };
136
+ }
74
137
  // ─── Tool Input Repair ─────────────────────────────────────────────
75
138
  /**
76
139
  * Attempt to repair wrong parameter names and types using the JSON schema.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "12.9.5",
3
+ "version": "12.9.6",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (connect any MCP server), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
6
6
  "author": {
@@ -86,6 +86,7 @@
86
86
  "test:mcp:sdk": "pnpm exec tsx test/continuous-test-suite-mcp-sdk.ts",
87
87
  "test:mcp:cli": "pnpm exec tsx test/continuous-test-suite-mcp-cli.ts",
88
88
  "test:mcp:stdio-lifecycle": "pnpm exec tsx test/continuous-test-suite-mcp-stdio-lifecycle.ts",
89
+ "test:mcp-direct-name-repair": "pnpm exec tsx test/continuous-test-suite-mcp-direct-name-repair.ts",
89
90
  "test:mcp:full": "pnpm run test:mcp:infra && pnpm run test:mcp:spans && pnpm run test:mcp:stdio-lifecycle && pnpm run test:mcp:sdk && pnpm run test:mcp:cli && pnpm run test:mcp:http",
90
91
  "test:media": "pnpm exec tsx test/continuous-test-suite-media-gen.ts",
91
92
  "test:media-registry-collisions": "pnpm exec tsx test/continuous-test-suite-media-registry-collisions.ts",
@@ -154,7 +155,7 @@
154
155
  "// CI tier — fast, no live AI calls, safe for every commit (test:unit; also see the separate provider-safety-net CI job, which runs build + test:providers-mocked + test:provider-structure + test:error-classifier-contract on every PR)": "",
155
156
  "test:tool-routing": "pnpm exec tsx test/continuous-test-suite-tool-routing.ts",
156
157
  "test:tool-routing-semantic": "pnpm exec tsx test/continuous-test-suite-tool-routing-semantic.ts",
157
- "test:unit": "pnpm run test:bugfixes && pnpm run test:mcp:infra && pnpm run test:mcp:spans && pnpm run test:tool-routing && pnpm run test:tool-routing-cli && pnpm run test:tool-dedup && pnpm run test:model-pool && pnpm run test:classifier-router && pnpm run test:tool-routing-semantic && pnpm run test:mcp-result-cache && pnpm run test:model-not-found-retryable && pnpm run test:archive:security && pnpm run test:office:security && pnpm run test:vector-chroma && pnpm run test:vector-pgvector && pnpm run test:vector-pinecone && pnpm run test:provider-wiring && pnpm run test:docs-mcp",
158
+ "test:unit": "pnpm run test:bugfixes && pnpm run test:mcp:infra && pnpm run test:mcp:spans && pnpm run test:tool-routing && pnpm run test:tool-routing-cli && pnpm run test:tool-dedup && pnpm run test:model-pool && pnpm run test:classifier-router && pnpm run test:tool-routing-semantic && pnpm run test:mcp-result-cache && pnpm run test:mcp-direct-name-repair && pnpm run test:model-not-found-retryable && pnpm run test:archive:security && pnpm run test:office:security && pnpm run test:vector-chroma && pnpm run test:vector-pgvector && pnpm run test:vector-pinecone && pnpm run test:provider-wiring && pnpm run test:docs-mcp",
158
159
  "// CI tier — live providers, runs only when API keys are present (test:credentials and test:dynamic make real provider calls when keys are set, so they live here, not in test:unit; test:matrix, a different suite covering the full provider capability matrix, runs nightly via .github/workflows/live-matrix.yml — test:providers itself is still only wired into test:live, not any GitHub Actions workflow)": "",
159
160
  "test:live": "pnpm run test:providers && pnpm run test:mcp:http && pnpm run test:mcp:sdk && pnpm run test:mcp:cli && pnpm run test:observability && pnpm run test:context && pnpm run test:memory && pnpm run test:tool-reliability && pnpm run test:evaluation && pnpm run test:autoresearch && pnpm run test:credentials && pnpm run test:dynamic",
160
161
  "// CI tier — product output (image/video/TTS/PPT) — costs $$ per run (not wired into any GitHub Actions workflow as of this comment; run manually or add to live-matrix.yml if nightly coverage is needed)": "",