@zackbart/connecta 0.8.0 → 0.9.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 (151) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/README.md +31 -55
  3. package/SECURITY.md +5 -11
  4. package/dist/activity.d.ts +0 -8
  5. package/dist/activity.d.ts.map +1 -1
  6. package/dist/activity.js +0 -1
  7. package/dist/activity.js.map +1 -1
  8. package/dist/auth/bearer.d.ts +1 -7
  9. package/dist/auth/bearer.d.ts.map +1 -1
  10. package/dist/auth/bearer.js +2 -23
  11. package/dist/auth/bearer.js.map +1 -1
  12. package/dist/auth/clerk.d.ts +2 -11
  13. package/dist/auth/clerk.d.ts.map +1 -1
  14. package/dist/auth/clerk.js +31 -46
  15. package/dist/auth/clerk.js.map +1 -1
  16. package/dist/auth/downstream-oauth.d.ts +15 -6
  17. package/dist/auth/downstream-oauth.d.ts.map +1 -1
  18. package/dist/auth/downstream-oauth.js +60 -11
  19. package/dist/auth/downstream-oauth.js.map +1 -1
  20. package/dist/call-admission.d.ts.map +1 -1
  21. package/dist/call-admission.js +0 -1
  22. package/dist/call-admission.js.map +1 -1
  23. package/dist/catalog-service.d.ts +1 -2
  24. package/dist/catalog-service.d.ts.map +1 -1
  25. package/dist/catalog-service.js +12 -18
  26. package/dist/catalog-service.js.map +1 -1
  27. package/dist/catalog.d.ts +6 -0
  28. package/dist/catalog.d.ts.map +1 -1
  29. package/dist/catalog.js +51 -0
  30. package/dist/catalog.js.map +1 -1
  31. package/dist/connectors/remote-mcp.d.ts +1 -1
  32. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  33. package/dist/connectors/remote-mcp.js +73 -71
  34. package/dist/connectors/remote-mcp.js.map +1 -1
  35. package/dist/errors.d.ts +19 -1
  36. package/dist/errors.d.ts.map +1 -1
  37. package/dist/errors.js +1 -0
  38. package/dist/errors.js.map +1 -1
  39. package/dist/execute.d.ts +4 -9
  40. package/dist/execute.d.ts.map +1 -1
  41. package/dist/execute.js +41 -20
  42. package/dist/execute.js.map +1 -1
  43. package/dist/executor-result.js +1 -1
  44. package/dist/executor-result.js.map +1 -1
  45. package/dist/index.d.ts +3 -82
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +17 -90
  48. package/dist/index.js.map +1 -1
  49. package/dist/invocation.d.ts +5 -0
  50. package/dist/invocation.d.ts.map +1 -1
  51. package/dist/invocation.js +46 -10
  52. package/dist/invocation.js.map +1 -1
  53. package/dist/meta-tools.d.ts +4 -12
  54. package/dist/meta-tools.d.ts.map +1 -1
  55. package/dist/meta-tools.js +119 -169
  56. package/dist/meta-tools.js.map +1 -1
  57. package/dist/operator-ui/generated.d.ts +2 -2
  58. package/dist/operator-ui/generated.d.ts.map +1 -1
  59. package/dist/operator-ui/generated.js +2 -2
  60. package/dist/operator-ui/generated.js.map +1 -1
  61. package/dist/operator-ui/model.d.ts +1 -22
  62. package/dist/operator-ui/model.d.ts.map +1 -1
  63. package/dist/operator-ui/model.js.map +1 -1
  64. package/dist/registry.d.ts +11 -178
  65. package/dist/registry.d.ts.map +1 -1
  66. package/dist/registry.js +52 -330
  67. package/dist/registry.js.map +1 -1
  68. package/dist/retired-toolkits.d.ts +3 -0
  69. package/dist/retired-toolkits.d.ts.map +1 -0
  70. package/dist/retired-toolkits.js +9 -0
  71. package/dist/retired-toolkits.js.map +1 -0
  72. package/dist/routes/activity.d.ts.map +1 -1
  73. package/dist/routes/activity.js +2 -5
  74. package/dist/routes/activity.js.map +1 -1
  75. package/dist/routes/credentials.d.ts.map +1 -1
  76. package/dist/routes/credentials.js +1 -12
  77. package/dist/routes/credentials.js.map +1 -1
  78. package/dist/routes/mcp.d.ts.map +1 -1
  79. package/dist/routes/mcp.js +94 -188
  80. package/dist/routes/mcp.js.map +1 -1
  81. package/dist/routes/oauth.d.ts.map +1 -1
  82. package/dist/routes/oauth.js +3 -8
  83. package/dist/routes/oauth.js.map +1 -1
  84. package/dist/routes/shared.d.ts +9 -30
  85. package/dist/routes/shared.d.ts.map +1 -1
  86. package/dist/routes/shared.js +10 -69
  87. package/dist/routes/shared.js.map +1 -1
  88. package/dist/routes/ui.d.ts.map +1 -1
  89. package/dist/routes/ui.js +4 -10
  90. package/dist/routes/ui.js.map +1 -1
  91. package/dist/server.d.ts.map +1 -1
  92. package/dist/server.js +1 -31
  93. package/dist/server.js.map +1 -1
  94. package/dist/skills.d.ts +3 -6
  95. package/dist/skills.d.ts.map +1 -1
  96. package/dist/skills.js +9 -38
  97. package/dist/skills.js.map +1 -1
  98. package/dist/timeout.d.ts.map +1 -1
  99. package/dist/timeout.js +2 -4
  100. package/dist/timeout.js.map +1 -1
  101. package/dist/types.d.ts +8 -61
  102. package/dist/types.d.ts.map +1 -1
  103. package/dist/ui.d.ts +1 -2
  104. package/dist/ui.d.ts.map +1 -1
  105. package/dist/ui.js +5 -35
  106. package/dist/ui.js.map +1 -1
  107. package/dist/version.d.ts +1 -1
  108. package/dist/version.js +1 -1
  109. package/package.json +3 -2
  110. package/src/activity.ts +0 -9
  111. package/src/auth/bearer.ts +3 -32
  112. package/src/auth/clerk.ts +35 -48
  113. package/src/auth/downstream-oauth.ts +106 -24
  114. package/src/call-admission.ts +0 -1
  115. package/src/catalog-service.ts +23 -23
  116. package/src/catalog.ts +55 -0
  117. package/src/connectors/remote-mcp.ts +106 -86
  118. package/src/errors.ts +22 -0
  119. package/src/execute.ts +43 -24
  120. package/src/executor-result.ts +1 -1
  121. package/src/index.ts +24 -208
  122. package/src/invocation.ts +63 -10
  123. package/src/meta-tools.ts +139 -187
  124. package/src/operator-ui/browser.css +1 -54
  125. package/src/operator-ui/browser.ts +16 -150
  126. package/src/operator-ui/generated.ts +2 -2
  127. package/src/operator-ui/model.ts +1 -23
  128. package/src/registry.ts +59 -429
  129. package/src/retired-toolkits.ts +14 -0
  130. package/src/routes/activity.ts +1 -6
  131. package/src/routes/credentials.ts +0 -12
  132. package/src/routes/mcp.ts +110 -230
  133. package/src/routes/oauth.ts +2 -8
  134. package/src/routes/shared.ts +10 -100
  135. package/src/routes/ui.ts +2 -12
  136. package/src/server.ts +1 -31
  137. package/src/skills.ts +9 -38
  138. package/src/timeout.ts +2 -4
  139. package/src/types.ts +11 -62
  140. package/src/ui.ts +9 -46
  141. package/src/version.ts +1 -1
  142. package/dist/credential-health.d.ts +0 -205
  143. package/dist/credential-health.d.ts.map +0 -1
  144. package/dist/credential-health.js +0 -610
  145. package/dist/credential-health.js.map +0 -1
  146. package/dist/toolkits.d.ts +0 -124
  147. package/dist/toolkits.d.ts.map +0 -1
  148. package/dist/toolkits.js +0 -322
  149. package/dist/toolkits.js.map +0 -1
  150. package/src/credential-health.ts +0 -835
  151. package/src/toolkits.ts +0 -461
