@zackbart/connecta 0.10.4 → 0.10.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +135 -0
  2. package/dist/activity.d.ts +11 -1
  3. package/dist/activity.d.ts.map +1 -1
  4. package/dist/activity.js +44 -3
  5. package/dist/activity.js.map +1 -1
  6. package/dist/catalog-service.d.ts +40 -0
  7. package/dist/catalog-service.d.ts.map +1 -1
  8. package/dist/catalog-service.js +97 -15
  9. package/dist/catalog-service.js.map +1 -1
  10. package/dist/errors.d.ts +48 -1
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/errors.js +67 -0
  13. package/dist/errors.js.map +1 -1
  14. package/dist/execute.d.ts +72 -0
  15. package/dist/execute.d.ts.map +1 -1
  16. package/dist/execute.js +163 -10
  17. package/dist/execute.js.map +1 -1
  18. package/dist/index.d.ts +15 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +6 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/invocation.d.ts +9 -2
  23. package/dist/invocation.d.ts.map +1 -1
  24. package/dist/invocation.js +59 -29
  25. package/dist/invocation.js.map +1 -1
  26. package/dist/meta-tools.d.ts +12 -3
  27. package/dist/meta-tools.d.ts.map +1 -1
  28. package/dist/meta-tools.js +185 -30
  29. package/dist/meta-tools.js.map +1 -1
  30. package/dist/operator-ui/generated.d.ts +1 -1
  31. package/dist/operator-ui/generated.d.ts.map +1 -1
  32. package/dist/operator-ui/generated.js +1 -1
  33. package/dist/operator-ui/generated.js.map +1 -1
  34. package/dist/registry.d.ts +11 -0
  35. package/dist/registry.d.ts.map +1 -1
  36. package/dist/registry.js +5 -2
  37. package/dist/registry.js.map +1 -1
  38. package/dist/routes/mcp.d.ts.map +1 -1
  39. package/dist/routes/mcp.js +9 -0
  40. package/dist/routes/mcp.js.map +1 -1
  41. package/dist/routes/shared.d.ts +4 -0
  42. package/dist/routes/shared.d.ts.map +1 -1
  43. package/dist/routes/shared.js.map +1 -1
  44. package/dist/skills.d.ts +1 -1
  45. package/dist/skills.d.ts.map +1 -1
  46. package/dist/skills.js +1 -1
  47. package/dist/version.d.ts +1 -1
  48. package/dist/version.js +1 -1
  49. package/documentation/code-mode.md +125 -34
  50. package/documentation/meta-tools.md +91 -9
  51. package/documentation/rich-output-design.md +212 -0
  52. package/ethos.md +17 -19
  53. package/examples/worker/README.md +11 -3
  54. package/examples/worker/src/d1-activity-row.ts +40 -0
  55. package/examples/worker/src/d1-activity.ts +3 -2
  56. package/package.json +1 -1
  57. package/src/activity.ts +64 -3
  58. package/src/catalog-service.ts +166 -26
  59. package/src/errors.ts +102 -1
  60. package/src/execute.ts +240 -10
  61. package/src/index.ts +22 -0
  62. package/src/invocation.ts +59 -17
  63. package/src/meta-tools.ts +239 -37
  64. package/src/operator-ui/browser.ts +10 -2
  65. package/src/operator-ui/generated.ts +1 -1
  66. package/src/registry.ts +5 -2
  67. package/src/routes/mcp.ts +9 -0
  68. package/src/routes/shared.ts +4 -0
  69. package/src/skills.ts +1 -1
  70. package/src/version.ts +1 -1
  71. package/templates/node/package.json +1 -1
package/src/meta-tools.ts CHANGED
@@ -23,7 +23,9 @@ import {
23
23
  type DeferredWork,
24
24
  } from "./connector-scope.js";
25
25
  import {
26
+ boundedEchoText,
26
27
  classifyCallError,
28
+ echoedCallArgs,
27
29
  messageLooksRetryable,
28
30
  type CallErrorDetails,
29
31
  } from "./errors.js";
