@zackbart/connecta 0.4.1 → 0.5.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 (64) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/README.md +40 -5
  3. package/SECURITY.md +10 -6
  4. package/dist/activity.d.ts +8 -0
  5. package/dist/activity.d.ts.map +1 -1
  6. package/dist/activity.js +1 -0
  7. package/dist/activity.js.map +1 -1
  8. package/dist/connectors/api.d.ts +13 -0
  9. package/dist/connectors/api.d.ts.map +1 -1
  10. package/dist/connectors/api.js +2 -0
  11. package/dist/connectors/api.js.map +1 -1
  12. package/dist/connectors/remote-mcp.d.ts +13 -0
  13. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  14. package/dist/connectors/remote-mcp.js +2 -0
  15. package/dist/connectors/remote-mcp.js.map +1 -1
  16. package/dist/execute.d.ts +4 -4
  17. package/dist/execute.d.ts.map +1 -1
  18. package/dist/execute.js.map +1 -1
  19. package/dist/index.d.ts +35 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +27 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/meta-tools.d.ts +22 -4
  24. package/dist/meta-tools.d.ts.map +1 -1
  25. package/dist/meta-tools.js +91 -18
  26. package/dist/meta-tools.js.map +1 -1
  27. package/dist/registry.d.ts +183 -2
  28. package/dist/registry.d.ts.map +1 -1
  29. package/dist/registry.js +293 -27
  30. package/dist/registry.js.map +1 -1
  31. package/dist/server.d.ts +7 -1
  32. package/dist/server.d.ts.map +1 -1
  33. package/dist/server.js +67 -7
  34. package/dist/server.js.map +1 -1
  35. package/dist/skills.d.ts +52 -1
  36. package/dist/skills.d.ts.map +1 -1
  37. package/dist/skills.js +161 -1
  38. package/dist/skills.js.map +1 -1
  39. package/dist/toolkits.d.ts +44 -0
  40. package/dist/toolkits.d.ts.map +1 -0
  41. package/dist/toolkits.js +134 -0
  42. package/dist/toolkits.js.map +1 -0
  43. package/dist/types.d.ts +20 -1
  44. package/dist/types.d.ts.map +1 -1
  45. package/dist/ui.d.ts +28 -0
  46. package/dist/ui.d.ts.map +1 -1
  47. package/dist/ui.js +89 -8
  48. package/dist/ui.js.map +1 -1
  49. package/dist/version.d.ts +1 -1
  50. package/dist/version.js +1 -1
  51. package/package.json +5 -2
  52. package/src/activity.ts +9 -0
  53. package/src/connectors/api.ts +15 -0
  54. package/src/connectors/remote-mcp.ts +15 -0
  55. package/src/execute.ts +4 -4
  56. package/src/index.ts +69 -1
  57. package/src/meta-tools.ts +126 -25
  58. package/src/registry.ts +416 -29
  59. package/src/server.ts +98 -7
  60. package/src/skills.ts +184 -1
  61. package/src/toolkits.ts +215 -0
  62. package/src/types.ts +20 -1
  63. package/src/ui.ts +92 -8
  64. package/src/version.ts +1 -1
package/src/meta-tools.ts CHANGED
@@ -13,8 +13,19 @@ import {
13
13
  messageLooksRetryable,
14
14
  type CallErrorDetails,
15
15
  } from "./errors.js";
16
- import type { Registry } from "./registry.js";
17
- import { AVAILABLE_SKILLS } from "./skills.js";
16
+ import {
17
+ isValidMaxResultBytes,
18
+ MIN_MAX_RESULT_BYTES,
19
+ resolveMaxResultBytes,
20
+ type RegistryView,
21
+ } from "./registry.js";
22
+ import {
23
+ connectorGuide,
24
+ connectorSkillName,
25
+ hasConnectorGuides,
26
+ listSkills,
27
+ resolveSkill,
28
+ } from "./skills.js";
18
29
  import type { ConnectorStatus, KVStorage, ToolDef } from "./types.js";
19
30
 
