@superdurable/dex-agents 0.1.0 → 0.2.0

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.
Files changed (149) hide show
  1. package/README.md +1568 -62
  2. package/dist/src/agent-built-in-tools.d.ts +81 -0
  3. package/dist/src/agent-built-in-tools.js +246 -0
  4. package/dist/src/agent-built-in-tools.js.map +1 -0
  5. package/dist/src/agent-context-compaction.d.ts +108 -0
  6. package/dist/src/agent-context-compaction.js +39 -0
  7. package/dist/src/agent-context-compaction.js.map +1 -0
  8. package/dist/src/agent-framework-runner.d.ts +456 -16
  9. package/dist/src/agent-framework-runner.js +4 -3
  10. package/dist/src/agent-framework-runner.js.map +1 -1
  11. package/dist/src/agent-plans.d.ts +48 -0
  12. package/dist/src/agent-plans.js +86 -0
  13. package/dist/src/agent-plans.js.map +1 -0
  14. package/dist/src/agent-session-cancellation-steps.d.ts +28 -0
  15. package/dist/src/agent-session-cancellation-steps.js +154 -0
  16. package/dist/src/agent-session-cancellation-steps.js.map +1 -0
  17. package/dist/src/agent-session-codecs.d.ts +65 -4
  18. package/dist/src/agent-session-codecs.js +342 -8
  19. package/dist/src/agent-session-codecs.js.map +1 -1
  20. package/dist/src/agent-session-flow.d.ts +295 -27
  21. package/dist/src/agent-session-flow.js +368 -21
  22. package/dist/src/agent-session-flow.js.map +1 -1
  23. package/dist/src/agent-session-persistence.d.ts +81 -4
  24. package/dist/src/agent-session-persistence.js +33 -2
  25. package/dist/src/agent-session-persistence.js.map +1 -1
  26. package/dist/src/agent-session-settings.d.ts +58 -0
  27. package/dist/src/agent-session-settings.js +81 -0
  28. package/dist/src/agent-session-settings.js.map +1 -0
  29. package/dist/src/agent-session-step-options.d.ts +27 -0
  30. package/dist/src/agent-session-step-options.js +39 -0
  31. package/dist/src/agent-session-step-options.js.map +1 -0
  32. package/dist/src/agent-session-step-types.d.ts +20 -0
  33. package/dist/src/agent-session-step-types.js +28 -0
  34. package/dist/src/agent-session-step-types.js.map +1 -0
  35. package/dist/src/agent-session-steps.d.ts +101 -8
  36. package/dist/src/agent-session-steps.js +936 -197
  37. package/dist/src/agent-session-steps.js.map +1 -1
  38. package/dist/src/agent-session-tracer.d.ts +61 -0
  39. package/dist/src/agent-session-tracer.js +608 -0
  40. package/dist/src/agent-session-tracer.js.map +1 -0
  41. package/dist/src/agent-session-transitions.d.ts +189 -26
  42. package/dist/src/agent-session-transitions.js +680 -32
  43. package/dist/src/agent-session-transitions.js.map +1 -1
  44. package/dist/src/agent-session-values.d.ts +690 -29
  45. package/dist/src/agent-session-values.js +39 -4
  46. package/dist/src/agent-session-values.js.map +1 -1
  47. package/dist/src/agent-session-wait-steps.d.ts +53 -0
  48. package/dist/src/agent-session-wait-steps.js +389 -0
  49. package/dist/src/agent-session-wait-steps.js.map +1 -0
  50. package/dist/src/agent-tracing-options.d.ts +107 -0
  51. package/dist/src/agent-tracing-options.js +20 -0
  52. package/dist/src/agent-tracing-options.js.map +1 -0
  53. package/dist/src/ai-sdk/ai-sdk-model-client.d.ts +40 -0
  54. package/dist/src/ai-sdk/ai-sdk-model-client.js +235 -0
  55. package/dist/src/ai-sdk/ai-sdk-model-client.js.map +1 -0
  56. package/dist/src/ai-sdk/index.d.ts +1 -0
  57. package/dist/src/ai-sdk/index.js +9 -0
  58. package/dist/src/ai-sdk/index.js.map +1 -0
  59. package/dist/src/index.d.ts +9 -3
  60. package/dist/src/index.js +7 -2
  61. package/dist/src/index.js.map +1 -1
  62. package/dist/src/mcp/index.d.ts +3 -0
  63. package/dist/src/mcp/index.js +10 -0
  64. package/dist/src/mcp/index.js.map +1 -0
  65. package/dist/src/mcp/mcp-resource-and-prompt-tools.d.ts +29 -0
  66. package/dist/src/mcp/mcp-resource-and-prompt-tools.js +185 -0
  67. package/dist/src/mcp/mcp-resource-and-prompt-tools.js.map +1 -0
  68. package/dist/src/mcp/mcp-server-config.d.ts +86 -0
  69. package/dist/src/mcp/mcp-server-config.js +38 -0
  70. package/dist/src/mcp/mcp-server-config.js.map +1 -0
  71. package/dist/src/mcp/mcp-tool-source.d.ts +470 -0
  72. package/dist/src/mcp/mcp-tool-source.js +1204 -0
  73. package/dist/src/mcp/mcp-tool-source.js.map +1 -0
  74. package/dist/src/stepwise/agent-definition.d.ts +158 -0
  75. package/dist/src/stepwise/agent-definition.js +9 -0
  76. package/dist/src/stepwise/agent-definition.js.map +1 -0
  77. package/dist/src/stepwise/function-tool.d.ts +130 -0
  78. package/dist/src/stepwise/function-tool.js +75 -0
  79. package/dist/src/stepwise/function-tool.js.map +1 -0
  80. package/dist/src/stepwise/index.d.ts +7 -0
  81. package/dist/src/stepwise/index.js +12 -0
  82. package/dist/src/stepwise/index.js.map +1 -0
  83. package/dist/src/stepwise/json-schema-validation.d.ts +9 -0
  84. package/dist/src/stepwise/json-schema-validation.js +141 -0
  85. package/dist/src/stepwise/json-schema-validation.js.map +1 -0
  86. package/dist/src/stepwise/model-client.d.ts +194 -0
  87. package/dist/src/stepwise/model-client.js +9 -0
  88. package/dist/src/stepwise/model-client.js.map +1 -0
  89. package/dist/src/stepwise/scripted-model-client.d.ts +99 -0
  90. package/dist/src/stepwise/scripted-model-client.js +156 -0
  91. package/dist/src/stepwise/scripted-model-client.js.map +1 -0
  92. package/dist/src/stepwise/stepwise-agent-runner.d.ts +189 -0
  93. package/dist/src/stepwise/stepwise-agent-runner.js +804 -0
  94. package/dist/src/stepwise/stepwise-agent-runner.js.map +1 -0
  95. package/dist/src/stepwise/stepwise-agent-session-flow.d.ts +35 -0
  96. package/dist/src/stepwise/stepwise-agent-session-flow.js +56 -0
  97. package/dist/src/stepwise/stepwise-agent-session-flow.js.map +1 -0
  98. package/dist/src/stepwise/stepwise-input-items.d.ts +12 -0
  99. package/dist/src/stepwise/stepwise-input-items.js +172 -0
  100. package/dist/src/stepwise/stepwise-input-items.js.map +1 -0
  101. package/dist/src/stepwise/tool-source.d.ts +103 -0
  102. package/dist/src/stepwise/tool-source.js +9 -0
  103. package/dist/src/stepwise/tool-source.js.map +1 -0
  104. package/dist/src/testing/agent-session-test-client.d.ts +65 -8
  105. package/dist/src/testing/agent-session-test-client.js +95 -22
  106. package/dist/src/testing/agent-session-test-client.js.map +1 -1
  107. package/dist/src/testing/index.d.ts +1 -0
  108. package/dist/src/testing/index.js +1 -0
  109. package/dist/src/testing/index.js.map +1 -1
  110. package/dist/src/testing/private-dex-engine.d.ts +9 -0
  111. package/dist/src/testing/private-dex-engine.js +9 -1
  112. package/dist/src/testing/private-dex-engine.js.map +1 -1
  113. package/dist/src/testing/scripted-model-client.d.ts +1 -0
  114. package/dist/src/testing/scripted-model-client.js +10 -0
  115. package/dist/src/testing/scripted-model-client.js.map +1 -0
  116. package/dist/src/ui-stream/agent-event-json.d.ts +30 -0
  117. package/dist/src/ui-stream/agent-event-json.js +64 -0
  118. package/dist/src/ui-stream/agent-event-json.js.map +1 -0
  119. package/dist/src/ui-stream/agent-event-sse.d.ts +154 -0
  120. package/dist/src/ui-stream/agent-event-sse.js +159 -0
  121. package/dist/src/ui-stream/agent-event-sse.js.map +1 -0
  122. package/dist/src/ui-stream/agent-event-stream-positions.d.ts +42 -0
  123. package/dist/src/ui-stream/agent-event-stream-positions.js +153 -0
  124. package/dist/src/ui-stream/agent-event-stream-positions.js.map +1 -0
  125. package/dist/src/ui-stream/agent-session-record-reader.d.ts +72 -0
  126. package/dist/src/ui-stream/agent-session-record-reader.js +241 -0
  127. package/dist/src/ui-stream/agent-session-record-reader.js.map +1 -0
  128. package/dist/src/ui-stream/agent-ui-message-stream-responses.d.ts +219 -0
  129. package/dist/src/ui-stream/agent-ui-message-stream-responses.js +440 -0
  130. package/dist/src/ui-stream/agent-ui-message-stream-responses.js.map +1 -0
  131. package/dist/src/ui-stream/agent-ui-message-stream.d.ts +138 -0
  132. package/dist/src/ui-stream/agent-ui-message-stream.js +681 -0
  133. package/dist/src/ui-stream/agent-ui-message-stream.js.map +1 -0
  134. package/dist/src/ui-stream/agent-ui-message-types.d.ts +238 -0
  135. package/dist/src/ui-stream/agent-ui-message-types.js +57 -0
  136. package/dist/src/ui-stream/agent-ui-message-types.js.map +1 -0
  137. package/dist/src/ui-stream/agent-ui-messages.d.ts +66 -0
  138. package/dist/src/ui-stream/agent-ui-messages.js +220 -0
  139. package/dist/src/ui-stream/agent-ui-messages.js.map +1 -0
  140. package/dist/src/ui-stream/agent-ui-stream-cursor.d.ts +64 -0
  141. package/dist/src/ui-stream/agent-ui-stream-cursor.js +186 -0
  142. package/dist/src/ui-stream/agent-ui-stream-cursor.js.map +1 -0
  143. package/dist/src/ui-stream/index.d.ts +14 -0
  144. package/dist/src/ui-stream/index.js +22 -0
  145. package/dist/src/ui-stream/index.js.map +1 -0
  146. package/dist/src/ui-stream/server-sent-event-responses.d.ts +6 -0
  147. package/dist/src/ui-stream/server-sent-event-responses.js +34 -0
  148. package/dist/src/ui-stream/server-sent-event-responses.js.map +1 -0
  149. package/package.json +41 -5