@@ -280,6 +282,7 @@ interface ProjectionFeedback {
280
282
  $connecta: {
281
283
  type: "field_projection";
282
284
  unmatchedFields: string[];
285
+ hint?: string;
283
286
  schemaDeclared?: true;
284
287
  schemaCoverage?: "complete" | "partial";
285
288
  invalidFields?: string[];
@@ -649,7 +652,20 @@ function schemaProjectionFeedback(
649
652
  const invalidFields = analysis.complete
650
653
  ? unmatchedFields.filter((field) => !available.has(field))
651
654
  : [];
655
+ const missingArrayMarker = unmatchedFields.some((field) =>
656
+ analysis.paths.some(
657
+ (availableField) =>
658
+ availableField.includes("[]") &&
659
+ availableField.replaceAll("[]", "") === field,
660
+ ),
661
+ );
652
662
  return {
663
+ ...(missingArrayMarker
664
+ ? {
665
+ hint:
666
+ 'Traverse arrays with [] after the array field name, for example "results[].id".',
667
+ }
668
+ : {}),
653
669
  schemaDeclared: true,
654
670
  schemaCoverage: analysis.complete ? "complete" : "partial",
655
671
  ...(invalidFields.length > 0 ? { invalidFields } : {}),
@@ -772,6 +788,10 @@ async function stashResult(
772
788
  resultId: string;
773
789
  totalBytes: number;
774
790
  hint: string;
791
+ nextAction: {
792
+ tool: "get_result";
793
+ arguments: { id: string; offset: 0 };
794
+ };
775
795
  }> {
776
796
  const id = crypto.randomUUID();
777
797
  await results.set(`result:${id}`, text, { ttlSeconds: RESULT_TTL_SECONDS });
@@ -780,16 +800,108 @@ async function stashResult(
780
800
  resultId: id,
781
801
  totalBytes,
782
802
  hint: "use get_result {id, offset} to page, or re-call with fields to select less",
803
+ nextAction: {
804
+ tool: "get_result",
805
+ arguments: { id, offset: 0 },
806
+ },
783
807
  };
784
808
  }
785
809
 
786
- /** Keep an oversized batch's inline outcome summary at fixed string overhead. */
810
+ interface GuardedResult<T> {
811
+ result: T;
812
+ truncated: boolean;
813
+ }
814
+
815
+ /**
816
+ * Keep an oversized batch's inline outcome summary at fixed string overhead.
817
+ * The same clamp the error envelopes use — one budget, one marker, defined
818
+ * once in `errors.ts` so the two cannot drift apart.
819
+ */
787
820
  function batchSummaryString(value: string): string {
788
- const bytes = enc.encode(value);
789
- const maxBytes = 512;
790
- if (bytes.length <= maxBytes) return value;
791
- const end = alignEndToCharBoundary(bytes, 0, maxBytes, bytes.length);
792
- return `${dec.decode(bytes.slice(0, end))}…`;
821
+ return boundedEchoText(value);
822
+ }
823
+
824
+ /** Candidate addresses kept in an oversized batch's summary of one ambiguity. */
825
+ const MAX_SUMMARY_ADDRESSES = 10;
826
+
827
+ /**
828
+ * A recovery route rebuilt field by field so the summary above keeps its
829
+ * promise. Spreading `nextAction` through raw would reopen the hole this
830
+ * function closes: every variant carries free-form strings, and one of them
831
+ * carries the caller's arguments, which is exactly the payload an oversized
832
+ * batch was already too large to hold.
833
+ */
834
+ function batchSummaryNextAction(
835
+ nextAction: NonNullable<CallErrorDetails["nextAction"]>,
836
+ ): NonNullable<CallErrorDetails["nextAction"]> {
837
+ if ("function" in nextAction) {
838
+ // A batch runs on the top-level catalog, whose search route is the tool, so
839
+ // the function-keyed discovery variant does not arrive here today. Rebuild
840
+ // it anyway: a guard that silently dropped an unrecognized route would turn
841
+ // a recovery record into nothing at exactly the moment one is needed.
842
+ if (nextAction.function === "connecta.search") {
843
+ return {
844
+ function: "connecta.search",
845
+ arguments: batchSummarySearchArgs(nextAction.arguments),
846
+ purpose: batchSummaryString(nextAction.purpose),
847
+ };
848
+ }
849
+ // Say so when the candidate list is clipped. The unclipped purpose reads
850
+ // "choose the intended canonical address", which is a lie about a list
851
+ // that no longer contains every candidate — and the caller has no other
852
+ // way to learn that the address it wants was the eleventh.
853
+ const candidates = nextAction.addresses.slice(0, MAX_SUMMARY_ADDRESSES);
854
+ return {
855
+ function: nextAction.function,
856
+ addresses: candidates.map(batchSummaryString),
857
+ purpose: batchSummaryString(
858
+ candidates.length < nextAction.addresses.length
859
+ ? `${nextAction.purpose} Showing the first ${candidates.length} of ` +
860
+ `${nextAction.addresses.length} candidates; re-run the call on its ` +
861
+ "own to see them all."
862
+ : nextAction.purpose,
863
+ ),
864
+ };
865
+ }
866
+ if (nextAction.tool === "authorize_connector") {
867
+ return {
868
+ tool: "authorize_connector",
869
+ arguments: {
870
+ connector: batchSummaryString(nextAction.arguments.connector),
871
+ },
872
+ operatorHandoff: batchSummaryString(nextAction.operatorHandoff),
873
+ };
874
+ }
875
+ if (nextAction.tool === "call_destructive_tool") {
876
+ return {
877
+ tool: "call_destructive_tool",
878
+ arguments: {
879
+ address: batchSummaryString(nextAction.arguments.address),
880
+ ...echoedCallArgs(nextAction.arguments.args),
881
+ },
882
+ purpose: batchSummaryString(nextAction.purpose),
883
+ };
884
+ }
885
+ return {
886
+ tool: "search_tools",
887
+ arguments: batchSummarySearchArgs(nextAction.arguments),
888
+ purpose: batchSummaryString(nextAction.purpose),
889
+ };
890
+ }
891
+
892
+ /** The scoping arguments both discovery routes carry, bounded the same way. */
893
+ function batchSummarySearchArgs(args: {
894
+ query: string;
895
+ connector?: string;
896
+ includeSchemas: "compact";
897
+ }): { query: string; connector?: string; includeSchemas: "compact" } {
898
+ return {
899
+ query: batchSummaryString(args.query),
900
+ ...(args.connector !== undefined
901
+ ? { connector: batchSummaryString(args.connector) }
902
+ : {}),
903
+ includeSchemas: "compact",
904
+ };
793
905
  }
794
906
 
795
907
  /**
@@ -803,16 +915,22 @@ async function guardEncoded(
803
915
  bytes: Uint8Array,
804
916
  results: KVStorage,
805
917
  cap: number,
806
- ): Promise<ToolResult> {
918
+ ): Promise<GuardedResult<ToolResult>> {
807
919
  if (bytes.length <= cap) {
808
- return { content: [{ type: "text", text }] };
920
+ return {
921
+ result: { content: [{ type: "text", text }] },
922
+ truncated: false,
923
+ };
809
924
  }
810
925
  const notice = await stashResult(text, results, bytes.length);
811
926
  const head = dec.decode(
812
927
  bytes.slice(0, alignEndToCharBoundary(bytes, 0, cap, bytes.length)),
813
928
  );
814
929
  return {
815
- content: [{ type: "text", text: `${head}\n${JSON.stringify(notice)}` }],
930
+ result: {
931
+ content: [{ type: "text", text: `${head}\n${JSON.stringify(notice)}` }],
932
+ },
933
+ truncated: true,
816
934
  };
817
935
  }
818
936
 
@@ -821,7 +939,7 @@ async function guardText(
821
939
  text: string,
822
940
  results: KVStorage,
823
941
  cap: number,
824
- ): Promise<ToolResult> {
942
+ ): Promise<GuardedResult<ToolResult>> {
825
943
  // `JSON.stringify`'s type says `string` where its behavior says `string |
826
944
  // undefined`, so TypeScript alone does not keep a non-string out of here.
827
945
  // Normalizing at the door means the size check below always measures exactly
@@ -837,11 +955,14 @@ async function guardValue(
837
955
  value: unknown,
838
956
  results: KVStorage,
839
957
  cap: number,
840
- ): Promise<unknown> {
958
+ ): Promise<GuardedResult<unknown>> {
841
959
  const text = serializeResultText(value);
842
960
  const bytes = enc.encode(text);
843
- if (bytes.length <= cap) return value;
844
- return stashResult(text, results, bytes.length);
961
+ if (bytes.length <= cap) return { result: value, truncated: false };
962
+ return {
963
+ result: await stashResult(text, results, bytes.length),
964
+ truncated: true,
965
+ };
845
966
  }
846
967
 
847
968
  /**
@@ -866,7 +987,7 @@ async function guardContent(
866
987
  content: TextContent[],
867
988
  results: KVStorage,
868
989
  cap: number,
869
- ): Promise<ToolResult> {
990
+ ): Promise<GuardedResult<ToolResult>> {
870
991
  let text: string;
871
992
  try {
872
993
  text = JSON.stringify(content);
@@ -875,17 +996,22 @@ async function guardContent(
875
996
  // be measured, stashed, or paged either — there is nothing this guard could
876
997
  // do with it. Pass it through as the old text-only measure did, rather than
877
998
  // turning a call that used to succeed into result_processing_failed.
878
- return { content };
999
+ return { result: { content }, truncated: false };
879
1000
  }
880
1001
  const bytes = enc.encode(text);
881
1002
  // Under the cap the downstream blocks pass through untouched, non-text ones
882
1003
  // included, in their original order.
883
- if (bytes.length <= cap) return { content };
1004
+ if (bytes.length <= cap) {
1005
+ return { result: { content }, truncated: false };
1006
+ }
884
1007
  if (content.every((b) => b.type === "text")) {
885
1008
  return guardEncoded(text, bytes, results, cap);
886
1009
  }
887
1010
  const notice = await stashResult(text, results, bytes.length);
888
- return { content: [{ type: "text", text: JSON.stringify(notice) }] };
1011
+ return {
1012
+ result: { content: [{ type: "text", text: JSON.stringify(notice) }] },
1013
+ truncated: true,
1014
+ };
889
1015
  }
890
1016
 
891
1017
  // --- compact schema rendering (feature 3a) --------------------------------
@@ -901,11 +1027,13 @@ export interface SearchArgs {
901
1027
  fullDescriptions?: boolean;
902
1028
  includeSchemas?: "compact" | "json";
903
1029
  }
904
- export interface DescribeArgs {
905
- addresses: string[];
1030
+ export type DescribeArgs = (
1031
+ | { address: string; addresses?: never }
1032
+ | { address?: never; addresses: string[] }
1033
+ ) & {
906
1034
  format?: "compact" | "json";
907
1035
  fullDescriptions?: boolean;
908
- }
1036
+ };
909
1037
  export interface ListArgs {
910
1038
  /** When false, return cached/observed health without downstream I/O. */
911
1039
  probe?: boolean;
@@ -922,6 +1050,10 @@ export interface CallArgs {
922
1050
  /** Include connector/catalog/result-processing timing segments. */
923
1051
  diagnostics?: boolean;
924
1052
  }
1053
+ export interface DestructiveCallArgs extends CallArgs {
1054
+ /** Short model-authored context for the host's approval UI; never downstream input. */
1055
+ reason?: string;
1056
+ }
925
1057
  export interface GetResultArgs {
926
1058
  id: string;
927
1059
  /**
@@ -948,6 +1080,21 @@ export interface SkillArgs {
948
1080
  name?: string;
949
1081
  }
950
1082
 
1083
+ /**
1084
+ * The sentence that closes the OAuth handoff, telling the operator's agent how
1085
+ * to confirm the flow landed. `authorize_connector` is registered on both
1086
+ * surfaces but `list_connectors` is not, so the classic status check cannot be
1087
+ * the only one offered: a code-first agent handed that advice gets an
1088
+ * unknown-tool error at exactly the moment it is trying to recover. It gets the
1089
+ * check its own surface serves instead — the same folded-name defect as the
1090
+ * describe path (#261), one tool result further along.
1091
+ */
1092
+ function oauthFollowUp(surface: ConnectaSurface, connectorId: string): string {
1093
+ return surface === "code-first"
1094
+ ? `Then retry the original call; connecta.search({ connector: ${JSON.stringify(connectorId)} }) inside execute_code confirms the catalog now loads.`
1095
+ : "Re-run list_connectors afterwards to confirm status is ok.";
1096
+ }
1097
+
951
1098
  /**
952
1099
  * Every base meta-tool handler over a registry — all nine, whichever surface is
953
1100
  * advertised, since folding a tool away only skips its registration and never
@@ -1002,6 +1149,14 @@ export function createMetaTools(
1002
1149
  requestScope,
1003
1150
  probeTimeoutMs,
1004
1151
  concurrency: discoveryConcurrency,
1152
+ // These handlers are the top-level tools, so the route is the advertised
1153
+ // one; in-program describes route through connecta.describe instead and
1154
+ // are built with their own CatalogService in execute.ts.
1155
+ describeRoute:
1156
+ surface === "code-first" ? "connecta.describe" : "describe_tools",
1157
+ // searchRoute keeps its default: unlike describe_tools, search_tools is
1158
+ // served by both advertised surfaces, so a top-level handler has nothing to
1159
+ // derive. Only an in-program caller needs to be sent to connecta.search.
1005
1160
  });
1006
1161
  const invocation = new InvocationService(registry, catalog, opts.activity);
1007
1162
  const withProbeDeadline = <T>(
@@ -1029,6 +1184,12 @@ export function createMetaTools(
1029
1184
  interface ProcessedCallResult {
1030
1185
  toolResult: ToolResult;
1031
1186
  value?: unknown;
1187
+ /**
1188
+ * Friction on a call that *succeeded*. It travels as a friction class, not
1189
+ * as an `errorCode`, so persistence keyed on "this row has an error code"
1190
+ * keeps counting failures rather than truncations.
1191
+ */
1192
+ friction?: "result_too_large";
1032
1193
  }
1033
1194
 
1034
1195
  /** MCP adapter: shared invocation semantics plus MCP-only result shaping. */
@@ -1075,10 +1236,14 @@ export function createMetaTools(
1075
1236
  resolved.definition.outputSchema,
1076
1237
  )
1077
1238
  : result;
1078
- value = await guardValue(value, results, cap);
1239
+ const guarded = await guardValue(value, results, cap);
1240
+ value = guarded.result;
1079
1241
  return {
1080
1242
  toolResult: jsonResult({ ok: true, data: value }),
1081
1243
  value,
1244
+ ...(guarded.truncated
1245
+ ? { friction: "result_too_large" as const }
1246
+ : {}),
1082
1247
  };
1083
1248
  }
1084
1249
  if (resolved.connector.kind === "mcp") {
@@ -1091,7 +1256,13 @@ export function createMetaTools(
1091
1256
  resolved.definition.outputSchema,
1092
1257
  );
1093
1258
  }
1094
- return { toolResult: await guardContent(content, results, cap) };
1259
+ const guarded = await guardContent(content, results, cap);
1260
+ return {
1261
+ toolResult: guarded.result,
1262
+ ...(guarded.truncated
1263
+ ? { friction: "result_too_large" as const }
1264
+ : {}),
1265
+ };
1095
1266
  }
1096
1267
  const value = fields
1097
1268
  ? projectionValue(
@@ -1100,19 +1271,26 @@ export function createMetaTools(
1100
1271
  resolved.definition.outputSchema,
1101
1272
  )
1102
1273
  : result;
1274
+ const guarded = await guardText(
1275
+ serializeResultText(value),
1276
+ results,
1277
+ cap,
1278
+ );
1103
1279
  return {
1104
- toolResult: await guardText(
1105
- serializeResultText(value),
1106
- results,
1107
- cap,
1108
- ),
1280
+ toolResult: guarded.result,
1109
1281
  value,
1282
+ ...(guarded.truncated
1283
+ ? { friction: "result_too_large" as const }
1284
+ : {}),
1110
1285
  };
1111
1286
  },
1287
+ activityFriction: (processed) => processed.friction,
1112
1288
  },
1113
1289
  );
