@zackbart/connecta 0.4.0 → 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 (82) hide show
  1. package/CHANGELOG.md +252 -0
  2. package/README.md +72 -17
  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 +23 -0
  9. package/dist/connectors/api.d.ts.map +1 -1
  10. package/dist/connectors/api.js +13 -1
  11. package/dist/connectors/api.js.map +1 -1
  12. package/dist/connectors/remote-mcp.d.ts +27 -1
  13. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  14. package/dist/connectors/remote-mcp.js +31 -0
  15. package/dist/connectors/remote-mcp.js.map +1 -1
  16. package/dist/credentials.d.ts +2 -1
  17. package/dist/credentials.d.ts.map +1 -1
  18. package/dist/credentials.js +4 -2
  19. package/dist/credentials.js.map +1 -1
  20. package/dist/execute.d.ts +4 -4
  21. package/dist/execute.d.ts.map +1 -1
  22. package/dist/execute.js.map +1 -1
  23. package/dist/executors/quickjs.d.ts.map +1 -1
  24. package/dist/executors/quickjs.js +32 -4
  25. package/dist/executors/quickjs.js.map +1 -1
  26. package/dist/index.d.ts +51 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +67 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/meta-tools.d.ts +25 -4
  31. package/dist/meta-tools.d.ts.map +1 -1
  32. package/dist/meta-tools.js +138 -24
  33. package/dist/meta-tools.js.map +1 -1
  34. package/dist/registry.d.ts +183 -2
  35. package/dist/registry.d.ts.map +1 -1
  36. package/dist/registry.js +293 -27
  37. package/dist/registry.js.map +1 -1
  38. package/dist/server.d.ts +9 -1
  39. package/dist/server.d.ts.map +1 -1
  40. package/dist/server.js +96 -12
  41. package/dist/server.js.map +1 -1
  42. package/dist/skills.d.ts +52 -1
  43. package/dist/skills.d.ts.map +1 -1
  44. package/dist/skills.js +161 -1
  45. package/dist/skills.js.map +1 -1
  46. package/dist/storage/file.d.ts.map +1 -1
  47. package/dist/storage/file.js +19 -3
  48. package/dist/storage/file.js.map +1 -1
  49. package/dist/toolkits.d.ts +44 -0
  50. package/dist/toolkits.d.ts.map +1 -0
  51. package/dist/toolkits.js +134 -0
  52. package/dist/toolkits.js.map +1 -0
  53. package/dist/types.d.ts +20 -1
  54. package/dist/types.d.ts.map +1 -1
  55. package/dist/ui.d.ts +29 -1
  56. package/dist/ui.d.ts.map +1 -1
  57. package/dist/ui.js +100 -15
  58. package/dist/ui.js.map +1 -1
  59. package/dist/validate.d.ts +33 -1
  60. package/dist/validate.d.ts.map +1 -1
  61. package/dist/validate.js +32 -2
  62. package/dist/validate.js.map +1 -1
  63. package/dist/version.d.ts +1 -1
  64. package/dist/version.js +1 -1
  65. package/package.json +5 -2
  66. package/src/activity.ts +9 -0
  67. package/src/connectors/api.ts +36 -1
  68. package/src/connectors/remote-mcp.ts +67 -0
  69. package/src/credentials.ts +6 -3
  70. package/src/execute.ts +4 -4
  71. package/src/executors/quickjs.ts +32 -4
  72. package/src/index.ts +141 -2
  73. package/src/meta-tools.ts +215 -40
  74. package/src/registry.ts +416 -29
  75. package/src/server.ts +130 -12
  76. package/src/skills.ts +184 -1
  77. package/src/storage/file.ts +18 -2
  78. package/src/toolkits.ts +215 -0
  79. package/src/types.ts +20 -1
  80. package/src/ui.ts +103 -14
  81. package/src/validate.ts +60 -2
  82. package/src/version.ts +1 -1
package/src/index.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { CredentialVault } from "./credentials.js";
2
2
  import { Registry } from "./registry.js";