@@ -0,0 +1,1204 @@
1
+ // Copyright (c) 2026 Super Durable, Inc.
2
+ //
3
+ // Licensed under the Sustainable Use License 1.0.
4
+ // You may not use this file except in compliance with the License.
5
+ // See the LICENSE file in the repository root.
6
+ //
7
+ // SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0
8
+ import { createHash } from "node:crypto";
9
+ import { createRequire } from "node:module";
10
+ import { setTimeout as delay } from "node:timers/promises";
11
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
12
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
13
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
14
+ import { CallToolResultSchema, CreateTaskResultSchema, ErrorCode, McpError, } from "@modelcontextprotocol/sdk/types.js";
15
+ import { ToolExecutionError, toolEffectFromMcpToolAnnotations, } from "../agent-framework-runner.js";
16
+ import { buildResourceAndPromptToolDefinitions, readServerName, resourceAndPromptToolNames, runResourceAndPromptTool, serializeToolOutputJson, } from "./mcp-resource-and-prompt-tools.js";
17
+ import { redactMcpServerConfigSecrets, validateMcpServerConfig } from "./mcp-server-config.js";
18
+ /** `_meta` key that carries the session's idempotency key on every `tools/call`. */
19
+ export const mcpIdempotencyKeyMetaKey = "io.superdurable.dex/idempotencyKey";
20
+ /** `AgentEvents` type for an MCP `notifications/progress` of a running tool call. */
21
+ export const mcpToolProgressEventType = "mcp.tool_progress";
22
+ /** `AgentEvents` type for each observed status of an MCP task. */
23
+ export const mcpTaskStatusEventType = "mcp.task_status";
24
+ /** Default bound on one MCP `tools/call` or task request, in milliseconds: 10 minutes. */
25
+ export const defaultMcpRequestTimeoutMs = 600_000;
26
+ /** Default bound on polling one MCP task inside one tool Step attempt, in milliseconds: 10 minutes. */
27
+ export const defaultMcpTaskWaitTimeoutMs = 600_000;
28
+ /** Default TTL the source requests for a task that `PollToolTask` polls, in milliseconds: 24 hours. */
29
+ export const defaultMcpTaskTtlMs = 86_400_000;
30
+ /** Default bound on connecting, the `initialize` handshake, and `tools/list`, in milliseconds: 30 seconds. */
31
+ export const defaultMcpConnectionTimeoutMs = 30_000;
32
+ /** Default number of connection attempts before a request reports the server unreachable. */
33
+ export const defaultMcpConnectionAttempts = 3;
34
+ /** Default {@link McpToolSourceOptions.sourceName}, which stored Stepwise calls are routed by. */
35
+ export const defaultMcpSourceName = "mcp";
36
+ /** Default age in milliseconds after which `listTools` lists a server again, in the background: 5 minutes. */
37
+ export const defaultMcpListingTtlMs = 300_000;
38
+ /** Default wait in milliseconds after a failed listing before `listTools` tries that server again: 30 seconds. */
39
+ export const defaultMcpListingFailureBackoffMs = 30_000;
40
+ const toolNameLengthLimit = 64;
41
+ const toolListingPageLimit = 100;
42
+ const connectionRetryDelayMs = 500;
43
+ const defaultTaskPollIntervalMs = 1_000;
44
+ const minimumTaskPollIntervalMs = 50;
45
+ const maximumTaskPollIntervalMs = 30_000;
46
+ const taskCancelTimeoutMs = 5_000;
47
+ const sessionTerminationTimeoutMs = 5_000;
48
+ // The requested task TTL outlasts CallTool's three bounded waits, with margin for retry delays.
49
+ const taskTtlWaitMultiple = 4;
50
+ // A durable poll is a Step: backing off to a minute bounds a long task's history.
51
+ const durableTaskPollBackoffCapSeconds = 60;
52
+ const maximumDurableTaskPollIntervalSeconds = 3_600;
53
+ // The server or a gateway may report InternalError after the call ran, so it leaves the outcome unknown.
54
+ const noResultErrorCodes = new Set([
55
+ ErrorCode.ConnectionClosed,
56
+ ErrorCode.RequestTimeout,
57
+ ErrorCode.InternalError,
58
+ ]);
59
+ /**
60
+ * A `tools/call` that reached an MCP server but returned no result.
61
+ *
62
+ * {@link McpToolSource} throws it when the connection broke, the request timed out, the server process
63
+ * exited, the server or a gateway answered with an internal error, an HTTP error status, or an unreadable
64
+ * body, or an MCP task stopped being observable. The tool may or may not have taken effect. It is not a
65
+ * {@link ToolExecutionError}, so the tool Step's effect decides what happens next: `CallTool` re-issues
66
+ * a `read` or `idempotent_write` call as a new request with the same idempotency key, and
67
+ * `CallSingleAttemptTool` records `outcome_unknown` for the other effects and never sends it again.
68
+ */
69
+ export class McpToolCallInterruptedError extends Error {
70
+ serverName;
71
+ mcpToolName;
72
+ detail;
73
+ /**
74
+ * Creates the error.
75
+ * @param serverName - Configured name of the MCP server.
76
+ * @param mcpToolName - The tool's name on that server.
77
+ * @param detail - What went wrong.
78
+ */
79
+ constructor(serverName, mcpToolName, detail) {
80
+ super(`MCP tool ${mcpToolName} of server ${serverName} returned no result: ${detail}`);
81
+ this.serverName = serverName;
82
+ this.mcpToolName = mcpToolName;
83
+ this.detail = detail;
84
+ this.name = "McpToolCallInterruptedError";
85
+ }
86
+ }
87
+ /**
88
+ * No configured MCP server could be listed.
89
+ *
90
+ * {@link McpToolSource.listTools} leaves out a server it cannot list and logs a warning; it throws this
91
+ * error only when every configured server failed and none has an earlier listing. Thrown from
92
+ * `runModelStep`, it fails the `RunModel` attempt, which retries.
93
+ */
94
+ export class McpToolListingError extends Error {
95
+ serverName;
96
+ detail;
97
+ /**
98
+ * Creates the error.
99
+ * @param serverName - Configured name of the last MCP server that failed.
100
+ * @param detail - What went wrong with that server.
101
+ */
102
+ constructor(serverName, detail) {
103
+ super(`could not list the tools of MCP server ${serverName}: ${detail}`);
104
+ this.serverName = serverName;
105
+ this.detail = detail;
106
+ this.name = "McpToolListingError";
107
+ }
108
+ }
109
+ const nodeInspectSymbol = Symbol.for("nodejs.util.inspect.custom");
110
+ /**
111
+ * Offers the tools of configured MCP servers to an agent, one Dex Step per call.
112
+ *
113
+ * Configure the servers on the Worker, never from Flow input, a user message, or a browser.
114
+ * {@link listTools} lists each server's tools and caches them per server as {@link McpTool} definitions
115
+ * that a runner offers to the model. When the model calls one, the runner returns
116
+ * {@link deferredToolCall}, so the session runs the call in its own tool Step, and the Step calls
117
+ * {@link executeDeferredToolCall}. No MCP call runs inside `RunModel`. {@link deferredToolCall} stores
118
+ * the server and the tool's MCP name on the call's record, so the tool Step calls that server directly,
119
+ * whatever the other servers do.
120
+ *
121
+ * Each tool's effect comes from its annotations through {@link toolEffectFromMcpToolAnnotations}:
122
+ * `readOnlyHint` makes it `read`, and `idempotentHint` makes it `idempotent_write`. Any other tool gets
123
+ * the session's `defaultToolEffect`, `unclassified` by default. Annotations are trusted by default. A
124
+ * {@link ToolPolicy} in `AgentSessionOptions.toolPolicies` keyed by {@link McpTool.name} wins.
125
+ *
126
+ * The source uses `@modelcontextprotocol/sdk` 1.x, which speaks MCP up to 2025-11-25: every request
127
+ * opens a new connection, performs the `initialize` handshake, ends the HTTP protocol session, and
128
+ * closes it, so no protocol session outlives a request. Every `tools/call` carries the session's
129
+ * idempotency key in `_meta` under {@link mcpIdempotencyKeyMetaKey}.
130
+ *
131
+ * Results map onto the session contract:
132
+ * - A result becomes the tool output: text blocks joined with newlines, other blocks and structured
133
+ * content as JSON.
134
+ * - An `isError` result, a JSON-RPC error response other than an internal error, and a failed,
135
+ * cancelled, or `input_required` task throw {@link ToolExecutionError}, so the session records
136
+ * `failed` and the model sees the text. The 2025-11-25 tasks deliver input requests as server requests
137
+ * that must be answered on a live connection, so no durable question can carry them; the source cancels
138
+ * an `input_required` task.
139
+ * - No result after the request was sent throws {@link McpToolCallInterruptedError}; the tool Step's
140
+ * effect decides whether it is re-issued.
141
+ * - A call that was never sent, because its server is not configured or cannot be reached, throws
142
+ * {@link ToolExecutionError}.
143
+ * - A tool whose `execution.taskSupport` is `required` is called as a task. A Streamable HTTP server
144
+ * without protocol sessions gets {@link ToolTaskStarted}: the tool Step ends, and `PollToolTask` calls
145
+ * {@link pollToolTask} on a Timer, so no Step or connection is held while the task runs. The source
146
+ * requests a TTL of `taskTtlMs` for such a task. `cancel_turn` abandons the task, and
147
+ * {@link cancelToolTask} sends `tasks/cancel`.
148
+ * - A stdio server's task lives in its process, and a server that keeps protocol sessions, as the SDK's
149
+ * task store does, binds its tasks to the session, which ends with the request. The tool Step polls
150
+ * such a task with `tasks/get`, with a heartbeat per poll, for at most `taskWaitTimeoutMs` per attempt.
151
+ * A retried attempt of a stateful server's call cannot see the task from its new session, so it calls
152
+ * the tool again.
153
+ * - For a Streamable HTTP server the task ID is saved as the attempt's heartbeat checkpoint before
154
+ * anything else, so once that checkpoint reaches Dex, a retried attempt resumes the same task instead of
155
+ * calling again. Dex may drop a heartbeat checkpoint; the idempotency key lets the server drop the
156
+ * duplicate then.
157
+ * - Without `DeferredToolExecution.supportsToolTasks`, for a runner without `pollToolTask`, the tool Step
158
+ * polls every task itself, as {@link callTool} does.
159
+ *
160
+ * With `resourceAndPromptToolsEnabled`, the listing also offers three `read` tools for the servers' resources
161
+ * and prompts: `mcp_list_resources_and_prompts` lists a server's resources, resource templates, and prompts;
162
+ * `mcp_read_resource` reads one resource by URI; and `mcp_get_prompt` gets one prompt with its arguments. Each
163
+ * takes the configured server's name as its `server` argument and opens a new connection to that server with
164
+ * the same transport and headers as a tool call. Its output is the server's answer as JSON with sorted keys:
165
+ * `{"resources", "resource_templates", "prompts"}`, `{"contents"}`, or `{"messages", "description"}`. A server
166
+ * that does not offer resources or prompts lists none of them. An MCP error answer throws
167
+ * {@link ToolExecutionError}. The names, descriptions, and argument schemas match the Python core. Both cores
168
+ * support every resource and prompt request that `@modelcontextprotocol/sdk` 1.32.2 supports; this core does
169
+ * not follow `notifications/resources/list_changed` or subscribe to resources, and neither does Python.
170
+ *
171
+ * Share one source between the parallel tool Steps of a Worker; build it once at startup.
172
+ *
173
+ * @example
174
+ * ```ts
175
+ * const tools = new McpToolSource([
176
+ * { transport: "stdio", name: "files", command: "mcp-server-files" },
177
+ * { transport: "streamable_http", name: "tickets", url: ticketsUrl },
178
+ * ]);
179
+ * class MyRunner implements AgentFrameworkRunner {
180
+ * // buildUserMessage omitted
181
+ * public async runModelStep(turn: AgentTurnContext): Promise<AgentTurnOutcome> {
182
+ * const offered = await tools.listTools(); // name, description, inputSchema
183
+ * const response = await callMyModel(turn.messages, offered);
184
+ * return {
185
+ * kind: "tool_calls_requested",
186
+ * frameworkRunState: JSON.stringify(response),
187
+ * usage: response.usage,
188
+ * toolCalls: response.toolCalls.map((call) =>
189
+ * tools.deferredToolCall(turn, { toolCallId: call.id, toolName: call.name, arguments: call.arguments })),
190
+ * };
191
+ * }
192
+ * public executeDeferredToolCall(call: DeferredToolExecution): Promise<ToolExecutionResult | ToolTaskStarted> {
193
+ * return tools.executeDeferredToolCall(call);
194
+ * }
195
+ * public pollToolTask(poll: ToolTaskPoll): Promise<ToolTaskPollOutcome> {
196
+ * return tools.pollToolTask(poll);
197
+ * }
198
+ * public cancelToolTask(cancellation: ToolTaskCancellation): Promise<void> {
199
+ * return tools.cancelToolTask(cancellation);
200
+ * }
201
+ * }
202
+ * ```
203
+ */
204
+ export class McpToolSource {
205
+ servers;
206
+ requestTimeoutMs;
207
+ taskWaitTimeoutMs;
208
+ taskTtlMs;
209
+ connectionTimeoutMs;
210
+ connectionAttempts;
211
+ connector;
212
+ listings = new Map();
213
+ listingTtlMs;
214
+ listingFailureBackoffMs;
215
+ configuredSourceName;
216
+ tools;
217
+ resourceAndPromptTools;
218
+ /**
219
+ * Describes the servers without connecting to them.
220
+ * @param servers - Server configurations with unique names.
221
+ * @param options - Timeouts, connection attempts, and client name.
222
+ * @throws {TypeError} When a configuration is invalid, server names repeat, a timeout, `taskTtlMs`,
223
+ * `listingTtlMs`, or `listingFailureBackoffMs` is not positive, or `connectionAttempts` is less than 1.
224
+ */
225
+ constructor(servers, options = {}) {
226
+ const serversByName = new Map();
227
+ for (const server of servers) {
228
+ validateMcpServerConfig(server);
229
+ if (serversByName.has(server.name)) {
230
+ throw new TypeError(`MCP server name ${server.name} is configured twice`);
231
+ }
232
+ serversByName.set(server.name, server);
233
+ }
234
+ this.servers = serversByName;
235
+ this.requestTimeoutMs = options.requestTimeoutMs ?? defaultMcpRequestTimeoutMs;
236
+ this.taskWaitTimeoutMs = options.taskWaitTimeoutMs ?? defaultMcpTaskWaitTimeoutMs;
237
+ this.taskTtlMs = options.taskTtlMs ?? defaultMcpTaskTtlMs;
238
+ this.connectionTimeoutMs = options.connectionTimeoutMs ?? defaultMcpConnectionTimeoutMs;
239
+ this.connectionAttempts = options.connectionAttempts ?? defaultMcpConnectionAttempts;
240
+ if (!(this.requestTimeoutMs > 0) ||
241
+ !(this.taskWaitTimeoutMs > 0) ||
242
+ !(this.taskTtlMs > 0) ||
243
+ !(this.connectionTimeoutMs > 0)) {
244
+ throw new TypeError("requestTimeoutMs, taskWaitTimeoutMs, taskTtlMs, and connectionTimeoutMs must be positive");
245
+ }
246
+ if (!Number.isInteger(this.connectionAttempts) || this.connectionAttempts < 1) {
247
+ throw new TypeError("connectionAttempts must be an integer of at least 1");
248
+ }
249
+ this.listingTtlMs = options.listingTtlMs ?? defaultMcpListingTtlMs;
250
+ this.listingFailureBackoffMs = options.listingFailureBackoffMs ?? defaultMcpListingFailureBackoffMs;
251
+ if (!(this.listingTtlMs > 0) || !(this.listingFailureBackoffMs > 0)) {
252
+ throw new TypeError("listingTtlMs and listingFailureBackoffMs must be positive");
253
+ }
254
+ this.configuredSourceName = options.sourceName ?? defaultMcpSourceName;
255
+ for (const name of serversByName.keys()) {
256
+ this.listings.set(name, { tools: undefined, listedAtMs: 0, failure: undefined, failedUntilMs: 0, pending: undefined });
257
+ }
258
+ this.connector = new McpConnector({ name: options.clientName ?? "dex-agents", version: dexAgentsVersion() }, this.connectionTimeoutMs);
259
+ this.resourceAndPromptTools =
260
+ options.resourceAndPromptToolsEnabled === true && serversByName.size > 0
261
+ ? buildResourceAndPromptToolDefinitions([...serversByName.keys()]).map((definition) => ({
262
+ ...definition,
263
+ serverName: "",
264
+ mcpToolName: "",
265
+ annotations: { readOnlyHint: true },
266
+ frameworkEffect: "read",
267
+ taskSupport: undefined,
268
+ }))
269
+ : [];
270
+ }
271
+ /**
272
+ * Returns the tools of every reachable configured server.
273
+ *
274
+ * Each server is listed on its own and cached per Worker process. The first listing of a server is awaited.
275
+ * After that, a listing older than `listingTtlMs` is refreshed in the background while the earlier one is
276
+ * returned. A server that cannot be listed is left out with a warning and is not tried again until
277
+ * `listingFailureBackoffMs` has passed; then it is listed in the background. One listing per server runs at a
278
+ * time, however many sessions call this. `refresh: true` lists every server again now and waits for it. When two
279
+ * tools map to the same agent tool name, the first in server order wins and the other is left out with a
280
+ * warning. Call it from `runModelStep` to offer the tools, or at Worker startup to warm the cache.
281
+ * @param options - `refresh: true` lists every server again now, ignoring the cache and the failure backoff.
282
+ * @returns Every offered tool, in server order and then in each server's order, then the resource and prompt
283
+ * tools when enabled.
284
+ * @throws {McpToolListingError} When servers are configured and none has a listing, because every one failed.
285
+ */
286
+ async listTools(options = {}) {
287
+ const isRefresh = options.refresh === true;
288
+ const now = Date.now();
289
+ const waits = [];
290
+ for (const server of this.servers.values()) {
291
+ const listing = this.listings.get(server.name);
292
+ if (listing.pending === undefined) {
293
+ const isStale = listing.tools === undefined || now - listing.listedAtMs >= this.listingTtlMs;
294
+ if (isRefresh || (isStale && now >= listing.failedUntilMs)) {
295
+ const pending = this.refreshListing(server, listing).finally(() => {
296
+ if (listing.pending === pending) {
297
+ listing.pending = undefined;
298
+ }
299
+ });
300
+ listing.pending = pending;
301
+ }
302
+ }
303
+ const isFirstListing = listing.tools === undefined && listing.failure === undefined;
304
+ if (listing.pending !== undefined && (isRefresh || isFirstListing)) {
305
+ waits.push(listing.pending);
306
+ }
307
+ }
308
+ await Promise.all(waits);
309
+ const listings = [...this.listings.values()];
310
+ if (listings.length > 0 && listings.every((listing) => listing.tools === undefined)) {
311
+ const failure = listings.map((listing) => listing.failure).filter((error) => error !== undefined).at(-1);
312
+ throw failure ?? new McpToolListingError([...this.servers.keys()][0] ?? "", "no listing finished yet");
313
+ }
314
+ this.tools ??= this.combineServerTools();
315
+ return [...this.tools.values()];
316
+ }
317
+ /**
318
+ * The name a Stepwise agent's `toolSources` routes stored calls by: `sourceName` as passed, or
319
+ * {@link defaultMcpSourceName}, `"mcp"`.
320
+ */
321
+ get sourceName() {
322
+ return this.configuredSourceName;
323
+ }
324
+ /**
325
+ * Shows the source in `console.log` and `util.inspect` output without secrets.
326
+ * @param depth - Remaining inspection depth.
327
+ * @param options - Node's inspection options.
328
+ * @param inspect - Node's `util.inspect`.
329
+ * @returns The source name and the server configurations, with environment and header values replaced by `***`.
330
+ */
331
+ [nodeInspectSymbol](depth, options, inspect) {
332
+ const servers = [...this.servers.values()].map(redactMcpServerConfigSecrets);
333
+ const childOptions = { ...options, depth: options.depth === null ? null : depth - 1 };
334
+ return `McpToolSource ${inspect({ sourceName: this.configuredSourceName, servers }, childOptions)}`;
335
+ }
336
+ /**
337
+ * Returns the tools of the last listings without listing, for estimates.
338
+ * @returns The cached tools, as {@link listTools} would return them, or none before any listing finished.
339
+ */
340
+ cachedTools() {
341
+ return this.tools === undefined ? [] : [...this.tools.values()];
342
+ }
343
+ /**
344
+ * Returns the tool names this source lists whatever its servers offer, so the Stepwise runner refuses a name
345
+ * collision when it is constructed instead of on every model call.
346
+ * @returns The resource and prompt tool names when they are enabled, else none.
347
+ */
348
+ declaredToolNames() {
349
+ return new Set(this.resourceAndPromptTools.map((tool) => tool.name));
350
+ }
351
+ /**
352
+ * Returns one listed tool by its agent tool name.
353
+ * @param toolName - {@link McpTool.name}.
354
+ * @returns The cached definition.
355
+ * @throws {RangeError} When {@link listTools} has not run or no listed server offers the tool.
356
+ */
357
+ tool(toolName) {
358
+ if (this.tools === undefined) {
359
+ throw new RangeError("call listTools before looking up an MCP tool");
360
+ }
361
+ const tool = this.tools.get(toolName);
362
+ if (tool === undefined) {
363
+ throw new RangeError(`no listed MCP server offers the tool ${toolName}`);
364
+ }
365
+ return tool;
366
+ }
367
+ /**
368
+ * Returns a {@link ToolPolicy} for every listed tool whose annotations declare an effect.
369
+ *
370
+ * Use it with a framework adapter that offers the MCP tools as its own tools: pass it in the
371
+ * adapter's `toolPolicies` and spread your own policies after it, so yours win.
372
+ * @example
373
+ * ```ts
374
+ * await tools.listTools();
375
+ * const toolPolicies = { ...tools.toolPolicies(), files__delete: { effect: "non_idempotent_write" } };
376
+ * ```
377
+ * @returns Policies by {@link McpTool.name}, with the effect's default approval.
378
+ * @throws {RangeError} When {@link listTools} has not run.
379
+ */
380
+ toolPolicies() {
381
+ if (this.tools === undefined) {
382
+ throw new RangeError("call listTools before reading MCP tool policies");
383
+ }
384
+ const policies = {};
385
+ for (const tool of this.tools.values()) {
386
+ if (tool.frameworkEffect !== undefined) {
387
+ policies[tool.name] = { effect: tool.frameworkEffect };
388
+ }
389
+ }
390
+ return policies;
391
+ }
392
+ /**
393
+ * Builds the {@link DeferredToolCall} for one model call of a listed MCP tool.
394
+ *
395
+ * The policy comes from `turn.resolveToolPolicy` with the tool's annotation effect, so an explicit
396
+ * {@link ToolPolicy} wins. The call's `toolSourceReference` names the server, the tool's MCP name, and
397
+ * its task support.
398
+ * @param turn - The `RunModel` context.
399
+ * @param call - Framework tool call ID, the {@link McpTool.name} the model called, the arguments as JSON
400
+ * text, and optionally `listedTool`, the {@link McpTool} the listing offered for this model call, as the
401
+ * Stepwise runner passes it, so a refresh that finished meanwhile cannot change or remove it. Without it, the
402
+ * tool is looked up in the current listing.
403
+ * @returns The call to put in `ToolCallsRequested.toolCalls`.
404
+ * @throws {RangeError} When no `listedTool` is given and {@link listTools} has not run or the tool is unknown.
405
+ */
406
+ deferredToolCall(turn, call) {
407
+ const listedTool = call.listedTool;
408
+ const tool = isMcpTool(listedTool) && listedTool.name === call.toolName ? listedTool : this.tool(call.toolName);
409
+ return {
410
+ toolCallId: call.toolCallId,
411
+ toolName: call.toolName,
412
+ arguments: call.arguments,
413
+ policy: turn.resolveToolPolicy(call.toolName, tool.frameworkEffect),
414
+ ...(this.isResourceAndPromptTool(call.toolName)
415
+ ? {}
416
+ : {
417
+ toolSourceReference: JSON.stringify({
418
+ mcp_server: tool.serverName,
419
+ mcp_tool: tool.mcpToolName,
420
+ ...(tool.taskSupport === undefined ? {} : { task_support: tool.taskSupport }),
421
+ }),
422
+ }),
423
+ };
424
+ }
425
+ /**
426
+ * Returns one deferred-call handler per listed MCP tool, keyed by agent tool name.
427
+ *
428
+ * Merge them with a runner's own handlers and dispatch `executeDeferredToolCall` by
429
+ * `call.toolCall.toolName`. Build the map inside `executeDeferredToolCall`, not once at construction:
430
+ * a replacement Worker runs tool Steps before any `RunModel`.
431
+ * @example
432
+ * ```ts
433
+ * public async executeDeferredToolCall(call: DeferredToolExecution): Promise<ToolExecutionResult> {
434
+ * const handlers = { ...this.localHandlers, ...(await tools.deferredToolHandlers()) };
435
+ * return handlers[call.toolCall.toolName]!(call);
436
+ * }
437
+ * ```
438
+ * @returns Handlers that each call {@link executeDeferredToolCall}.
439
+ * @throws {McpToolListingError} When no server could be listed.
440
+ */
441
+ async deferredToolHandlers() {
442
+ const handlers = {};
443
+ for (const tool of await this.listTools()) {
444
+ handlers[tool.name] = (call) => this.executeDeferredToolCall(call);
445
+ }
446
+ return handlers;
447
+ }
448
+ /**
449
+ * Runs one deferred MCP tool call in its tool Step.
450
+ *
451
+ * Call it from `AgentFrameworkRunner.executeDeferredToolCall`, and implement
452
+ * `AgentFrameworkRunner.pollToolTask` with {@link pollToolTask}. A call built by {@link deferredToolCall}
453
+ * goes straight to the server named on its record, without a listing. It passes the call's idempotency
454
+ * key, events, heartbeat, heartbeat checkpoint, and cancellation signal on.
455
+ *
456
+ * A Streamable HTTP server without protocol sessions that answers with a task gets {@link ToolTaskStarted}.
457
+ * The source saves the task ID as the heartbeat checkpoint first; a retried attempt that reads it returns
458
+ * the same task without calling the tool again.
459
+ * @param call - The approved call from the tool Step.
460
+ * @returns The tool output the model sees, or {@link ToolTaskStarted} for a task that `PollToolTask` polls.
461
+ * @throws {ToolExecutionError} When the arguments are not a JSON object, the tool failed, the server
462
+ * answered with an error or asked for input, or the call was never sent.
463
+ * @throws {McpToolCallInterruptedError} When the call was sent and no result came back.
464
+ */
465
+ async executeDeferredToolCall(call) {
466
+ const toolArguments = parseToolArguments(call.toolCall.arguments);
467
+ if (this.isResourceAndPromptTool(call.toolCall.toolName)) {
468
+ return this.runResourceAndPromptTool(call.toolCall.toolName, toolArguments, call.cancellationSignal);
469
+ }
470
+ const target = await this.resolveTarget(call.toolCall.toolName, call.toolCall.toolSourceReference);
471
+ // Without a poller, a task runs to its end inside the call, as for callTool.
472
+ const isTaskPolledBySession = call.supportsToolTasks === true;
473
+ const savedTask = savedTaskFromCheckpoint(call.heartbeatCheckpoint, target);
474
+ if (isTaskPolledBySession && savedTask?.isPolledBySession === true) {
475
+ // An earlier attempt started this task; PollToolTask finds out how it went.
476
+ return { kind: "tool_task_started", taskId: savedTask.taskId, pollIntervalSeconds: 1 };
477
+ }
478
+ return this.callTarget(target, toolArguments, {
479
+ idempotencyKey: call.idempotencyKey,
480
+ toolCallId: call.toolCall.toolCallId,
481
+ events: call.events,
482
+ heartbeat: () => call.heartbeat(),
483
+ ...(call.heartbeatCheckpoint === undefined ? {} : { heartbeatCheckpoint: call.heartbeatCheckpoint }),
484
+ ...(call.saveHeartbeatCheckpoint === undefined
485
+ ? {}
486
+ : { saveHeartbeatCheckpoint: (checkpoint) => call.saveHeartbeatCheckpoint(checkpoint) }),
487
+ signal: call.cancellationSignal,
488
+ }, isTaskPolledBySession);
489
+ }
490
+ /**
491
+ * Polls one MCP task that {@link executeDeferredToolCall} started, once.
492
+ *
493
+ * Call it from `AgentFrameworkRunner.pollToolTask`. It reads the task with `tasks/get` and writes an
494
+ * `mcp.task_status` event when the status changed. A completed task's result comes from `tasks/result`. A task in
495
+ * `input_required` is cancelled and fails, because its input requests need a live connection.
496
+ * @param poll - The call's record from `PollToolTask`.
497
+ * @returns The tool output, or `tool_task_pending` with the next poll interval, which backs off from the
498
+ * server's `pollInterval` to at most a minute.
499
+ * @throws {ToolExecutionError} When the task failed, was cancelled, returned an `isError` result, or asked
500
+ * for input.
501
+ * @throws {McpToolCallInterruptedError} When the server could not be reached, no longer knows the task, or
502
+ * answered a poll with an error. The task may still run, so `PollToolTask` retries and then records the
503
+ * call by its effect.
504
+ */
505
+ async pollToolTask(poll) {
506
+ const record = poll.toolCall;
507
+ const taskId = record.taskId ?? null;
508
+ if (taskId === null) {
509
+ throw new TypeError(`tool call ${record.toolCallId} has no MCP task`);
510
+ }
511
+ const target = await this.resolveTarget(record.toolName, record.toolSourceReference);
512
+ const call = new McpToolCall(this.connector, this.callLimits(false), target, {}, {
513
+ toolCallId: record.toolCallId,
514
+ events: poll.events,
515
+ heartbeat: () => poll.heartbeat(),
516
+ signal: poll.cancellationSignal,
517
+ });
518
+ const outcome = await call.pollTaskOnce(taskId, record.taskPollIntervalSeconds ?? null);
519
+ switch (outcome.kind) {
520
+ case "succeeded":
521
+ return { output: outcome.output };
522
+ case "failed":
523
+ throw new ToolExecutionError(outcome.message);
524
+ case "task_pending":
525
+ return { kind: "tool_task_pending", pollIntervalSeconds: outcome.pollIntervalSeconds };
526
+ case "interrupted":
527
+ case "server_unreachable":
528
+ case "task_lost":
529
+ throw new McpToolCallInterruptedError(target.server.name, target.mcpToolName, `polling MCP task ${taskId}: ${outcome.detail}`);
530
+ case "task_started":
531
+ throw new McpToolCallInterruptedError(target.server.name, target.mcpToolName, `MCP task ${taskId} restarted`);
532
+ }
533
+ }
534
+ /**
535
+ * Sends `tasks/cancel` for one MCP task that `cancel_turn` abandoned.
536
+ *
537
+ * Call it from `AgentFrameworkRunner.cancelToolTask`. Cancellation is best-effort: the server may finish the
538
+ * task anyway, and the session drops a result that arrives later. Any answer from the server ends the
539
+ * cancellation, including an error for a task that already finished or that the server no longer knows, so a
540
+ * repeated call is harmless.
541
+ * @param cancellation - The abandoned call's record from `CancelToolTask`.
542
+ * @returns A Promise that resolves once the server answered.
543
+ * @throws {McpToolCallInterruptedError} When the server could not be reached or did not answer;
544
+ * `CancelToolTask` retries, then records the call `outcome_unknown` anyway.
545
+ */
546
+ async cancelToolTask(cancellation) {
547
+ const record = cancellation.toolCall;
548
+ const taskId = record.taskId ?? null;
549
+ if (taskId === null) {
550
+ throw new TypeError(`tool call ${record.toolCallId} has no MCP task`);
551
+ }
552
+ const target = await this.resolveTarget(record.toolName, record.toolSourceReference);
553
+ const call = new McpToolCall(this.connector, this.callLimits(false), target, {}, {
554
+ toolCallId: record.toolCallId,
555
+ heartbeat: () => cancellation.heartbeat(),
556
+ signal: cancellation.cancellationSignal,
557
+ });
558
+ const outcome = await call.cancelTask(taskId);
559
+ if (outcome.kind !== "succeeded") {
560
+ const detail = "detail" in outcome ? outcome.detail : `unexpected ${outcome.kind}`;
561
+ throw new McpToolCallInterruptedError(target.server.name, target.mcpToolName, `cancelling MCP task ${taskId}: ${detail}`);
562
+ }
563
+ }
564
+ /**
565
+ * Calls one listed MCP tool once, as a tool Step does.
566
+ *
567
+ * Use it from a framework tool that wraps an MCP tool, when the adapter gives the tool only the
568
+ * idempotency key. It finds the tool's server through the per-server listing. Call it only inside a
569
+ * tool Step: the session's retry rules for the effect assume it.
570
+ * @param toolName - {@link McpTool.name}.
571
+ * @param toolArguments - Tool arguments.
572
+ * @param options - Idempotency key, events, heartbeat, checkpoints, and abort signal.
573
+ * @returns The tool output the model sees. A task is polled inside the call, for at most
574
+ * `taskWaitTimeoutMs`.
575
+ * @throws {ToolExecutionError} When the tool failed, the server answered with an error or asked for
576
+ * input, or the call was never sent.
577
+ * @throws {McpToolCallInterruptedError} When the call was sent and no result came back.
578
+ */
579
+ async callTool(toolName, toolArguments, options = {}) {
580
+ if (this.isResourceAndPromptTool(toolName)) {
581
+ return this.runResourceAndPromptTool(toolName, toolArguments, options.signal);
582
+ }
583
+ const result = await this.callTarget(await this.targetFromListing(toolName), { ...toolArguments }, options, false);
584
+ if ("kind" in result) {
585
+ throw new Error(`MCP tool ${toolName} returned a task to callTool`);
586
+ }
587
+ return result;
588
+ }
589
+ isResourceAndPromptTool(toolName) {
590
+ return this.resourceAndPromptTools.length > 0 && resourceAndPromptToolNames.has(toolName);
591
+ }
592
+ async runResourceAndPromptTool(toolName, toolArguments, signal) {
593
+ const serverName = readServerName(toolArguments);
594
+ const server = this.servers.get(serverName);
595
+ if (server === undefined) {
596
+ throw new ToolExecutionError(`MCP server ${serverName} is not configured`);
597
+ }
598
+ let detail = "no connection attempt";
599
+ for (let connectionAttempt = 1; connectionAttempt <= this.connectionAttempts; connectionAttempt += 1) {
600
+ let connection;
601
+ try {
602
+ connection = await this.connector.connect(server);
603
+ const output = await runResourceAndPromptTool(connection.client, toolName, toolArguments, {
604
+ timeout: this.connectionTimeoutMs,
605
+ ...(signal === undefined ? {} : { signal }),
606
+ });
607
+ return { output };
608
+ }
609
+ catch (error) {
610
+ if (error instanceof ToolExecutionError) {
611
+ throw error;
612
+ }
613
+ detail = describeError(error);
614
+ }
615
+ finally {
616
+ await this.connector.disconnectQuietly(connection);
617
+ }
618
+ if (connectionAttempt < this.connectionAttempts) {
619
+ await delay(connectionRetryDelayMs * connectionAttempt);
620
+ }
621
+ }
622
+ throw new ToolExecutionError(`could not read from MCP server ${serverName}, so ${toolName} did not finish: ${detail}`);
623
+ }
624
+ callLimits(isTaskPolledBySession) {
625
+ return {
626
+ requestTimeoutMs: this.requestTimeoutMs,
627
+ taskWaitTimeoutMs: this.taskWaitTimeoutMs,
628
+ taskTtlMs: this.taskTtlMs,
629
+ connectionAttempts: this.connectionAttempts,
630
+ isTaskPolledBySession,
631
+ };
632
+ }
633
+ async resolveTarget(toolName, toolSourceReference) {
634
+ return this.targetFromReference(toolName, toolSourceReference) ?? (await this.targetFromListing(toolName));
635
+ }
636
+ async callTarget(target, toolArguments, options, isTaskPolledBySession) {
637
+ const call = new McpToolCall(this.connector, this.callLimits(isTaskPolledBySession), target, toolArguments, options);
638
+ const outcome = await call.run(savedTaskFromCheckpoint(options.heartbeatCheckpoint, target)?.taskId);
639
+ switch (outcome.kind) {
640
+ case "succeeded":
641
+ return { output: outcome.output };
642
+ case "task_started":
643
+ return { kind: "tool_task_started", taskId: outcome.taskId, pollIntervalSeconds: outcome.pollIntervalSeconds };
644
+ case "failed":
645
+ throw new ToolExecutionError(outcome.message);
646
+ case "interrupted":
647
+ case "task_lost":
648
+ case "task_pending":
649
+ throw new McpToolCallInterruptedError(target.server.name, target.mcpToolName, outcome.kind === "task_pending" ? "the MCP task is still running" : outcome.detail);
650
+ case "server_unreachable":
651
+ throw new ToolExecutionError(`could not connect to MCP server ${target.server.name}, so ${target.mcpToolName} did not run: ${outcome.detail}`);
652
+ }
653
+ }
654
+ targetFromReference(toolName, toolSourceReference) {
655
+ const reference = parseToolSourceReference(toolSourceReference);
656
+ if (reference === undefined) {
657
+ return undefined;
658
+ }
659
+ const server = this.servers.get(reference.serverName);
660
+ if (server === undefined) {
661
+ throw new ToolExecutionError(`MCP server ${reference.serverName} is no longer configured, so ${reference.mcpToolName} did not run`);
662
+ }
663
+ return { server, mcpToolName: reference.mcpToolName, toolName, taskSupport: reference.taskSupport };
664
+ }
665
+ async targetFromListing(toolName) {
666
+ let tool;
667
+ try {
668
+ tool = (await this.listTools()).find((listed) => listed.name === toolName);
669
+ if (tool === undefined) {
670
+ tool = (await this.listTools({ refresh: true })).find((listed) => listed.name === toolName);
671
+ }
672
+ }
673
+ catch (error) {
674
+ if (error instanceof McpToolListingError) {
675
+ throw new ToolExecutionError(`${error.message}, so ${toolName} did not run`, { cause: error });
676
+ }
677
+ throw error;
678
+ }
679
+ const server = tool === undefined ? undefined : this.servers.get(tool.serverName);
680
+ if (tool === undefined || server === undefined) {
681
+ throw new ToolExecutionError(`no listed MCP server offers the tool ${toolName}`);
682
+ }
683
+ return { server, mcpToolName: tool.mcpToolName, toolName: tool.name, taskSupport: tool.taskSupport };
684
+ }
685
+ async refreshListing(server, listing) {
686
+ try {
687
+ listing.tools = await this.listServerTools(server);
688
+ listing.listedAtMs = Date.now();
689
+ listing.failure = undefined;
690
+ listing.failedUntilMs = 0;
691
+ this.tools = this.combineServerTools();
692
+ }
693
+ catch (error) {
694
+ // A background listing must never reject: nobody may be waiting for it.
695
+ const failure = error instanceof McpToolListingError ? error : new McpToolListingError(server.name, describeError(error));
696
+ listing.failure = failure;
697
+ listing.failedUntilMs = Date.now() + this.listingFailureBackoffMs;
698
+ console.warn(`left MCP server ${server.name} out of the tool listing: ${failure.message}`);
699
+ }
700
+ }
701
+ combineServerTools() {
702
+ const tools = new Map();
703
+ for (const serverName of this.servers.keys()) {
704
+ for (const tool of this.listings.get(serverName)?.tools ?? []) {
705
+ const existing = tools.get(tool.name);
706
+ if (existing !== undefined) {
707
+ console.warn(`left MCP tool ${tool.serverName}/${tool.mcpToolName} out: ${existing.serverName}/${existing.mcpToolName} already has the agent tool name ${tool.name}`);
708
+ continue;
709
+ }
710
+ tools.set(tool.name, tool);
711
+ }
712
+ }
713
+ for (const tool of this.resourceAndPromptTools) {
714
+ tools.set(tool.name, tool);
715
+ }
716
+ return tools;
717
+ }
718
+ async listServerTools(server) {
719
+ let detail = "no listing attempt";
720
+ for (let listingAttempt = 1; listingAttempt <= this.connectionAttempts; listingAttempt += 1) {
721
+ let connection;
722
+ try {
723
+ connection = await this.connector.connect(server);
724
+ const listed = [];
725
+ let cursor;
726
+ for (let page = 0; page < toolListingPageLimit; page += 1) {
727
+ const result = await connection.client.listTools(cursor === undefined ? {} : { cursor }, {
728
+ timeout: this.connectionTimeoutMs,
729
+ });
730
+ listed.push(...result.tools);
731
+ cursor = result.nextCursor;
732
+ if (cursor === undefined || cursor === "") {
733
+ break;
734
+ }
735
+ }
736
+ return listed.map((tool) => mcpToolFromListing(server, tool));
737
+ }
738
+ catch (error) {
739
+ detail = describeError(error);
740
+ }
741
+ finally {
742
+ await this.connector.disconnectQuietly(connection);
743
+ }
744
+ if (listingAttempt < this.connectionAttempts) {
745
+ await delay(connectionRetryDelayMs * listingAttempt);
746
+ }
747
+ }
748
+ throw new McpToolListingError(server.name, detail);
749
+ }
750
+ }
751
+ /** Opens one connection per request with a bounded handshake, and ends it. */
752
+ class McpConnector {
753
+ clientInfo;
754
+ connectionTimeoutMs;
755
+ constructor(clientInfo, connectionTimeoutMs) {
756
+ this.clientInfo = clientInfo;
757
+ this.connectionTimeoutMs = connectionTimeoutMs;
758
+ }
759
+ async connect(server) {
760
+ const client = new Client(this.clientInfo, { capabilities: {} });
761
+ let transport;
762
+ if (server.transport === "stdio") {
763
+ transport = new StdioClientTransport({
764
+ command: server.command,
765
+ args: [...(server.args ?? [])],
766
+ ...(server.env === undefined ? {} : { env: { ...server.env } }),
767
+ ...(server.cwd === undefined ? {} : { cwd: server.cwd }),
768
+ stderr: "inherit",
769
+ });
770
+ }
771
+ else {
772
+ transport = new StreamableHTTPClientTransport(new URL(server.url), {
773
+ requestInit: { headers: { ...(server.headers ?? {}) } },
774
+ });
775
+ }
776
+ const connection = { client, transport };
777
+ try {
778
+ await client.connect(transport, { timeout: this.connectionTimeoutMs });
779
+ }
780
+ catch (error) {
781
+ await this.disconnectQuietly(connection);
782
+ throw error;
783
+ }
784
+ return connection;
785
+ }
786
+ /** Ends the HTTP protocol session, then closes the connection; failures are logged. */
787
+ async disconnectQuietly(connection) {
788
+ if (connection === undefined) {
789
+ return;
790
+ }
791
+ const { client, transport } = connection;
792
+ try {
793
+ if (transport instanceof StreamableHTTPClientTransport && transport.sessionId !== undefined) {
794
+ await Promise.race([
795
+ transport.terminateSession(),
796
+ delay(sessionTerminationTimeoutMs).then(() => {
797
+ throw new Error(`no answer to the session DELETE within ${sessionTerminationTimeoutMs} ms`);
798
+ }),
799
+ ]);
800
+ }
801
+ }
802
+ catch (error) {
803
+ // The server times idle sessions out; the outcome is already known.
804
+ console.warn(`ending an MCP session failed: ${describeError(error)}`);
805
+ }
806
+ try {
807
+ await client.close();
808
+ }
809
+ catch (error) {
810
+ // The outcome is already known; a failed close only leaks this one connection.
811
+ console.warn(`closing an MCP connection failed: ${describeError(error)}`);
812
+ }
813
+ }
814
+ }
815
+ /** One tool call: connection attempts, the request, and task polling. */
816
+ class McpToolCall {
817
+ connector;
818
+ limits;
819
+ target;
820
+ toolArguments;
821
+ options;
822
+ reporter;
823
+ constructor(connector, limits, target, toolArguments, options) {
824
+ this.connector = connector;
825
+ this.limits = limits;
826
+ this.target = target;
827
+ this.toolArguments = toolArguments;
828
+ this.options = options;
829
+ this.reporter = new McpToolCallReporter(target.toolName, options.toolCallId, options.events);
830
+ }
831
+ async run(resumedTaskId) {
832
+ if (resumedTaskId !== undefined) {
833
+ const outcome = await this.withConnection(({ client }) => this.awaitTask(client, resumedTaskId, undefined));
834
+ if (outcome.kind === "server_unreachable") {
835
+ // An earlier attempt started the task, so it may be running.
836
+ return {
837
+ kind: "interrupted",
838
+ detail: `could not reach the server to poll MCP task ${resumedTaskId}: ${outcome.detail}`,
839
+ };
840
+ }
841
+ if (outcome.kind !== "task_lost") {
842
+ return outcome;
843
+ }
844
+ // Only CallTool retries an attempt, and it may send the call again.
845
+ console.warn(`calling MCP tool ${this.target.mcpToolName} again: ${outcome.detail}`);
846
+ }
847
+ return this.withConnection((connection) => this.sendToolCall(connection));
848
+ }
849
+ /** Sends `tasks/cancel` for one task, for `CancelToolTask`; a server's error answer counts as done. */
850
+ cancelTask(taskId) {
851
+ return this.withConnection(async ({ client }) => {
852
+ try {
853
+ await client.experimental.tasks.cancelTask(taskId, { timeout: taskCancelTimeoutMs });
854
+ }
855
+ catch (error) {
856
+ if (!(error instanceof McpError) || noResultErrorCodes.has(error.code)) {
857
+ return { kind: "interrupted", detail: `tasks/cancel got no answer: ${describeError(error)}` };
858
+ }
859
+ // The server answered, as for a finished or unknown task; asking again cannot help.
860
+ }
861
+ return { kind: "succeeded", output: "" };
862
+ });
863
+ }
864
+ /** Reads one task once, for `PollToolTask`. */
865
+ pollTaskOnce(taskId, previousIntervalSeconds) {
866
+ return this.withConnection(async ({ client }) => {
867
+ let task;
868
+ try {
869
+ task = await client.experimental.tasks.getTask(taskId, { timeout: this.limits.requestTimeoutMs });
870
+ }
871
+ catch (error) {
872
+ // The task may still run, so a failed poll leaves the outcome unknown.
873
+ return { kind: "interrupted", detail: `tasks/get for MCP task ${taskId} failed: ${describeError(error)}` };
874
+ }
875
+ // A working task was working at the last poll too; only a change is worth an event.
876
+ if (task.status !== "working") {
877
+ this.reporter.writeTaskStatus(task);
878
+ }
879
+ return ((await this.decideTaskOutcome(client, taskId, task)) ?? {
880
+ kind: "task_pending",
881
+ pollIntervalSeconds: durableTaskPollIntervalSeconds(task, previousIntervalSeconds),
882
+ });
883
+ });
884
+ }
885
+ async withConnection(operation) {
886
+ const { connectionAttempts } = this.limits;
887
+ let outcome = { kind: "server_unreachable", detail: "no connection attempt" };
888
+ for (let connectionAttempt = 1; connectionAttempt <= connectionAttempts; connectionAttempt += 1) {
889
+ outcome = await this.runOnNewConnection(operation);
890
+ if (outcome.kind !== "server_unreachable") {
891
+ return outcome;
892
+ }
893
+ if (connectionAttempt < connectionAttempts) {
894
+ await delay(connectionRetryDelayMs * connectionAttempt);
895
+ }
896
+ }
897
+ return outcome;
898
+ }
899
+ async runOnNewConnection(operation) {
900
+ let connection;
901
+ try {
902
+ connection = await this.connector.connect(this.target.server);
903
+ }
904
+ catch (error) {
905
+ return { kind: "server_unreachable", detail: describeError(error) };
906
+ }
907
+ try {
908
+ return await operation(connection);
909
+ }
910
+ catch (error) {
911
+ return { kind: "interrupted", detail: `the connection broke before a result arrived: ${describeError(error)}` };
912
+ }
913
+ finally {
914
+ await this.connector.disconnectQuietly(connection);
915
+ }
916
+ }
917
+ async sendToolCall({ client, transport }) {
918
+ const meta = this.options.idempotencyKey === undefined ? undefined : { [mcpIdempotencyKeyMetaKey]: this.options.idempotencyKey };
919
+ const params = { name: this.target.mcpToolName, arguments: this.toolArguments, ...(meta === undefined ? {} : { _meta: meta }) };
920
+ const requestOptions = {
921
+ timeout: this.limits.requestTimeoutMs,
922
+ onprogress: (progress) => this.reporter.writeProgress(progress),
923
+ ...(this.options.signal === undefined ? {} : { signal: this.options.signal }),
924
+ };
925
+ const isTaskCall = this.target.taskSupport === "required" && client.getServerCapabilities()?.tasks?.requests?.tools?.call !== undefined;
926
+ // A stdio task lives in its process, and a stateful server binds its tasks to the session this request ends.
927
+ const isTaskPolledBySession = this.limits.isTaskPolledBySession &&
928
+ transport instanceof StreamableHTTPClientTransport &&
929
+ transport.sessionId === undefined;
930
+ let createdTask;
931
+ try {
932
+ if (!isTaskCall) {
933
+ const response = await client.callTool(params, CallToolResultSchema, requestOptions);
934
+ if (!("content" in response)) {
935
+ // A pre-2024-11-05 server answers with a bare toolResult.
936
+ return { kind: "succeeded", output: JSON.stringify(response.toolResult) };
937
+ }
938
+ return outcomeFromToolResult(response);
939
+ }
940
+ createdTask = (await client.request({ method: "tools/call", params }, CreateTaskResultSchema, {
941
+ ...requestOptions,
942
+ task: {
943
+ ttl: isTaskPolledBySession ? this.limits.taskTtlMs : this.limits.taskWaitTimeoutMs * taskTtlWaitMultiple,
944
+ },
945
+ })).task;
946
+ }
947
+ catch (error) {
948
+ if (error instanceof McpError && !noResultErrorCodes.has(error.code)) {
949
+ return { kind: "failed", message: error.message };
950
+ }
951
+ throw error;
952
+ }
953
+ if (this.options.saveHeartbeatCheckpoint !== undefined && this.target.server.transport === "streamable_http") {
954
+ // A stdio server's tasks end with its process, so only HTTP tasks resume.
955
+ await this.options.saveHeartbeatCheckpoint(taskCheckpoint(this.target, createdTask.taskId, isTaskPolledBySession));
956
+ }
957
+ if (isTaskPolledBySession) {
958
+ this.reporter.writeTaskStatus(createdTask);
959
+ return {
960
+ kind: "task_started",
961
+ taskId: createdTask.taskId,
962
+ pollIntervalSeconds: durableTaskPollIntervalSeconds(createdTask, null),
963
+ };
964
+ }
965
+ // The task exists and may run on, so a polling failure propagates as an unknown outcome.
966
+ return this.awaitTask(client, createdTask.taskId, createdTask);
967
+ }
968
+ async awaitTask(client, taskId, created) {
969
+ const { requestTimeoutMs, taskWaitTimeoutMs } = this.limits;
970
+ const deadline = Date.now() + taskWaitTimeoutMs;
971
+ let task = created;
972
+ let reportedStatus;
973
+ for (;;) {
974
+ if (task !== undefined) {
975
+ if (task.status !== reportedStatus) {
976
+ this.reporter.writeTaskStatus(task);
977
+ reportedStatus = task.status;
978
+ }
979
+ const outcome = await this.decideTaskOutcome(client, taskId, task);
980
+ if (outcome !== undefined) {
981
+ return outcome;
982
+ }
983
+ const remainingMs = deadline - Date.now();
984
+ if (remainingMs <= 0) {
985
+ return {
986
+ kind: "interrupted",
987
+ detail: `MCP task ${taskId} did not finish within ${taskWaitTimeoutMs} ms in this attempt; it keeps running, and a retried attempt resumes polling it`,
988
+ };
989
+ }
990
+ await this.options.heartbeat?.();
991
+ await delay(Math.min(taskPollIntervalMs(task), remainingMs));
992
+ }
993
+ try {
994
+ task = await client.experimental.tasks.getTask(taskId, { timeout: requestTimeoutMs });
995
+ }
996
+ catch (error) {
997
+ if (created === undefined && error instanceof McpError && !noResultErrorCodes.has(error.code)) {
998
+ return { kind: "task_lost", detail: `tasks/get for MCP task ${taskId} failed: ${error.message}` };
999
+ }
1000
+ throw error;
1001
+ }
1002
+ }
1003
+ }
1004
+ /** Maps a terminal or `input_required` task; returns `undefined` while it works. */
1005
+ async decideTaskOutcome(client, taskId, task) {
1006
+ if (task.status === "completed") {
1007
+ const result = await client.experimental.tasks.getTaskResult(taskId, CallToolResultSchema, {
1008
+ timeout: this.limits.requestTimeoutMs,
1009
+ });
1010
+ return outcomeFromToolResult(result);
1011
+ }
1012
+ if (task.status === "failed") {
1013
+ return { kind: "failed", message: `MCP task ${taskId} failed: ${task.statusMessage ?? "no error detail"}` };
1014
+ }
1015
+ if (task.status === "cancelled") {
1016
+ const reason = task.statusMessage === undefined ? "" : `: ${task.statusMessage}`;
1017
+ return { kind: "failed", message: `MCP task ${taskId} was cancelled${reason}` };
1018
+ }
1019
+ if (task.status === "input_required") {
1020
+ await cancelTaskQuietly(client, taskId);
1021
+ const detail = task.statusMessage === undefined ? "" : ` (${task.statusMessage})`;
1022
+ return {
1023
+ kind: "failed",
1024
+ message: `the MCP server asked for input that this agent session cannot provide yet${detail}, so the tool did not finish`,
1025
+ };
1026
+ }
1027
+ return undefined;
1028
+ }
1029
+ }
1030
+ class McpToolCallReporter {
1031
+ toolName;
1032
+ toolCallId;
1033
+ events;
1034
+ constructor(toolName, toolCallId, events) {
1035
+ this.toolName = toolName;
1036
+ this.toolCallId = toolCallId;
1037
+ this.events = events;
1038
+ }
1039
+ writeProgress(progress) {
1040
+ this.events?.writeEvent(mcpToolProgressEventType, {
1041
+ tool_call_id: this.toolCallId ?? null,
1042
+ tool_name: this.toolName,
1043
+ progress: progress.progress,
1044
+ total: progress.total ?? null,
1045
+ message: progress.message ?? null,
1046
+ });
1047
+ }
1048
+ writeTaskStatus(task) {
1049
+ this.events?.writeEvent(mcpTaskStatusEventType, {
1050
+ tool_call_id: this.toolCallId ?? null,
1051
+ tool_name: this.toolName,
1052
+ task_id: task.taskId,
1053
+ status: task.status,
1054
+ status_message: task.statusMessage ?? null,
1055
+ });
1056
+ }
1057
+ }
1058
+ function mcpToolFromListing(server, tool) {
1059
+ const annotations = tool.annotations === undefined
1060
+ ? undefined
1061
+ : {
1062
+ ...(tool.annotations.readOnlyHint === undefined ? {} : { readOnlyHint: tool.annotations.readOnlyHint }),
1063
+ ...(tool.annotations.idempotentHint === undefined ? {} : { idempotentHint: tool.annotations.idempotentHint }),
1064
+ ...(tool.annotations.destructiveHint === undefined ? {} : { destructiveHint: tool.annotations.destructiveHint }),
1065
+ };
1066
+ const trustsAnnotations = server.trustsToolAnnotations ?? true;
1067
+ return {
1068
+ name: buildAgentToolName(server.name, tool.name),
1069
+ serverName: server.name,
1070
+ mcpToolName: tool.name,
1071
+ description: tool.description ?? tool.title ?? `MCP tool ${tool.name} of ${server.name}`,
1072
+ inputSchema: tool.inputSchema,
1073
+ annotations,
1074
+ frameworkEffect: trustsAnnotations && annotations !== undefined ? toolEffectFromMcpToolAnnotations(annotations) : undefined,
1075
+ taskSupport: tool.execution?.taskSupport,
1076
+ };
1077
+ }
1078
+ function buildAgentToolName(serverName, mcpToolName) {
1079
+ const candidate = `${serverName}__${mcpToolName}`.replace(/[^A-Za-z0-9_-]/g, "_");
1080
+ if (candidate.length <= toolNameLengthLimit) {
1081
+ return candidate;
1082
+ }
1083
+ const digest = createHash("sha256").update(candidate).digest("hex").slice(0, 8);
1084
+ return `${candidate.slice(0, 55)}_${digest}`;
1085
+ }
1086
+ function parseToolArguments(argumentsJson) {
1087
+ let toolArguments;
1088
+ try {
1089
+ toolArguments = JSON.parse(argumentsJson);
1090
+ }
1091
+ catch (error) {
1092
+ throw new ToolExecutionError(`the tool arguments are not valid JSON: ${describeError(error)}`, { cause: error });
1093
+ }
1094
+ if (toolArguments === null || typeof toolArguments !== "object" || Array.isArray(toolArguments)) {
1095
+ throw new ToolExecutionError("the tool arguments must be a JSON object");
1096
+ }
1097
+ return toolArguments;
1098
+ }
1099
+ function parseJsonObject(text) {
1100
+ if (text === undefined) {
1101
+ return undefined;
1102
+ }
1103
+ try {
1104
+ const value = JSON.parse(text);
1105
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined;
1106
+ }
1107
+ catch {
1108
+ // Text from another tool source, not an MCP reference.
1109
+ return undefined;
1110
+ }
1111
+ }
1112
+ function parseToolSourceReference(toolSourceReference) {
1113
+ const reference = parseJsonObject(toolSourceReference);
1114
+ const serverName = reference?.["mcp_server"];
1115
+ const mcpToolName = reference?.["mcp_tool"];
1116
+ if (typeof serverName !== "string" || typeof mcpToolName !== "string") {
1117
+ return undefined;
1118
+ }
1119
+ const taskSupport = reference?.["task_support"];
1120
+ return {
1121
+ serverName,
1122
+ mcpToolName,
1123
+ taskSupport: taskSupport === "required" || taskSupport === "optional" || taskSupport === "forbidden" ? taskSupport : undefined,
1124
+ };
1125
+ }
1126
+ function taskCheckpoint(target, taskId, isPolledBySession) {
1127
+ return JSON.stringify({
1128
+ mcp_server: target.server.name,
1129
+ mcp_tool: target.mcpToolName,
1130
+ mcp_task_id: taskId,
1131
+ ...(isPolledBySession ? { mcp_task_polled_by_session: true } : {}),
1132
+ });
1133
+ }
1134
+ /** Reads a task this source saved; a checkpoint another writer saved reads as none. */
1135
+ function savedTaskFromCheckpoint(checkpoint, target) {
1136
+ const saved = parseJsonObject(checkpoint);
1137
+ if (saved?.["mcp_server"] !== target.server.name || saved["mcp_tool"] !== target.mcpToolName) {
1138
+ return undefined;
1139
+ }
1140
+ const taskId = saved["mcp_task_id"];
1141
+ return typeof taskId === "string"
1142
+ ? { taskId, isPolledBySession: saved["mcp_task_polled_by_session"] === true }
1143
+ : undefined;
1144
+ }
1145
+ function outcomeFromToolResult(result) {
1146
+ const text = renderToolResultText(result);
1147
+ if (result.isError === true) {
1148
+ return { kind: "failed", message: text === "" ? "the MCP tool reported an error" : text };
1149
+ }
1150
+ return { kind: "succeeded", output: text };
1151
+ }
1152
+ /** Seconds until the next durable poll: the server's hint, backed off from the last interval to a minute. */
1153
+ function durableTaskPollIntervalSeconds(task, previousSeconds) {
1154
+ const hintSeconds = task.pollInterval === undefined ? 1 : Math.ceil(task.pollInterval / 1_000);
1155
+ const backoffSeconds = previousSeconds === null ? 1 : Math.min(previousSeconds * 2, durableTaskPollBackoffCapSeconds);
1156
+ return Math.min(Math.max(1, hintSeconds, backoffSeconds), maximumDurableTaskPollIntervalSeconds);
1157
+ }
1158
+ function taskPollIntervalMs(task) {
1159
+ if (task.pollInterval === undefined) {
1160
+ return defaultTaskPollIntervalMs;
1161
+ }
1162
+ return Math.min(Math.max(task.pollInterval, minimumTaskPollIntervalMs), maximumTaskPollIntervalMs);
1163
+ }
1164
+ async function cancelTaskQuietly(client, taskId) {
1165
+ try {
1166
+ await client.experimental.tasks.cancelTask(taskId, { timeout: taskCancelTimeoutMs });
1167
+ }
1168
+ catch (error) {
1169
+ // Cancellation is cooperative; the tool's outcome is already reported either way.
1170
+ console.warn(`tasks/cancel for MCP task ${taskId} failed: ${describeError(error)}`);
1171
+ }
1172
+ }
1173
+ function describeError(error) {
1174
+ if (error instanceof AggregateError) {
1175
+ return error.errors.map(describeError).join("; ");
1176
+ }
1177
+ if (error instanceof Error) {
1178
+ const cause = error.cause instanceof Error ? ` (${error.cause.message})` : "";
1179
+ return `${error.name}: ${error.message}${cause}`;
1180
+ }
1181
+ return String(error);
1182
+ }
1183
+ function dexAgentsVersion() {
1184
+ try {
1185
+ const manifest = createRequire(import.meta.url)("../../../package.json");
1186
+ return typeof manifest.version === "string" ? manifest.version : "0";
1187
+ }
1188
+ catch {
1189
+ // A bundled copy has no package.json beside it; the version is informational.
1190
+ return "0";
1191
+ }
1192
+ }
1193
+ function isMcpTool(tool) {
1194
+ return tool !== undefined && "serverName" in tool && "mcpToolName" in tool && "frameworkEffect" in tool;
1195
+ }
1196
+ /** Joins text blocks; other content blocks and structured content render as `serializeToolOutputJson` text. */
1197
+ export function renderToolResultText(result) {
1198
+ const parts = result.content.map((block) => (block.type === "text" ? block.text : serializeToolOutputJson(block)));
1199
+ if (parts.length === 0 && result.structuredContent !== undefined) {
1200
+ parts.push(serializeToolOutputJson(result.structuredContent));
1201
+ }
1202
+ return parts.join("\n");
1203
+ }
1204
+ //# sourceMappingURL=mcp-tool-source.js.map