20
31
  interface TextContent {
@@ -150,19 +161,28 @@ function isContinuationByte(b: number): boolean {
150
161
  * forward to the end of that codepoint instead so paging always advances.
151
162
  * Assumes `offset` is itself a codepoint boundary (offsets are the prior
152
163
  * `nextOffset`, which this function guarantees, and 0 is always a boundary).
164
+ *
165
+ * The return is always `> offset` while `offset < total`, whatever `end` is
166
+ * asked for. That is the belt-and-braces half of issue #32: cap validation
167
+ * keeps an empty window from arising in the first place, and this keeps an
168
+ * empty window from turning into a `nextOffset === offset` paging loop if one
169
+ * ever does. Exported for direct testing of that invariant.
153
170
  */
154
- function alignEndToCharBoundary(
171
+ export function alignEndToCharBoundary(
155
172
  bytes: Uint8Array,
156
173
  offset: number,
157
174
  end: number,
158
175
  total: number,
159
176
  ): number {
160
177
  if (end >= total) return total;
161
- let e = end;
178
+ // A window that reaches no further than `offset` yields no bytes and no
179
+ // progress; widen it to one byte and let the codepoint walk below finish it.
180
+ const wanted = Math.max(end, offset + 1);
181
+ let e = wanted;
162
182
  while (e > offset && isContinuationByte(bytes[e])) e--;
163
183
  if (e === offset) {
164
184
  // Window is narrower than the codepoint at `offset`; take the whole thing.
165
- e = end;
185
+ e = wanted;
166
186
  while (e < total && isContinuationByte(bytes[e])) e++;
167
187
  }
168
188
  return e;
@@ -310,6 +330,7 @@ export interface CallArgs {
310
330
  export interface GetResultArgs {
311
331
  id: string;
312
332
  offset?: number;
333
+ /** Page size in bytes; a whole number >= 1. Defaults to the deployment cap. */
313
334
  maxBytes?: number;
314
335
  }
315
336
  export interface BatchCall {
@@ -339,12 +360,13 @@ export interface SkillArgs {
339
360
  /**
340
361
  * The nine meta-tool handlers over a registry. Exported for direct testing;
341
362
  * registerMetaTools() wires them onto an McpServer. `opts.maxResultBytes`
342
- * overrides the registry's default result-size cap; `opts.defaultToolTimeoutMs`
363
+ * overrides the registry's default result-size cap (a connector's own
364
+ * `maxResultBytes` overrides it in turn); `opts.defaultToolTimeoutMs`
343
365
  * supplies a deadline for calls that don't carry one. (execute_code, the
344
366
  * optional tenth tool, is registered separately by registerExecuteTool.)
345
367
  */
346
368
  export function createMetaTools(
347
- registry: Registry,
369
+ registry: RegistryView,
348
370
  baseUrl: string,
349
371
  opts: {
350
372
  maxResultBytes?: number;
@@ -355,7 +377,10 @@ export function createMetaTools(
355
377
  activity?: ActivityRequestContext;
356
378
  } = {},
357
379
  ) {
358
- const cap = opts.maxResultBytes ?? registry.maxResultBytes;
380
+ const globalCap = resolveMaxResultBytes(
381
+ opts.maxResultBytes,
382
+ registry.maxResultBytes,
383
+ );
359
384
  const defaultToolTimeoutMs = normalizeTimeoutMs(opts.defaultToolTimeoutMs);
360
385
  const probeTimeoutMs =
361
386
  normalizeTimeoutMs(opts.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
@@ -442,6 +467,16 @@ export function createMetaTools(
442
467
  );
443
468
  }
444
469
  const results = registry.resultsStorage();
470
+ // Result-size cap for THIS call: the connector's own override wins, then
471
+ // the deployment-wide value, then the built-in default (already folded
472
+ // into `globalCap`). Resolved per call so one batch_call can mix a
473
+ // tight-capped connector with siblings on the global cap. An override the
474
+ // registry already warned about at startup is dropped here, so the
475
+ // connector simply inherits `globalCap`.
476
+ const cap = resolveMaxResultBytes(
477
+ resolved.connector.maxResultBytes,
478
+ globalCap,
479
+ );
445
480
  const fields = call.fields && call.fields.length > 0 ? call.fields : null;
446
481
  // An explicit per-call deadline always wins; the config default only fills
447
482
  // the gap, and stays off entirely when the deployment sets none.
@@ -632,6 +667,7 @@ export function createMetaTools(
632
667
 
633
668
  return {
634
669
  async skills(args: SkillArgs = {}): Promise<ToolResult> {
670
+ const connectors = registry.listConnectors();
635
671
  if (!args.name) {
636
672
  return {
637
673
  content: [
@@ -639,19 +675,15 @@ export function createMetaTools(
639
675
  type: "text",
640
676
  text:
641
677
  'Available skills. Fetch one with skills({ name: "<name>" }).\n\n' +
642
- AVAILABLE_SKILLS.map(
643
- (skill) => `- \`${skill.name}\` — ${skill.description}`,
644
- ).join("\n"),
678
+ listSkills(connectors)
679
+ .map((skill) => `- \`${skill.name}\` — ${skill.description}`)
680
+ .join("\n"),
645
681
  },
646
682
  ],
647
683
  };
648
684
  }
649
- const skill = AVAILABLE_SKILLS.find((item) => item.name === args.name);
650
- if (!skill) {
651
- return errorResult(
652
- `Unknown skill "${args.name}". Available: ${AVAILABLE_SKILLS.map((item) => item.name).join(", ")}.`,
653
- );
654
- }
685
+ const skill = resolveSkill(args.name, connectors);
686
+ if (!skill.found) return errorResult(skill.message);
655
687
  return { content: [{ type: "text", text: skill.content }] };
656
688
  },
657
689
 
@@ -679,12 +711,17 @@ export function createMetaTools(
679
711
  status = { state: "error", message: msg(err) };
680
712
  }
681
713
  } else {
714
+ // "error" comes from THIS view's own observations — a sibling
715
+ // toolkit's failure is not this session's experience — while
716
+ // ok/unknown may lean on the deployment-wide success signal, since
717
+ // "the connector answers at all" is a fact about the connector.
718
+ // Unscoped, the two are the same log, so this is unchanged there.
682
719
  status = {
683
720
  state:
684
721
  observed?.consecutiveFailures &&
685
722
  observed.consecutiveFailures > 0
686
723
  ? ("error" as const)
687
- : observed?.lastSuccessAt || c.kind === "api"
724
+ : registry.hasObservedSuccess(c.id) || c.kind === "api"
688
725
  ? ("ok" as const)
689
726
  : ("unknown" as const),
690
727
  ...(observed?.lastError ? { message: observed.lastError } : {}),
@@ -742,6 +779,7 @@ export function createMetaTools(
742
779
  connectorId: string;
743
780
  connectorTitle?: string;
744
781
  connectorDescription?: string;
782
+ connectorGuideSkill?: string;
745
783
  tool: ToolDef;
746
784
  score: number;
747
785
  order: number;
@@ -764,6 +802,9 @@ export function createMetaTools(
764
802
  connectorId: c.id,
765
803
  connectorTitle: c.title,
766
804
  connectorDescription: c.description,
805
+ ...(connectorGuide(c)
806
+ ? { connectorGuideSkill: connectorSkillName(c.id) }
807
+ : {}),
767
808
  tool: ranked.tool,
768
809
  score: ranked.score,
769
810
  order: orderBase + ranked.order,
@@ -778,6 +819,8 @@ export function createMetaTools(
778
819
  id: string;
779
820
  title?: string;
780
821
  description?: string;
822
+ /** Skill name of this connector's usage guide, when it has one. */
823
+ guide?: string;
781
824
  tools: Array<{
782
825
  name: string;
783
826
  address: string;
@@ -795,6 +838,9 @@ export function createMetaTools(
795
838
  id: match.connectorId,
796
839
  ...(match.connectorTitle ? { title: match.connectorTitle } : {}),
797
840
  description: match.connectorDescription,
841
+ ...(match.connectorGuideSkill
842
+ ? { guide: match.connectorGuideSkill }
843
+ : {}),
798
844
  tools: [],
799
845
  };
800
846
  byConnector.set(match.connectorId, group);
@@ -899,6 +945,9 @@ export function createMetaTools(
899
945
  tool.description,
900
946
  args.fullDescriptions === true,
901
947
  ),
948
+ ...(connectorGuide(resolved.connector)
949
+ ? { guide: connectorSkillName(resolved.connector.id) }
950
+ : {}),
902
951
  inputSchema: format === "json" ? schema : compactSchema(schema),
903
952
  ...(tool.outputSchema
904
953
  ? {
@@ -925,6 +974,20 @@ export function createMetaTools(
925
974
  },
926
975
 
927
976
  async getResult(args: GetResultArgs): Promise<ToolResult> {
977
+ // Client-supplied page size: a normal input-validation error, not a
978
+ // clamp. Callers arriving over MCP are rejected earlier by the
979
+ // registered zod schema and never reach this branch, so it exists for
980
+ // in-process callers of createMetaTools — which have no schema in front
981
+ // of them — and to keep the rule true of the handler on its own terms.
982
+ if (
983
+ args.maxBytes !== undefined &&
984
+ !isValidMaxResultBytes(args.maxBytes)
985
+ ) {
986
+ return errorResult(
987
+ `Invalid maxBytes ${args.maxBytes}: must be a whole number of bytes ` +
988
+ `>= ${MIN_MAX_RESULT_BYTES}. Omit it to use the deployment default.`,
989
+ );
990
+ }
928
991
  const results = registry.resultsStorage();
929
992
  const stored = await results.get(`result:${args.id}`);
930
993
  if (stored === null || stored === undefined) {
@@ -933,7 +996,11 @@ export function createMetaTools(
933
996
  const bytes = enc.encode(stored);
934
997
  const total = bytes.length;
935
998
  const offset = Math.max(0, Math.trunc(args.offset ?? 0));
936
- const maxBytes = args.maxBytes ?? cap;
999
+ // Page size only: a stashed result carries no connector identity, so
1000
+ // get_result keeps the deployment-wide default when none is requested.
1001
+ // Both sides are validated by now — the argument above, `globalCap` at
1002
+ // intake — so `offset + maxBytes` always reaches past `offset`.
1003
+ const maxBytes = args.maxBytes ?? globalCap;
937
1004
  // Align the slice end to a codepoint boundary so a multi-byte char is
938
1005
  // never split across pages (which would emit U+FFFD on both sides).
939
1006
  // `nextOffset` is this aligned end, so it is a valid boundary for the
@@ -1076,7 +1143,7 @@ const CALL_DESC =
1076
1143
  const CALL_DESTRUCTIVE_DESC =
1077
1144
  "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.";
1078
1145
  const GET_RESULT_DESC =
1079
- "Page a truncated result stashed by call_tool/batch_call. Input { id, offset?, maxBytes? } → { text, offset, nextOffset?, totalBytes } sliced by byte offset. Unknown/expired id is an error.";
1146
+ "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). Unknown/expired id is an error.";
1080
1147
  const BATCH_DESC =
1081
1148
  "Use for 2–10 independent tools explicitly annotated readOnlyHint: true. Calls run in parallel with shared request-scoped clients; use execute_code when available instead for dependencies or in-sandbox reduction. Unannotated, write-capable, and destructive tools are refused. Batch timeout, safe retry, result mode, and diagnostics defaults may be overridden per call.";
1082
1149
  const AUTHORIZE_DESC =
@@ -1084,6 +1151,37 @@ const AUTHORIZE_DESC =
1084
1151
  const SKILLS_DESC =
1085
1152
  'List or fetch concise guidance for choosing among Connecta meta-tools. Call skills({ name: "usage" }) once when the routing workflow is unfamiliar; do not refetch it in the same task.';
1086
1153
 
1154
+ /**
1155
+ * Sentences appended to a meta-tool description only when this connection
1156
+ * actually has connector guides. Tool descriptions are always-loaded context,
1157
+ * so a deployment with no guides gets every base description unchanged rather
1158
+ * than paying for text about a feature it does not use.
1159
+ *
1160
+ * Registration is per connection and reads the connection's own registry view,
1161
+ * so under a toolkit these sentences reflect the SCOPED connector set: a scoped
1162
+ * session whose connectors carry no guides sees the base descriptions, and
1163
+ * never learns from a tool description that guides exist out of scope.
1164
+ */
1165
+ const GUIDE_NOTES = {
1166
+ skills:
1167
+ ' skills({}) also lists this deployment\'s per-connector usage guides as "connector:<connectorId>"; fetch the guide for a connector before working with it for the first time.',
1168
+ search:
1169
+ " A connector group carrying `guide` has a usage guide; fetch it with skills({ name: <guide> }).",
1170
+ describe:
1171
+ " An entry carrying `guide` belongs to a connector with a usage guide; fetch it with skills({ name: <guide> }).",
1172
+ } as const;
1173
+
1174
+ /** `base`, plus its guide note when any VISIBLE connector carries a guide. */
1175
+ function describedFor(
1176
+ registry: RegistryView,
1177
+ base: string,
1178
+ note: keyof typeof GUIDE_NOTES,
1179
+ ): string {
1180
+ return hasConnectorGuides(registry.listConnectors())
1181
+ ? base + GUIDE_NOTES[note]
1182
+ : base;
1183
+ }
1184
+
1087
1185
  /**
1088
1186
  * Connecta refuses downstream tools that are not explicitly annotated
1089
1187
  * read-only, so its own meta-tools must carry the same hints — otherwise a
@@ -1107,7 +1205,7 @@ const READ_ONLY_LOCAL = {
1107
1205
  /** Register the nine meta-tools onto an McpServer instance. */
1108
1206
  export function registerMetaTools(
1109
1207
  server: McpServer,
1110
- registry: Registry,
1208
+ registry: RegistryView,
1111
1209
  ctx: {
1112
1210
  baseUrl: string;
1113
1211
  maxResultBytes?: number;
@@ -1126,7 +1224,7 @@ export function registerMetaTools(
1126
1224
  server.registerTool(
1127
1225
  "skills",
1128
1226
  {
1129
- description: SKILLS_DESC,
1227
+ description: describedFor(registry, SKILLS_DESC, "skills"),
1130
1228
  inputSchema: { name: z.string().optional() },
1131
1229
  annotations: {
1132
1230
  readOnlyHint: true,
@@ -1151,7 +1249,7 @@ export function registerMetaTools(
1151
1249
  server.registerTool(
1152
1250
  "search_tools",
1153
1251
  {
1154
- description: SEARCH_DESC,
1252
+ description: describedFor(registry, SEARCH_DESC, "search"),
1155
1253
  inputSchema: {
1156
1254
  query: z.string().optional(),
1157
1255
  connector: z.string().optional(),
@@ -1168,7 +1266,7 @@ export function registerMetaTools(
1168
1266
  server.registerTool(
1169
1267
  "describe_tools",
1170
1268
  {
1171
- description: DESCRIBE_DESC,
1269
+ description: describedFor(registry, DESCRIBE_DESC, "describe"),
1172
1270
  inputSchema: {
1173
1271
  addresses: z.array(z.string()),
1174
1272
  format: z.enum(["compact", "json"]).optional(),
@@ -1247,7 +1345,10 @@ export function registerMetaTools(
1247
1345
  inputSchema: {
1248
1346
  id: z.string(),
1249
1347
  offset: z.number().int().nonnegative().optional(),
1250
- maxBytes: z.number().int().positive().optional(),
1348
+ // Same rule as isValidMaxResultBytes, expressed for the wire: sharing
1349
+ // the floor constant keeps the schema from drifting away from the
1350
+ // in-handler check if MIN_MAX_RESULT_BYTES ever moves.
1351
+ maxBytes: z.number().int().min(MIN_MAX_RESULT_BYTES).optional(),
1251
1352
  },
1252
1353
  annotations: READ_ONLY_LOCAL,
1253
1354
  },