3
3
  import { createFetchHandler } from "./server.js";
4
+ import { droppedBrandingUrls } from "./ui.js";
5
+ import { resolveToolkits, type ToolkitConfig } from "./toolkits.js";
4
6
  import { memoryStorage } from "./storage/memory.js";
5
7
  import { CONNECTA_VERSION } from "./version.js";
6
8
  import type { ActivityReadGate, ActivityStore } from "./activity.js";
@@ -15,6 +17,36 @@ import type {
15
17
 
16
18
  export interface ConnectaConfig {
17
19
  connectors: Connector[];
20
+ /**
21
+ * Named scoped views over `connectors`, selected per client connection with
22
+ * `?toolkit=<name>` on the `/mcp` URL. One deployment belongs to one org;
23
+ * a toolkit is the slice of it a group of team members sees.
24
+ *
25
+ * ```ts
26
+ * toolkits: {
27
+ * support: { connectors: ["zendesk", "notion"] },
28
+ * exec: {
29
+ * connectors: ["zendesk", "notion", "gmail"],
30
+ * excludeTools: ["gmail.send_message"],
31
+ * },
32
+ * }
33
+ * ```
34
+ *
35
+ * Inside a toolkit-scoped session every meta-tool behaves as if out-of-scope
36
+ * connectors and tools do not exist, and an out-of-scope address fails
37
+ * exactly as a nonexistent one does. No `?toolkit=` ⇒ the full registry, so
38
+ * adding toolkits changes nothing for connections that don't ask for one; an
39
+ * unknown name is an error, never a silent fallback.
40
+ *
41
+ * Toolkits scope VISIBILITY, not identity: they do not decide *which* team
42
+ * member may select which toolkit. Gate that in `auth` (per-member binding is
43
+ * a follow-up).
44
+ *
45
+ * Definitions are validated at construction: an unknown connector id, an
46
+ * empty connector selection, an empty `includeTools`, a malformed tool
47
+ * address, or an address naming no tool on an in-code connector all throw.
48
+ */
49
+ toolkits?: ToolkitConfig;
18
50
  /**
19
51
  * Privacy-minimal downstream tool activity storage. Writes are best-effort
20
52
  * and never change tool results. Implement `list` to enable the Activity UI.
@@ -58,7 +90,9 @@ export interface ConnectaConfig {
58
90
  toolCatalogStaleSeconds?: number;
59
91
  /**
60
92
  * Max inline result size (bytes) before call_tool/batch_call truncate and
61
- * stash the full text for get_result paging. Default 50_000.
93
+ * stash the full text for get_result paging. Must be a whole number of bytes
94
+ * >= 1; anything else (0, negative, fractional, NaN, Infinity) warns at
95
+ * startup and falls back to the default 50_000.
62
96
  */
63
97
  maxResultBytes?: number;
64
98
  /**
@@ -77,6 +111,22 @@ export interface ConnectaConfig {
77
111
  * not explicitly ask to retry.
78
112
  */
79
113
  defaultToolTimeoutMs?: number;
114
+ /**
115
+ * Deadline (ms) applied to each individual downstream probe/catalog call that
116
+ * the discovery meta-tools fan out — `list_connectors` (with `probe`),
117
+ * `search_tools`, and `describe_tools` — so a single hung connector can no
118
+ * longer stall the whole meta-tool call. **Defaults to a generous 30_000**,
119
+ * chosen to trip only on a pathological hang, not on a realistically slow
120
+ * probe, so having it on by default will not break existing deployments.
121
+ * Bounds one downstream call, not the whole fan-out: a connector that outruns
122
+ * it degrades to an unavailable/errored entry while the rest are unaffected.
123
+ *
124
+ * Does NOT apply to `call_tool`/`batch_call` — those carry their own budget
125
+ * via `defaultToolTimeoutMs` or a per-call `timeoutMs`. Note this bounds the
126
+ * caller-facing wait only; the underlying fetch is not currently aborted, so
127
+ * real cancellation of the downstream request is a deferred follow-up.
128
+ */
129
+ probeTimeoutMs?: number;
80
130
  serverInfo?: {
81
131
  name?: string;
82
132
  version?: string;
@@ -122,6 +172,83 @@ function normalizeAuth(auth: ConnectaConfig["auth"]): InboundAuth[] {
122
172
  });
123
173
  }
124
174
 
175
+ /**
176
+ * One-time construction warnings for deployment shapes that run fine but are
177
+ * usually unintended. Warning-only — never throws and never changes behavior;
178
+ * each condition emits at most one `logger.warn`. Iterates connectors once.
179
+ */
180
+ function warnInsecureConfig(
181
+ config: ConnectaConfig,
182
+ inboundAuth: InboundAuth[],
183
+ logger: Logger,
184
+ ): void {
185
+ const oauthConnectors = config.connectors.filter((c) => c.finishAuth);
186
+ const hasCredentialConnector = config.connectors.some((c) => c.credential);
187
+
188
+ // Open mode (no inbound auth) with connectors that expose credentials or
189
+ // downstream OAuth: any caller reaches everything, including the vault.
190
+ if (
191
+ inboundAuth.length === 0 &&
192
+ (hasCredentialConnector || oauthConnectors.length > 0)
193
+ ) {
194
+ logger.warn(
195
+ "[connecta] running with no inbound authentication: any caller can " +
196
+ "invoke every connector and read or overwrite stored credentials. " +
197
+ "Configure `auth` (for example bearerToken(...) or Clerk) to gate access.",
198
+ );
199
+ }
200
+
201
+ // Unset publicUrl with OAuth connectors: the downstream redirect_uri is
202
+ // derived per-request from the attacker-influenced inbound Host header.
203
+ if (oauthConnectors.length > 0 && !config.publicUrl) {
204
+ logger.warn(
205
+ "[connecta] publicUrl is unset while OAuth connectors are configured: " +
206
+ "the downstream OAuth redirect_uri is derived per-request from the " +
207
+ "inbound Host header, so an attacker who controls that header can point " +
208
+ "it at their own host and capture the authorization code. Set " +
209
+ "`publicUrl` to a fixed https origin.",
210
+ );
211
+ }
212
+
213
+ // Toolkits with no inbound auth: a toolkit is a scoped VIEW selected by the
214
+ // caller, not an authentication boundary. With nothing gating /mcp, any
215
+ // caller picks any toolkit — or omits the parameter and sees everything.
216
+ if (inboundAuth.length === 0 && config.toolkits) {
217
+ logger.warn(
218
+ "[connecta] toolkits are configured but there is no inbound " +
219
+ "authentication: a toolkit is a scoped view a client selects with " +
220
+ "?toolkit=, not an access check, so any caller can choose any toolkit " +
221
+ "or omit the parameter and see every connector. Configure `auth` " +
222
+ "(for example bearerToken(...) or Clerk).",
223
+ );
224
+ }
225
+
226
+ // Branding URLs that failed their scheme gate. Rendering silently falls back
227
+ // (a bad URL must not take the page down), so this warning is the only way an
228
+ // operator learns their value never reached the page.
229
+ const dropped = droppedBrandingUrls(config.branding);
230
+ if (dropped.length > 0) {
231
+ logger.warn(
232
+ `[connecta] branding ${dropped.join(", ")} dropped: a branding URL is ` +
233
+ "used as an href, so it must be an absolute http(s) URL (favicon.href " +
234
+ "may also be a root-relative path). The default is rendered instead.",
235
+ );
236
+ }
237
+
238
+ // OAuth connectors whose callback performs no state/CSRF check: the public
239
+ // /oauth/callback/<id> route would exchange any delivered code.
240
+ for (const connector of oauthConnectors) {
241
+ if (!connector.verifyState) {
242
+ logger.warn(
243
+ `[connecta] connector "${connector.id}" has an OAuth callback with no ` +
244
+ `state/CSRF check: /oauth/callback/${connector.id} will exchange any ` +
245
+ "delivered code. Implement `verifyState` (the shipped remoteMcp " +
246
+ "connector already does).",
247
+ );
248
+ }
249
+ }
250
+ }
251
+
125
252
  export function createConnecta(config: ConnectaConfig): Connecta {
126
253
  const storage = config.storage ?? memoryStorage();
127
254
  const logger = config.logger ?? defaultLogger();
@@ -143,9 +270,16 @@ export function createConnecta(config: ConnectaConfig): Connecta {
143
270
  toolCatalogStaleSeconds: config.toolCatalogStaleSeconds,
144
271
  maxResultBytes: config.maxResultBytes,
145
272
  });
273
+ // Throws on every structural mistake it can see (see resolveToolkits): a
274
+ // typo must not become a scope the operator never wrote. Note this is about
275
+ // the scope being *intended*, not about it being an access check — a toolkit
276
+ // scopes visibility, and `auth` remains the thing deciding who gets in.
277
+ const toolkits = resolveToolkits(config.toolkits, config.connectors);
278
+ const inboundAuth = normalizeAuth(config.auth);
279
+ warnInsecureConfig(config, inboundAuth, logger);
146
280
  const handler = createFetchHandler({
147
281
  registry,
148
- auth: normalizeAuth(config.auth),
282
+ auth: inboundAuth,
149
283
  publicUrl: config.publicUrl,
150
284
  serverInfo: {
151
285
  ...config.serverInfo,
@@ -158,9 +292,11 @@ export function createConnecta(config: ConnectaConfig): Connecta {
158
292
  activityDeploymentId: config.activityDeploymentId,
159
293
  executor: config.executor,
160
294
  defaultToolTimeoutMs: config.defaultToolTimeoutMs,
295
+ probeTimeoutMs: config.probeTimeoutMs,
161
296
  credentialVault,
162
297
  deploymentInfo: config.deploymentInfo,
163
298
  branding: config.branding,
299
+ ...(toolkits ? { toolkits } : {}),
164
300
  });
165
301
  return {
166
302
  fetch: (request, _env, ctx) =>
@@ -190,6 +326,9 @@ export { CONNECTA_VERSION } from "./version.js";
190
326
  // the class itself, the credential vault, and the meta-tool/sandbox factories
191
327
  // are internal factoring and are deliberately not part of the API surface.
192
328
  export type { Registry } from "./registry.js";
329
+ // Config-as-code shapes for `ConnectaConfig.toolkits`. The resolved `Toolkit`
330
+ // and the `ScopedRegistry` that enforces it are internal factoring.
331
+ export type { ToolkitConfig, ToolkitDefinition } from "./toolkits.js";
193
332
 
194
333
  export type { RemoteMcpOptions, RemoteMcpAuth } from "./connectors/remote-mcp.js";
195
334
  export type { ApiOptions, ApiTool } from "./connectors/api.js";
package/src/meta-tools.ts CHANGED
@@ -13,9 +13,20 @@ 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";
18
- import type { KVStorage, ToolDef } from "./types.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";
29
+ import type { ConnectorStatus, KVStorage, ToolDef } from "./types.js";
19
30
 
20
31
  interface TextContent {
21
32
  type: "text";
@@ -72,6 +83,42 @@ function normalizeTimeoutMs(value: number | undefined): number | undefined {
72
83
  return Math.max(1, Math.trunc(value));
73
84
  }
74
85
 
86
+ /**
87
+ * Generous default bound for a single downstream probe/catalog call in the
88
+ * list/search/describe fan-out. High enough to trip only on a pathological
89
+ * hang, not a realistically slow probe.
90
+ */
91
+ const DEFAULT_PROBE_TIMEOUT_MS = 30_000;
92
+
93
+ /**
94
+ * Reject `promise` after `ms` if it has not settled, so one hung downstream
95
+ * cannot stall a whole fan-out. NOTE: this bounds only the caller-facing wait —
96
+ * the registry probe methods take no AbortSignal, so the underlying fetch is
97
+ * NOT cancelled and keeps running in the background. Real cancellation
98
+ * (AbortSignal plumbed through the registry) is a deferred follow-up.
99
+ */
100
+ function withTimeout<T>(
101
+ promise: Promise<T>,
102
+ ms: number,
103
+ label: string,
104
+ ): Promise<T> {
105
+ return new Promise<T>((resolve, reject) => {
106
+ const timer = setTimeout(() => {
107
+ reject(new Error(`${label} timed out after ${ms}ms`));
108
+ }, ms);
109
+ promise.then(
110
+ (value) => {
111
+ clearTimeout(timer);
112
+ resolve(value);
113
+ },
114
+ (err) => {
115
+ clearTimeout(timer);
116
+ reject(err);
117
+ },
118
+ );
119
+ });
120
+ }
121
+
75
122
  /**
76
123
  * How long to wait before the next attempt, or `undefined` for "don't retry".
77
124
  *
@@ -114,19 +161,28 @@ function isContinuationByte(b: number): boolean {
114
161
  * forward to the end of that codepoint instead so paging always advances.
115
162
  * Assumes `offset` is itself a codepoint boundary (offsets are the prior
116
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.
117
170
  */
118
- function alignEndToCharBoundary(
171
+ export function alignEndToCharBoundary(
119
172
  bytes: Uint8Array,
120
173
  offset: number,
121
174
  end: number,
122
175
  total: number,
123
176
  ): number {
124
177
  if (end >= total) return total;
125
- 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;
126
182
  while (e > offset && isContinuationByte(bytes[e])) e--;
127
183
  if (e === offset) {
128
184
  // Window is narrower than the codepoint at `offset`; take the whole thing.
129
- e = end;
185
+ e = wanted;
130
186
  while (e < total && isContinuationByte(bytes[e])) e++;
131
187
  }
132
188
  return e;
@@ -274,6 +330,7 @@ export interface CallArgs {
274
330
  export interface GetResultArgs {
275
331
  id: string;
276
332
  offset?: number;
333
+ /** Page size in bytes; a whole number >= 1. Defaults to the deployment cap. */
277
334
  maxBytes?: number;
278
335
  }
279
336
  export interface BatchCall {
@@ -303,22 +360,30 @@ export interface SkillArgs {
303
360
  /**
304
361
  * The nine meta-tool handlers over a registry. Exported for direct testing;
305
362
  * registerMetaTools() wires them onto an McpServer. `opts.maxResultBytes`
306
- * 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`
307
365
  * supplies a deadline for calls that don't carry one. (execute_code, the
308
366
  * optional tenth tool, is registered separately by registerExecuteTool.)
309
367
  */
310
368
  export function createMetaTools(
311
- registry: Registry,
369
+ registry: RegistryView,
312
370
  baseUrl: string,
313
371
  opts: {
314
372
  maxResultBytes?: number;
315
373
  /** Deadline applied when a call passes no `timeoutMs`. Off when unset. */
316
374
  defaultToolTimeoutMs?: number;
375
+ /** Per-connector deadline for the list/search/describe probe fan-out. Default 30_000. */
376
+ probeTimeoutMs?: number;
317
377
  activity?: ActivityRequestContext;
318
378
  } = {},
319
379
  ) {
320
- const cap = opts.maxResultBytes ?? registry.maxResultBytes;
380
+ const globalCap = resolveMaxResultBytes(
381
+ opts.maxResultBytes,
382
+ registry.maxResultBytes,
383
+ );
321
384
  const defaultToolTimeoutMs = normalizeTimeoutMs(opts.defaultToolTimeoutMs);
385
+ const probeTimeoutMs =
386
+ normalizeTimeoutMs(opts.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
322
387
  // createMetaTools() is called once per inbound MCP request. Sharing this
323
388
  // identity lets remote connectors reuse one downstream client inside that
324
389
  // request without leaking request-bound I/O into the next one.
@@ -402,6 +467,16 @@ export function createMetaTools(
402
467
  );
403
468
  }
404
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
+ );
405
480
  const fields = call.fields && call.fields.length > 0 ? call.fields : null;
406
481
  // An explicit per-call deadline always wins; the config default only fills
407
482
  // the gap, and stays off entirely when the deployment sets none.
@@ -592,6 +667,7 @@ export function createMetaTools(
592
667
 
593
668
  return {
594
669
  async skills(args: SkillArgs = {}): Promise<ToolResult> {
670
+ const connectors = registry.listConnectors();
595
671
  if (!args.name) {
596
672
  return {
597
673
  content: [
@@ -599,19 +675,15 @@ export function createMetaTools(
599
675
  type: "text",
600
676
  text:
601
677
  'Available skills. Fetch one with skills({ name: "<name>" }).\n\n' +
602
- AVAILABLE_SKILLS.map(
603
- (skill) => `- \`${skill.name}\` — ${skill.description}`,
604
- ).join("\n"),
678
+ listSkills(connectors)
679
+ .map((skill) => `- \`${skill.name}\` — ${skill.description}`)
680
+ .join("\n"),
605
681
  },
606
682
  ],
607
683
  };
608
684
  }
609
- const skill = AVAILABLE_SKILLS.find((item) => item.name === args.name);
610
- if (!skill) {
611
- return errorResult(
612
- `Unknown skill "${args.name}". Available: ${AVAILABLE_SKILLS.map((item) => item.name).join(", ")}.`,
613
- );
614
- }
685
+ const skill = resolveSkill(args.name, connectors);
686
+ if (!skill.found) return errorResult(skill.message);
615
687
  return { content: [{ type: "text", text: skill.content }] };
616
688
  },
617
689
 
@@ -622,25 +694,50 @@ export function createMetaTools(
622
694
  const checkedAt = new Date().toISOString();
623
695
  const statusStarted = Date.now();
624
696
  const observed = registry.healthFor(c.id);
625
- let status = probe
626
- ? await registry.statusFor(c.id, baseUrl, requestScope)
627
- : {
628
- state:
629
- observed?.consecutiveFailures &&
630
- observed.consecutiveFailures > 0
631
- ? ("error" as const)
632
- : observed?.lastSuccessAt || c.kind === "api"
633
- ? ("ok" as const)
634
- : ("unknown" as const),
635
- ...(observed?.lastError ? { message: observed.lastError } : {}),
636
- };
697
+ let status:
698
+ | ConnectorStatus
699
+ | { state: "ok" | "error" | "unknown"; message?: string };
700
+ if (probe) {
701
+ try {
702
+ status = await withTimeout(
703
+ registry.statusFor(c.id, baseUrl, requestScope),
704
+ probeTimeoutMs,
705
+ `list_connectors probe of "${c.id}"`,
706
+ );
707
+ } catch (err) {
708
+ // A probe that outran probeTimeoutMs (or otherwise threw)
709
+ // degrades this connector to an error status rather than
710
+ // hanging the whole list_connectors call.
711
+ status = { state: "error", message: msg(err) };
712
+ }
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.
719
+ status = {
720
+ state:
721
+ observed?.consecutiveFailures &&
722
+ observed.consecutiveFailures > 0
723
+ ? ("error" as const)
724
+ : registry.hasObservedSuccess(c.id) || c.kind === "api"
725
+ ? ("ok" as const)
726
+ : ("unknown" as const),
727
+ ...(observed?.lastError ? { message: observed.lastError } : {}),
728
+ };
729
+ }
637
730
  let tools = registry.peekTools(c.id);
638
731
  // An auth_required status may have just started OAuth. A second
639
732
  // listTools probe would overwrite its state/verifier while returning
640
733
  // the first (now stale) authorization URL.
641
734
  if (probe && status.state === "ok") {
642
735
  try {
643
- tools = await registry.refreshTools(c.id, baseUrl, requestScope);
736
+ tools = await withTimeout(
737
+ registry.refreshTools(c.id, baseUrl, requestScope),
738
+ probeTimeoutMs,
739
+ `list_connectors catalog refresh of "${c.id}"`,
740
+ );
644
741
  registry.recordSuccess(c.id, Date.now() - statusStarted);
645
742
  } catch (err) {
646
743
  status = { state: "error" as const, message: msg(err) };
@@ -682,12 +779,19 @@ export function createMetaTools(
682
779
  connectorId: string;
683
780
  connectorTitle?: string;
684
781
  connectorDescription?: string;
782
+ connectorGuideSkill?: string;
685
783
  tool: ToolDef;
686
784
  score: number;
687
785
  order: number;
688
786
  }> = [];
689
787
  const catalogs = await Promise.allSettled(
690
- conns.map((c) => registry.getTools(c.id, baseUrl, requestScope)),
788
+ conns.map((c) =>
789
+ withTimeout(
790
+ registry.getTools(c.id, baseUrl, requestScope),
791
+ probeTimeoutMs,
792
+ `search_tools probe of "${c.id}"`,
793
+ ),
794
+ ),
691
795
  );
692
796
  let orderBase = 0;
693
797
  catalogs.forEach((catalog, connectorIndex) => {
@@ -698,6 +802,9 @@ export function createMetaTools(
698
802
  connectorId: c.id,
699
803
  connectorTitle: c.title,
700
804
  connectorDescription: c.description,
805
+ ...(connectorGuide(c)
806
+ ? { connectorGuideSkill: connectorSkillName(c.id) }
807
+ : {}),
701
808
  tool: ranked.tool,
702
809
  score: ranked.score,
703
810
  order: orderBase + ranked.order,
@@ -712,6 +819,8 @@ export function createMetaTools(
712
819
  id: string;
713
820
  title?: string;
714
821
  description?: string;
822
+ /** Skill name of this connector's usage guide, when it has one. */
823
+ guide?: string;
715
824
  tools: Array<{
716
825
  name: string;
717
826
  address: string;
@@ -729,6 +838,9 @@ export function createMetaTools(
729
838
  id: match.connectorId,
730
839
  ...(match.connectorTitle ? { title: match.connectorTitle } : {}),
731
840
  description: match.connectorDescription,
841
+ ...(match.connectorGuideSkill
842
+ ? { guide: match.connectorGuideSkill }
843
+ : {}),
732
844
  tools: [],
733
845
  };
734
846
  byConnector.set(match.connectorId, group);
@@ -791,7 +903,13 @@ export function createMetaTools(
791
903
  ),
792
904
  ];
793
905
  const loaded = await Promise.allSettled(
794
- connectorIds.map((id) => registry.getTools(id, baseUrl, requestScope)),
906
+ connectorIds.map((id) =>
907
+ withTimeout(
908
+ registry.getTools(id, baseUrl, requestScope),
909
+ probeTimeoutMs,
910
+ `describe_tools probe of "${id}"`,
911
+ ),
912
+ ),
795
913
  );
796
914
  const catalogs = new Map<string, ToolDef[] | Error>();
797
915
  loaded.forEach((result, index) => {
@@ -827,6 +945,9 @@ export function createMetaTools(
827
945
  tool.description,
828
946
  args.fullDescriptions === true,
829
947
  ),
948
+ ...(connectorGuide(resolved.connector)
949
+ ? { guide: connectorSkillName(resolved.connector.id) }
950
+ : {}),
830
951
  inputSchema: format === "json" ? schema : compactSchema(schema),
831
952
  ...(tool.outputSchema
832
953
  ? {
@@ -853,6 +974,20 @@ export function createMetaTools(
853
974
  },
854
975
 
855
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
+ }
856
991
  const results = registry.resultsStorage();
857
992
  const stored = await results.get(`result:${args.id}`);
858
993
  if (stored === null || stored === undefined) {
@@ -861,7 +996,11 @@ export function createMetaTools(
861
996
  const bytes = enc.encode(stored);
862
997
  const total = bytes.length;
863
998
  const offset = Math.max(0, Math.trunc(args.offset ?? 0));
864
- 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;
865
1004
  // Align the slice end to a codepoint boundary so a multi-byte char is
866
1005
  // never split across pages (which would emit U+FFFD on both sides).
867
1006
  // `nextOffset` is this aligned end, so it is a valid boundary for the
@@ -1004,7 +1143,7 @@ const CALL_DESC =
1004
1143
  const CALL_DESTRUCTIVE_DESC =
1005
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.";
1006
1145
  const GET_RESULT_DESC =
1007
- "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.";
1008
1147
  const BATCH_DESC =
1009
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.";
1010
1149
  const AUTHORIZE_DESC =
@@ -1012,6 +1151,37 @@ const AUTHORIZE_DESC =
1012
1151
  const SKILLS_DESC =
1013
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.';
1014
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
+
1015
1185
  /**
1016
1186
  * Connecta refuses downstream tools that are not explicitly annotated
1017
1187
  * read-only, so its own meta-tools must carry the same hints — otherwise a
@@ -1035,24 +1205,26 @@ const READ_ONLY_LOCAL = {
1035
1205
  /** Register the nine meta-tools onto an McpServer instance. */
1036
1206
  export function registerMetaTools(
1037
1207
  server: McpServer,
1038
- registry: Registry,
1208
+ registry: RegistryView,
1039
1209
  ctx: {
1040
1210
  baseUrl: string;
1041
1211
  maxResultBytes?: number;
1042
1212
  defaultToolTimeoutMs?: number;
1213
+ probeTimeoutMs?: number;
1043
1214
  activity?: ActivityRequestContext;
1044
1215
  },
1045
1216
  ): void {
1046
1217
  const mt = createMetaTools(registry, ctx.baseUrl, {
1047
1218
  maxResultBytes: ctx.maxResultBytes,
1048
1219
  defaultToolTimeoutMs: ctx.defaultToolTimeoutMs,
1220
+ probeTimeoutMs: ctx.probeTimeoutMs,
1049
1221
  activity: ctx.activity,
1050
1222
  });
1051
1223
 
1052
1224
  server.registerTool(
1053
1225
  "skills",
1054
1226
  {
1055
- description: SKILLS_DESC,
1227
+ description: describedFor(registry, SKILLS_DESC, "skills"),
1056
1228
  inputSchema: { name: z.string().optional() },
1057
1229
  annotations: {
1058
1230
  readOnlyHint: true,
@@ -1077,7 +1249,7 @@ export function registerMetaTools(
1077
1249
  server.registerTool(
1078
1250
  "search_tools",
1079
1251
  {
1080
- description: SEARCH_DESC,
1252
+ description: describedFor(registry, SEARCH_DESC, "search"),
1081
1253
  inputSchema: {
1082
1254
  query: z.string().optional(),
1083
1255
  connector: z.string().optional(),
@@ -1094,7 +1266,7 @@ export function registerMetaTools(
1094
1266
  server.registerTool(
1095
1267
  "describe_tools",
1096
1268
  {
1097
- description: DESCRIBE_DESC,
1269
+ description: describedFor(registry, DESCRIBE_DESC, "describe"),
1098
1270
  inputSchema: {
1099
1271
  addresses: z.array(z.string()),
1100
1272
  format: z.enum(["compact", "json"]).optional(),
@@ -1173,7 +1345,10 @@ export function registerMetaTools(
1173
1345
  inputSchema: {
1174
1346
  id: z.string(),
1175
1347
  offset: z.number().int().nonnegative().optional(),
1176
- 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(),
1177
1352
  },
1178
1353
  annotations: READ_ONLY_LOCAL,
1179
1354
  },