@@ -1,13 +1,17 @@
1
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
- import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
3
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
1
+ import {
2
+ Client,
3
+ isInputRequiredResult,
4
+ specTypeSchemas,
5
+ StreamableHTTPClientTransport,
6
+ UnauthorizedError,
7
+ } from "@modelcontextprotocol/client";
4
8
  import type {
5
9
  FetchLike,
10
+ ListToolsResult,
11
+ StandardSchemaV1,
12
+ Tool,
6
13
  Transport,
7
- } from "@modelcontextprotocol/sdk/shared/transport.js";
8
- import { ListToolsResultSchema } from "@modelcontextprotocol/sdk/types.js";
9
- import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
10
- import { z } from "zod";
14
+ } from "@modelcontextprotocol/client";
11
15
  import { KvOAuthProvider } from "../auth/downstream-oauth.js";
12
16
  import { MAX_CATALOG_TOOLS } from "../catalog-limits.js";
13
17
  import { ConnectorCallError } from "../errors.js";
@@ -131,36 +135,29 @@ type ListedTool = Awaited<ReturnType<Client["listTools"]>>["tools"][number];
131
135
  * end-of-pagination as `null`. Only the cursor is widened; every tool and every
132
136
  * other result field still passes through the SDK's pinned schema.
133
137
  */