1114
1290
  if (!outcome.ok) {
1291
+ const structuredRecovery = outcome.error.nextAction !== undefined;
1115
1292
  const failedResult =
1293
+ structuredRecovery ||
1116
1294
  outcome.error.code === "auth_required" ||
1117
1295
  outcome.error.code === "invalid_args" ||
1118
1296
  outcome.error.code === "input_required_unsupported" ||
@@ -1126,6 +1304,7 @@ export function createMetaTools(
1126
1304
  })
1127
1305
  : errorResult(outcome.error.message);
1128
1306
  if (
1307
+ structuredRecovery ||
1129
1308
  outcome.error.code === "auth_required" ||
1130
1309
  outcome.error.code === "invalid_args" ||
1131
1310
  outcome.error.code === "input_required_unsupported"
@@ -1311,7 +1490,12 @@ export function createMetaTools(
1311
1490
  async searchTools(args: SearchArgs): Promise<ToolResult> {
1312
1491
  return discoveryResult(
1313
1492
  async () =>
1314
- groupedSearchResult(await catalog.search(args)),
1493
+ groupedSearchResult(
1494
+ await catalog.search({
1495
+ ...args,
1496
+ includeSchemaKeys: args.includeSchemas !== undefined,
1497
+ }),
1498
+ ),
1315
1499
  "Request a smaller limit, omit fullDescriptions, or use compact schemas.",
1316
1500
  );
1317
1501
  },
@@ -1327,7 +1511,9 @@ export function createMetaTools(
1327
1511
  return (await runCall(args, "call_tool")).toolResult;
1328
1512
  },
1329
1513
 
1330
- async callDestructiveTool(args: CallArgs): Promise<ToolResult> {
1514
+ async callDestructiveTool(args: DestructiveCallArgs): Promise<ToolResult> {
1515
+ // `reason` is read by the host's approval view and stops there — runCall
1516
+ // forwards only the call fields, so it never reaches the connector.
1331
1517
  return (
1332
1518
  await runCall(args, "call_destructive_tool", { allowDestructive: true })
1333
1519
  ).toolResult;
@@ -1516,7 +1702,7 @@ export function createMetaTools(
1516
1702
  ? { recovery: details.recovery }
1517
1703
  : {}),
1518
1704
  ...(details.nextAction !== undefined
1519
- ? { nextAction: details.nextAction }
1705
+ ? { nextAction: batchSummaryNextAction(details.nextAction) }
1520
1706
  : {}),
1521
1707
  ...(details.retry !== undefined
1522
1708
  ? { retry: batchSummaryString(details.retry) }
@@ -1605,7 +1791,8 @@ export function createMetaTools(
1605
1791
  ? {
1606
1792
  authorizationUrl: status.authorizationUrl,
1607
1793
  instructions:
1608
- "Have the operator open authorizationUrl in a browser and complete the consent flow. The provider then redirects back to this server's /oauth/callback/<connector> route, which finishes the flow automatically. Re-run list_connectors afterwards to confirm status is ok.",
1794
+ "Have the operator open authorizationUrl in a browser and complete the consent flow. The provider then redirects back to this server's /oauth/callback/<connector> route, which finishes the flow automatically. " +
1795
+ oauthFollowUp(surface, connector.id),
1609
1796
  }
1610
1797
  : {}),
1611
1798
  ...(status.message ? { message: status.message } : {}),
@@ -1623,12 +1810,12 @@ export function createMetaTools(
1623
1810
 
1624
1811
  const LIST_DESC =
1625
1812
  "List connectors with status, cached tool count, and recent real-call health. Use probe=false for a fast inventory; use probe=true (default) only to diagnose live health or authorization.";
1626
- const SEARCH_DESC = `Unknown address: use 2–4 distinctive action/object terms, not the full request; omit limit initially (default ${DEFAULT_SEARCH_LIMIT}) and page only if needed, up to ${MAX_SEARCH_LIMIT}. Partial and no-match searches report term coverage and next-step guidance. safety="readOnly" returns only calls available to call_tool and generated code; "approvalRequired" returns everything else; omitted or "all" preserves the complete catalog. This filters results, not authority. includeSchemas="compact" adds the input and any declared output shape, each bounded; inputSchemaTruncated/outputSchemaTruncated mark shapes that need exact retrieval; matches also carry declared annotations. Call directly when sufficient. Empty query browses all.`;
1813
+ const SEARCH_DESC = `Unknown address: use 2–4 distinctive action/object terms, not the full request; omit limit initially (default ${DEFAULT_SEARCH_LIMIT}) and page only if needed, up to ${MAX_SEARCH_LIMIT}. Partial and no-match searches report term coverage and next-step guidance. safety="readOnly" returns only calls available to call_tool and generated code; "approvalRequired" returns everything else; omitted or "all" preserves the complete catalog. This filters results, not authority. includeSchemas="compact" adds the input and any declared output shape, each bounded; plain-object schemas also expose inputKeys, requiredInputKeys, and outputKeys, while inputSchemaTruncated/outputSchemaTruncated mark shapes that need exact retrieval; matches also carry declared annotations. Call directly when sufficient. Empty query browses all.`;
1627
1814
  const DESCRIBE_DESC = `Only when search_tools omitted schemas, a compact shape is ambiguous, or exact JSON constraints are needed. Inspects up to ${MAX_DESCRIBE_ADDRESSES} addresses with schemas and annotations; "compact" is default, while "json" preserves exact constraints.`;
1628
1815
  const CALL_DESC =
1629
- 'Use for one tool explicitly annotated readOnlyHint: true. For 2–10 independent read-only calls use batch_call; for dependent steps or data reduction use execute_code when available. Unannotated, write-capable, and destructive tools are refused and require call_destructive_tool. fields selects JSON dot-paths; any misses return data plus `$connecta` field-projection feedback. resultMode "value" unwraps results, timeoutMs sets a deadline, safe maxRetries are annotation-gated, diagnostics adds timing, and large results page through get_result.';
1816
+ 'Use for one tool explicitly annotated readOnlyHint: true. For 2–10 independent read-only calls use batch_call; for dependent steps or data reduction use execute_code when available. Unannotated, write-capable, and destructive tools are refused and require call_destructive_tool. fields selects JSON dot-paths; traverse arrays with [] (for example results[].id). Misses return data plus `$connecta` feedback. resultMode "value" unwraps results, timeoutMs sets a deadline, safe maxRetries are annotation-gated, diagnostics adds timing, and large results page through get_result.';
1630
1817
  const CALL_DESTRUCTIVE_DESC =
1631
- "Invoke any tool that is not explicitly annotated readOnlyHint: true, including unannotated, write-capable, or destructive tools. The MCP destructiveHint on this meta-tool lets the host request human approval before execution. Use only after reviewing the downstream tool schema and consequences.";
1818
+ "Invoke any tool that is not explicitly annotated readOnlyHint: true, including unannotated, write-capable, or destructive tools. Include a short reason explaining the intended consequence for the human reviewer; it grants no authority and is never passed downstream. The MCP destructiveHint on this meta-tool lets the host request human approval before execution. Use only after reviewing the downstream tool schema and consequences.";
1632
1819
  const GET_RESULT_DESC =
1633
1820
  "Page a truncated result stashed by call_tool/batch_call. Input { id, offset?, maxBytes? } → { text, offset, nextOffset?, totalBytes } sliced by byte offset. maxBytes is a whole number of bytes >= 1 (omit for the deployment default) and offset a whole number of bytes >= 0; an offset inside a multi-byte character is moved back to that character's first byte and the offset served is returned. Unknown/expired id is an error.";
1634
1821
  const BATCH_DESC =
@@ -1651,7 +1838,7 @@ const SKILLS_DESC =
1651
1838
  */
1652
1839
  const CODE_FIRST_SEARCH_DESC = `${SEARCH_DESC} Expand an ambiguous compact shape, or read exact JSON constraints, with connecta.describe inside execute_code.`;
1653
1840
  const CODE_FIRST_CALL_DESC =
1654
- 'Use for ONE tool explicitly annotated readOnlyHint: true — the cheapest path for a single cold call. For two or more calls, dependent steps, loops, joins, or data reduction use execute_code, whose connecta.call and connecta.batch reach the same tools. Unannotated, write-capable, and destructive tools are refused and require call_destructive_tool. fields selects JSON dot-paths; any misses return data plus `$connecta` field-projection feedback. resultMode "value" unwraps results, timeoutMs sets a deadline, safe maxRetries are annotation-gated, diagnostics adds timing, and large results page through get_result.';
1841
+ 'Use for ONE tool explicitly annotated readOnlyHint: true — the cheapest path for a single cold call. For two or more calls, dependent steps, loops, joins, or data reduction use execute_code, whose connecta.call and connecta.batch reach the same tools. Unannotated, write-capable, and destructive tools are refused and require call_destructive_tool. fields selects JSON dot-paths; traverse arrays with [] (for example results[].id). Misses return data plus `$connecta` feedback. resultMode "value" unwraps results, timeoutMs sets a deadline, safe maxRetries are annotation-gated, diagnostics adds timing, and large results page through get_result.';
1655
1842
  const CODE_FIRST_GET_RESULT_DESC =
1656
1843
  "Page a truncated result stashed by call_tool or call_destructive_tool; a program's oversized return is not paged, so reduce it in code instead. Input { id, offset?, maxBytes? } → { text, offset, nextOffset?, totalBytes } sliced by byte offset. maxBytes is a whole number of bytes >= 1 (omit for the deployment default) and offset a whole number of bytes >= 0; an offset inside a multi-byte character is moved back to that character's first byte and the offset served is returned. Unknown/expired id is an error.";
1657
1844
 
@@ -1843,14 +2030,29 @@ export function registerMetaTools(
1843
2030
  "call_destructive_tool",
1844
2031
  {
1845
2032
  description: CALL_DESTRUCTIVE_DESC,
1846
- inputSchema: z.object(CALL_INPUT_SCHEMA),
2033
+ inputSchema: z.object({
2034
+ ...CALL_INPUT_SCHEMA,
2035
+ // Bounded above, but with no lower bound: a model that sends `""` or
2036
+ // whitespace has written no reason, and failing an entire consequential
2037
+ // call over a cosmetic field the host merely displays is the wrong
2038
+ // trade. It is normalized to absent below instead.
2039
+ reason: z.string().max(500).optional(),
2040
+ }),
1847
2041
  annotations: {
1848
2042
  destructiveHint: true,
1849
2043
  readOnlyHint: false,
1850
2044
  openWorldHint: true,
1851
2045
  },
1852
2046
  },
1853
- async (args) => mt.callDestructiveTool(args as CallArgs),
2047
+ async (args) => {
2048
+ // `reason` is the host's to display and connecta's to keep out of the
2049
+ // downstream call, so this destructuring is the whole of its handling:
2050
+ // nothing below reads it. Dropping it is also what makes an empty or
2051
+ // whitespace-only one "absent" rather than a validation failure — there
2052
+ // is no field left for it to be absent from.
2053
+ const { reason: _hostContext, ...call } = args as DestructiveCallArgs;
2054
+ return mt.callDestructiveTool(call);
2055
+ },
1854
2056
  );
1855
2057
 
1856
2058
  server.registerTool(
@@ -58,6 +58,7 @@ interface UiActivityEvent {
58
58
  durationMs: number;
59
59
  attempts: number;
60
60
  errorCode?: string;
61
+ friction?: string;
61
62
  }
62
63
 
63
64
  interface UiActivityResponse {
@@ -425,6 +426,7 @@ function renderActivity(): void {
425
426
  event.source,
426
427
  event.outcome,
427
428
  event.errorCode,
429
+ event.friction,
428
430
  actor.kind,
429
431
  actor.id,
430
432
  actor.namespace,
@@ -454,7 +456,13 @@ function renderActivity(): void {
454
456
  const retryCopy = event.attempts > 1
455
457
  ? " · " + esc(event.attempts) + " attempts"
456
458
  : "";
457
- const errorCopy = event.errorCode ? " · " + esc(event.errorCode) : "";
459
+ const frictionCopy = event.friction ? " · " + esc(event.friction) : "";
460
+ // The friction class and the code coincide for auth_required and
461
+ // result_too_large. Printing "· auth_required · auth_required" says nothing
462
+ // twice, so the coarse class stands in for both when they agree.
463
+ const errorCopy = event.errorCode && event.errorCode !== event.friction
464
+ ? " · " + esc(event.errorCode)
465
+ : "";
458
466
  const actorId = event.actor?.id
459
467
  ? (event.actor.namespace
460
468
  ? event.actor.namespace + " · " + event.actor.id
@@ -474,7 +482,7 @@ function renderActivity(): void {
474
482
  "</div>" + stableActorId + "</div></div>" +
475
483
  '<div><div class="activity-address">' + esc(event.address) +
476
484
  '</div><div class="activity-detail">' + esc(event.source) + retryCopy +
477
- errorCopy + '</div></div>' +
485
+ frictionCopy + errorCopy + '</div></div>' +
478
486
  '<div><div class="activity-outcome">' + esc(event.outcome) +
479
487
  '</div><div class="activity-detail">' + esc(event.durationMs) + ' ms</div></div>';
480
488
  list.appendChild(item);