134
- const CompatibleListToolsResultSchema = ListToolsResultSchema.extend({
135
- nextCursor: z.string().nullable().optional(),
136
- });
137
-
138
- /**
139
- * Re-prime an SDK client's tool-metadata cache from the *full* walked catalog.
140
- *
141
- * The SDK's `Client.listTools()` caches one page at a time and **clears** the
142
- * output-schema validators and task-support sets before each replacement.
143
- * This walk uses `Client.request()` so it can make the narrow null-cursor
144
- * compatibility concession above, then primes the metadata exactly once from
145
- * the complete chain. Otherwise `callTool` would find no validator or task
146
- * requirement for earlier-page tools and enforcement would depend on where a
147
- * tool happened to land, which is not enforcement.
148
- *
149
- * So hand the whole aggregated list back deliberately, once, at the end. The
150
- * SDK types the method `private`, hence the cast; the SDK version is pinned
151
- * exactly and `test/remote-mcp-pagination.test.ts` asserts the method still
152
- * exists, so a bump that renames it fails CI rather than quietly restoring the
153
- * bug.
154
- */
155
- function primeToolMetadata(client: Client, tools: ListedTool[]): void {
156
- const prime = (
157
- client as unknown as {
158
- cacheToolMetadata?: (tools: ListedTool[]) => void;
159
- }
160
- ).cacheToolMetadata;
161
- if (typeof prime !== "function") return;
162
- prime.call(client, tools);
163
- }
138
+ const CompatibleListToolsResultSchema: StandardSchemaV1<
139
+ unknown,
140
+ ListToolsResult
141
+ > = {
142
+ "~standard": {
143
+ version: 1,
144
+ vendor: "connecta",
145
+ validate(value) {
146
+ const normalized =
147
+ typeof value === "object" &&
148
+ value !== null &&
149
+ "nextCursor" in value &&
150
+ value.nextCursor === null
151
+ ? (() => {
152
+ const copy = { ...value };
153
+ delete copy.nextCursor;
154
+ return copy;
155
+ })()
156
+ : value;
157
+ return specTypeSchemas.ListToolsResult["~standard"].validate(normalized);
158
+ },
159
+ },
160
+ };
164
161
 
165
162
  /**
166
163
  * True for a result-parse failure caused by the page's `nextCursor` itself.
@@ -171,13 +168,18 @@ function primeToolMetadata(client: Client, tools: ListedTool[]): void {
171
168
  */
172
169
  function isCursorShapeError(err: unknown): boolean {
173
170
  const issues = (err as { issues?: unknown } | null)?.issues;
174
- return (
171
+ if (
175
172
  Array.isArray(issues) &&
176
173
  issues.some((issue) => {
177
174
  const path = (issue as { path?: unknown }).path;
178
175
  return Array.isArray(path) && path[0] === "nextCursor";
179
176
  })
180
- );
177
+ ) {
178
+ return true;
179
+ }
180
+ // SDK v2 wraps Standard Schema failures in a ProtocolError and preserves the
181
+ // failing path in the message rather than exposing the validator's issues.
182
+ return msg(err).startsWith("Invalid result for tools/list: nextCursor:");
181
183
  }
182
184
 
183
185
  function msg(err: unknown): string {
@@ -190,8 +192,7 @@ function msg(err: unknown): string {
190
192
  * `Client.close()` only unwinds our side — it aborts the transport's controller
191
193
  * and fires `onclose`. Spec session termination is a separate DELETE carrying
192
194
  * `Mcp-Session-Id`, and without it a stateful provider keeps the session alive
193
- * until its own (often hour-long) timeout, which a periodic probe would then
194
- * accumulate several of per connector.
195
+ * until its own (often hour-long) timeout, accumulating abandoned sessions.
195
196
  *
196
197
  * Ordering is load-bearing: the SDK sends that DELETE on the transport's
197
198
  * AbortSignal, so calling this *after* close would abort the request on issue
@@ -394,6 +395,14 @@ export function redirectSafeFetch(
394
395
  interface ConnectionState {
395
396
  client: Client | null;
396
397
  transport: Transport | null;
398
+ /**
399
+ * The last complete raw catalog, retained only for this request scope.
400
+ *
401
+ * SDK v2 exposes `toolDefinition` as the public call-time seam for output
402
+ * validation and header mirroring, replacing the v1 private
403
+ * `cacheToolMetadata` reach-through.
404
+ */
405
+ toolDefinitions: Map<string, Tool>;
397
406
  connecting: Promise<void> | null;
398
407
  authRequired: boolean;
399
408
  provider: KvOAuthProvider | null;
@@ -468,6 +477,14 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
468
477
  const scopeEndedError = () =>
469
478
  new Error(`Connector "${id}" scope ended during connection.`);
470
479
 
480
+ const requestOptions = (ctx: ConnectorContext) =>
481
+ ctx.timeoutMs || ctx.signal
482
+ ? {
483
+ ...(ctx.timeoutMs ? { timeout: ctx.timeoutMs } : {}),
484
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
485
+ }
486
+ : undefined;
487
+
471
488
  /**
472
489
  * One `tools/list` request. The SDK schema is retained wholesale except for
473
490
  * accepting `null` as the common, unambiguous end-of-chain spelling. Other
@@ -485,12 +502,7 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
485
502
  ...(cursor === undefined ? {} : { params: { cursor } }),
486
503
  },
487
504
  CompatibleListToolsResultSchema,
488
- ctx.timeoutMs || ctx.signal
489
- ? {
490
- ...(ctx.timeoutMs ? { timeout: ctx.timeoutMs } : {}),
491
- ...(ctx.signal ? { signal: ctx.signal } : {}),
492
- }
493
- : undefined,
505
+ requestOptions(ctx),
494
506
  );
495
507
  } catch (err) {
496
508
  if (!isCursorShapeError(err)) throw err;
@@ -509,6 +521,7 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
509
521
  state = {
510
522
  client: null,
511
523
  transport: null,
524
+ toolDefinitions: new Map(),
512
525
  connecting: null,
513
526
  authRequired: false,
514
527
  provider: null,
@@ -547,29 +560,23 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
547
560
  const url = new URL(opts.url);
548
561
  const guardedFetch = redirectSafeFetch(id, opts.redirects);
549
562
  if (opts.auth?.type === "oauth") {
550
- // The SDK class declares `sessionId` as an own `string | undefined`
551
- // property while its Transport interface declares it optional. They are
552
- // runtime-compatible; exact optional types only exposes that declaration
553
- // mismatch at this boundary.
554
563
  return new StreamableHTTPClientTransport(url, {
555
564
  authProvider: provider ?? newProvider(ctx),
556
565
  fetch: guardedFetch,
557
- }) as unknown as Transport;
566
+ });
558
567
  }
559
568
  const headers =
560
569
  opts.auth?.type === "headers" ? opts.auth.headers : undefined;
561
- return new StreamableHTTPClientTransport(
562
- url,
563
- {
564
- ...(headers ? { requestInit: { headers } } : {}),
565
- fetch: guardedFetch,
566
- },
567
- ) as unknown as Transport;
570
+ return new StreamableHTTPClientTransport(url, {
571
+ ...(headers ? { requestInit: { headers } } : {}),
572
+ fetch: guardedFetch,
573
+ });
568
574
  };
569
575
 
570
576
  const reset = (state: ConnectionState) => {
571
577
  state.client = null;
572
578
  state.transport = null;
579
+ state.toolDefinitions.clear();
573
580
  state.connecting = null;
574
581
  state.authRequired = false;
575
582
  state.provider = null;
@@ -650,13 +657,17 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
650
657
  throw operatorDisconnectedError();
651
658
  }
652
659
  provider?.captureGeneration(genAtStart);
653
- // The SDK defaults to AJV, which compiles every advertised outputSchema
654
- // with `new Function`. Cloudflare Workers prohibit dynamic code
655
- // generation, so a remote such as Stripe fails during tools/list unless
656
- // the SDK's edge-safe validator is selected explicitly.
660
+ // SDK v2 selects its validator by runtime export condition: AJV on
661
+ // Node and @cfworker/json-schema under workerd. The Workers-safe path
662
+ // no longer needs Connecta-specific wiring.
657
663
  const c = new Client(
658
664
  { name: "connecta", version: CONNECTA_VERSION },
659
- { jsonSchemaValidator: new CfWorkerJsonSchemaValidator() },
665
+ {
666
+ versionNegotiation: { mode: "auto" },
667
+ // Connecta has no interactive relay. Surface the result manually
668
+ // below as one structured, non-retryable connector failure.
669
+ inputRequired: { autoFulfill: false },
670
+ },
660
671
  );
661
672
  const t = buildTransport(ctx, provider);
662
673
  if (!ownsAttempt()) await abandon(t);
@@ -869,9 +880,9 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
869
880
  `Connector "${id}" kept advertising more tools/list pages after ${MAX_TOOL_PAGES} — refusing to page further.`,
870
881
  );
871
882
  }
872
- // Repair what the per-page listTools calls left behind before any of
873
- // these tools can be called. See primeToolMetadata.
874
- primeToolMetadata(client, listed);
883
+ // Publish definitions only after the full walk succeeds. A later-page
884
+ // failure must not leave a partial validation/header view behind.
885
+ state.toolDefinitions = new Map(listed.map((tool) => [tool.name, tool]));
875
886
  return listed.map((t) => ({
876
887
  name: t.name,
877
888
  ...(t.description !== undefined ? { description: t.description } : {}),
@@ -904,19 +915,32 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
904
915
  await ensureConnected(ctx, state);
905
916
  const client = state.client!;
906
917
  try {
907
- return await client.callTool(
918
+ const toolDefinition = state.toolDefinitions.get(name);
919
+ if (toolDefinition?.execution?.taskSupport === "required") {
920
+ throw new Error(
921
+ `Tool "${name}" requires task-based execution, which Connecta does not support.`,
922
+ );
923
+ }
924
+ const result = await client.callTool(
908
925
  {
909
926
  name,
910
927
  arguments: (args ?? {}) as Record<string, unknown>,
911
928
  },
912
- undefined,
913
- ctx.timeoutMs || ctx.signal
914
- ? {
915
- ...(ctx.timeoutMs ? { timeout: ctx.timeoutMs } : {}),
916
- ...(ctx.signal ? { signal: ctx.signal } : {}),
917
- }
918
- : undefined,
929
+ {
930
+ ...requestOptions(ctx),
931
+ allowInputRequired: true,
932
+ ...(toolDefinition ? { toolDefinition } : {}),
933
+ },
919
934
  );
935
+ if (isInputRequiredResult(result)) {
936
+ throw new ConnectorCallError(
937
+ "input_required_unsupported",
938
+ `Connector "${id}" returned input_required for "${name}". ` +
939
+ "Connecta cannot relay multi-round-trip input yet; this " +
940
+ "capability is gated pending real host and downstream adoption.",
941
+ );
942
+ }
943
+ return result;
920
944
  } catch (err) {
921
945
  // A grant revoked after connect surfaces here, not in ensureConnected.
922
946
  if (err instanceof UnauthorizedError) {
@@ -942,6 +966,7 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
942
966
  const transport = state.transport;
943
967
  state.client = null;
944
968
  state.transport = null;
969
+ state.toolDefinitions.clear();
945
970
  state.connecting = null;
946
971
  state.authRequired = false;
947
972
  state.connectedGeneration = null;
@@ -982,7 +1007,7 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
982
1007
  }
983
1008
  },
984
1009
 
985
- async finishAuth(code, ctx) {
1010
+ async finishAuth(code, ctx, callbackParams) {
986
1011
  const state = stateFor(ctx);
987
1012
  const provider = getProvider(ctx, state);
988
1013
  // verifyState ran on this request-scoped provider first and captured the
@@ -990,7 +1015,11 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
990
1015
  // token write remains tagged with that older generation and is unreadable.
991
1016
  const t = (state.transport ??
992
1017
  buildTransport(ctx, provider)) as StreamableHTTPClientTransport;
993
- await t.finishAuth(code);
1018
+ if (callbackParams !== undefined) {
1019
+ await t.finishAuth(callbackParams);
1020
+ } else {
1021
+ await t.finishAuth(code);
1022
+ }
994
1023
  await provider.clearPending();
995
1024
  // Reset so the next use reconnects with the freshly stored tokens.
996
1025
  reset(state);
@@ -998,15 +1027,6 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
998
1027
  };
999
1028
 
1000
1029
  if (opts.auth?.type === "oauth") {
1001
- // The credential liveness checks (issue #24) probe only connectors that
1002
- // actually hold a stored grant: with no tokens there is nothing whose
1003
- // liveness could have lapsed, and a `status()` probe would kick off DCR +
1004
- // consent on a timer for a connector nobody has authorized yet.
1005
- connector.hasStoredCredential = async (ctx) => {
1006
- const state = stateFor(ctx);
1007
- return (await getProvider(ctx, state).tokens()) !== undefined;
1008
- };
1009
-
1010
1030
  connector.verifyState = async (oauthState, ctx) => {
1011
1031
  const state = stateFor(ctx);
1012
1032
  return getProvider(ctx, state).verifyState(oauthState);
package/src/errors.ts CHANGED
@@ -8,14 +8,22 @@ export type ConnectorCallErrorCode =
8
8
  | "rate_limited"
9
9
  | "unavailable"
10
10
  | "invalid_args"
11
+ | "input_required_unsupported"
11
12
  | "connector_call_failed";
12
13
 
14
+ /** Agent-visible recovery class attached only to `auth_required` failures. */
15
+ export type AuthRecoveryMode =
16
+ | "oauth"
17
+ | "operator_config"
18
+ | "unavailable";
19
+
13
20
  const RETRYABLE_BY_CODE: Record<ConnectorCallErrorCode, boolean> = {
14
21
  timeout: true,
15
22
  rate_limited: true,
16
23
  unavailable: true,
17
24
  auth_required: false,
18
25
  invalid_args: false,
26
+ input_required_unsupported: false,
19
27
  connector_call_failed: false,
20
28
  };
21
29
 
@@ -78,6 +86,20 @@ export interface CallErrorDetails {
78
86
  * window so it can schedule a re-issue.
79
87
  */
80
88
  retryAfterMs?: number;
89
+ /** Connector whose failed operation needs recovery. */
90
+ connector?: string;
91
+ /** Canonical downstream address the agent may retry after recovery. */
92
+ operation?: string;
93
+ /** Which safe recovery path `authorize_connector` will return. */
94
+ recovery?: AuthRecoveryMode;
95
+ /** The single model-facing entry point for every credential class. */
96
+ nextAction?: {
97
+ tool: "authorize_connector";
98
+ arguments: { connector: string };
99
+ operatorHandoff: string;
100
+ };
101
+ /** Explicit retry guidance; recovery never retries or mutates by itself. */
102
+ retry?: string;
81
103
  }
82
104
 
83
105
  const RETRYABLE_MESSAGE_RE =
package/src/execute.ts CHANGED
@@ -1,8 +1,8 @@
1
- import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1
+ import type { McpServer } from "@modelcontextprotocol/server";
2
2
  import { z } from "zod";
3
3
  import type { ActivityRequestContext } from "./activity.js";
4
4
  import {
5
- assertDiscoveryResultSize,
5
+ boundedDiscoveryText,
6
6
  CatalogService,
7
7
  flatSearchResult,
8
8
  } from "./catalog-service.js";
@@ -16,14 +16,12 @@ import {
16
16
  ExecutorAdmissionError,
17
17
  isAdmittingExecutor,
18
18
  } from "./executor-admission.js";
19
- import { unwrapMcpResult } from "./mcp-result.js";
20
19
  import {
21
20
  InvocationFailure,
22
21
  InvocationService,
23
22
  } from "./invocation.js";
24
23
  import type { RegistryView } from "./registry.js";
25
24
  import type {
26
- Connector,
27
25
  Executor,
28
26
  ExecutorProvider,
29
27
  Logger,
@@ -137,19 +135,6 @@ ${declarations}
137
135
  })();`;
138
136
  }
139
137
 
140
- /**
141
- * Unwrap an MCP CallToolResult so sandbox code sees plain values:
142
- * isError throws (a real exception the code can catch), structuredContent
143
- * wins when present, all-text content is JSON.parsed when possible.
144
- * Non-MCP connectors already return plain values.
145
- */
146
- export function unwrapForSandbox(
147
- kind: Connector["kind"],
148
- result: unknown,
149
- ): unknown {
150
- return unwrapMcpResult(kind, result);
151
- }
152
-
153
138
  /**
154
139
  * Expose one fixed host provider plus trusted sandbox setup that creates a
155
140
  * lazy proxy global per connector. No connector catalog is touched until code
@@ -165,6 +150,7 @@ export async function buildSandboxProviders(
165
150
  maxHostCalls?: number;
166
151
  hostCallTimeoutMs?: number;
167
152
  discoveryConcurrency?: number;
153
+ onInvocationFailure?: (failure: InvocationFailure) => void;
168
154
  } = {},
169
155
  ): Promise<ExecutorProvider[]> {
170
156
  // All host calls made by one execute_code invocation share a downstream
@@ -225,7 +211,11 @@ export async function buildSandboxProviders(
225
211
  args ?? {},
226
212
  invocationContext(),
227
213
  );
228
- if (!outcome.ok) throw new InvocationFailure(outcome.error);
214
+ if (!outcome.ok) {
215
+ const failure = new InvocationFailure(outcome.error);
216
+ limits.onInvocationFailure?.(failure);
217
+ throw failure;
218
+ }
229
219
  return outcome.value;
230
220
  };
231
221
  const callNamespace = async (
@@ -240,7 +230,11 @@ export async function buildSandboxProviders(
240
230
  args ?? {},
241
231
  invocationContext(),
242
232
  );
243
- if (!outcome.ok) throw new InvocationFailure(outcome.error);
233
+ if (!outcome.ok) {
234
+ const failure = new InvocationFailure(outcome.error);
235
+ limits.onInvocationFailure?.(failure);
236
+ throw failure;
237
+ }
244
238
  return outcome.value;
245
239
  };
246
240
 
@@ -287,7 +281,7 @@ export async function buildSandboxProviders(
287
281
  includeSchemas?: "compact" | "json";
288
282
  };
289
283
  const result = flatSearchResult(await catalog.search(args));
290
- assertDiscoveryResultSize(
284
+ boundedDiscoveryText(
291
285
  result,
292
286
  "Request a smaller limit, omit fullDescriptions, or use compact schemas.",
293
287
  );
@@ -300,7 +294,7 @@ export async function buildSandboxProviders(
300
294
  fullDescriptions?: boolean;
301
295
  };
302
296
  const result = { tools: await catalog.describe(args) };
303
- assertDiscoveryResultSize(
297
+ boundedDiscoveryText(
304
298
  result,
305
299
  'Split the address list or use format: "compact".',
306
300
  );
@@ -332,6 +326,7 @@ export function createExecuteTool(
332
326
  }
333
327
  let lease;
334
328
  let outcome;
329
+ const invocationFailures: InvocationFailure[] = [];
335
330
  try {
336
331
  // Admission comes before provider construction: queued calls retain no
337
332
  // catalogs, request scopes, or one-closure-per-tool provider arrays.
@@ -350,6 +345,9 @@ export function createExecuteTool(
350
345
  activity,
351
346
  {
352
347
  signal: controller.signal,
348
+ onInvocationFailure: (failure) => {
349
+ invocationFailures.push(failure);
350
+ },
353
351
  ...(config.discoveryConcurrency !== undefined
354
352
  ? { discoveryConcurrency: config.discoveryConcurrency }
355
353
  : {}),
@@ -401,6 +399,27 @@ export function createExecuteTool(
401
399
  )
402
400
  : undefined;
403
401
  if (outcome.error) {
402
+ // Executor bridges necessarily reduce thrown host errors to strings.
403
+ // Match that terminal string back to the request-local typed failure so
404
+ // an unhandled tool failure keeps the same structured contract as
405
+ // call_tool and batch_call. Failures caught by model code never reach
406
+ // outcome.error and therefore remain under that code's control.
407
+ let invocationFailure: InvocationFailure | undefined;
408
+ for (let i = invocationFailures.length - 1; i >= 0; i--) {
409
+ const candidate = invocationFailures[i];
410
+ if (candidate && outcome.error.includes(candidate.message)) {
411
+ invocationFailure = candidate;
412
+ break;
413
+ }
414
+ }
415
+ if (invocationFailure) {
416
+ const result = jsonResult({
417
+ error: invocationFailure.details,
418
+ ...(logs ? { logs } : {}),
419
+ });
420
+ result.isError = true;
421
+ return result;
422
+ }
404
423
  return errorResult(
405
424
  `Error: ${outcome.error}${logs ? `\n\nLogs:\n${logs}` : ""}`,
406
425
  );
@@ -463,11 +482,11 @@ export function registerExecuteTool(
463
482
  "execute_code",
464
483
  {
465
484
  description: EXECUTE_DESC,
466
- inputSchema: {
485
+ inputSchema: z.object({
467
486
  code: z
468
487
  .string()
469
488
  .describe("A JavaScript async arrow function to execute."),
470
- },
489
+ }),
471
490
  // The sandbox exposes only tools that are explicitly read-only, and the
472
491
  // executor grants no network, filesystem, env, or timer capabilities.
473
492
  annotations: {
@@ -478,7 +497,7 @@ export function registerExecuteTool(
478
497
  },
479
498
  async (args, extra) => {
480
499
  const controller = new AbortController();
481
- const signals = [extra.signal, ctx.requestSignal].filter(
500
+ const signals = [extra.mcpReq.signal, ctx.requestSignal].filter(
482
501
  (signal): signal is AbortSignal => signal !== undefined,
483
502
  );
484
503
  const forwarders = signals.map((signal) => {
@@ -9,7 +9,7 @@ function msg(err: unknown): string {
9
9
  }
10
10
 
11
11
  function serializeExecuteValue(value: unknown): string {
12
- const serialized = JSON.stringify(value, null, 2);
12
+ const serialized = JSON.stringify(value);
13
13
  return serialized === undefined ? String(value) : serialized;
14
14
  }
15
15