@zackbart/connecta 0.12.2 → 0.14.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 (75) hide show
  1. package/CHANGELOG.md +324 -0
  2. package/README.md +4 -1
  3. package/dist/catalog-service.d.ts +41 -0
  4. package/dist/catalog-service.d.ts.map +1 -1
  5. package/dist/catalog-service.js +127 -11
  6. package/dist/catalog-service.js.map +1 -1
  7. package/dist/connectors/api.d.ts +5 -4
  8. package/dist/connectors/api.d.ts.map +1 -1
  9. package/dist/connectors/api.js +5 -1
  10. package/dist/connectors/api.js.map +1 -1
  11. package/dist/connectors/remote-mcp.d.ts +5 -4
  12. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  13. package/dist/connectors/remote-mcp.js.map +1 -1
  14. package/dist/execute.d.ts.map +1 -1
  15. package/dist/execute.js +12 -10
  16. package/dist/execute.js.map +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/meta-tools.d.ts.map +1 -1
  21. package/dist/meta-tools.js +5 -4
  22. package/dist/meta-tools.js.map +1 -1
  23. package/dist/providers/cloudflare.d.ts +53 -0
  24. package/dist/providers/cloudflare.d.ts.map +1 -0
  25. package/dist/providers/cloudflare.js +1385 -0
  26. package/dist/providers/cloudflare.js.map +1 -0
  27. package/dist/providers/linear.d.ts +44 -0
  28. package/dist/providers/linear.d.ts.map +1 -0
  29. package/dist/providers/linear.js +243 -0
  30. package/dist/providers/linear.js.map +1 -0
  31. package/dist/providers/mixpanel.d.ts +21 -0
  32. package/dist/providers/mixpanel.d.ts.map +1 -0
  33. package/dist/providers/mixpanel.js +191 -0
  34. package/dist/providers/mixpanel.js.map +1 -0
  35. package/dist/providers/notion.d.ts +39 -0
  36. package/dist/providers/notion.d.ts.map +1 -0
  37. package/dist/providers/notion.js +1625 -0
  38. package/dist/providers/notion.js.map +1 -0
  39. package/dist/providers/stripe.d.ts +37 -0
  40. package/dist/providers/stripe.d.ts.map +1 -0
  41. package/dist/providers/stripe.js +232 -0
  42. package/dist/providers/stripe.js.map +1 -0
  43. package/dist/skills.d.ts +7 -9
  44. package/dist/skills.d.ts.map +1 -1
  45. package/dist/skills.js +58 -24
  46. package/dist/skills.js.map +1 -1
  47. package/dist/types.d.ts +26 -6
  48. package/dist/types.d.ts.map +1 -1
  49. package/dist/version.d.ts +1 -1
  50. package/dist/version.js +1 -1
  51. package/documentation/cloudflare.md +268 -0
  52. package/documentation/code-mode.md +6 -6
  53. package/documentation/connectors.md +122 -4
  54. package/documentation/linear.md +144 -0
  55. package/documentation/meta-tools.md +91 -8
  56. package/documentation/mixpanel.md +77 -0
  57. package/documentation/notion.md +233 -0
  58. package/documentation/stripe.md +202 -0
  59. package/ethos.md +8 -3
  60. package/package.json +21 -1
  61. package/src/catalog-service.ts +174 -10
  62. package/src/connectors/api.ts +10 -4
  63. package/src/connectors/remote-mcp.ts +5 -3
  64. package/src/execute.ts +18 -10
  65. package/src/index.ts +1 -0
  66. package/src/meta-tools.ts +10 -4
  67. package/src/providers/cloudflare.ts +1696 -0
  68. package/src/providers/linear.ts +301 -0
  69. package/src/providers/mixpanel.ts +228 -0
  70. package/src/providers/notion.ts +1879 -0
  71. package/src/providers/stripe.ts +306 -0
  72. package/src/skills.ts +64 -23
  73. package/src/types.ts +27 -6
  74. package/src/version.ts +1 -1
  75. package/templates/node/package.json +1 -1
@@ -0,0 +1,202 @@
1
+ # Stripe prebuilt connection
2
+
3
+ Import `stripe()` independently from `@zackbart/connecta/providers/stripe`. It
4
+ wraps [Stripe's hosted MCP server](https://docs.stripe.com/mcp) with a required
5
+ production/sandbox mode, OAuth by default, a mode-scaled admission policy, a
6
+ task-oriented usage guide, and a vetted safety classification. It adds no
7
+ provider dependency and is not reachable from Connecta's root entry.
8
+
9
+ ```ts
10
+ import { stripe } from "@zackbart/connecta/providers/stripe";
11
+
12
+ const billing = stripe("stripe_live", {
13
+ mode: "production",
14
+ title: "Stripe (production)",
15
+ purpose: "Revenue, disputes, and refunds for the real business",
16
+ instructions: "Never refund above $500 without a human in the loop.",
17
+ });
18
+ ```
19
+
20
+ The `id` owns the ordinary connector namespaces; use a different id for every
21
+ Stripe account. `purpose` is required because an agent choosing between two
22
+ instances needs to know which account answers the question. Account
23
+ `instructions` are appended to the maintained guide and cannot change the
24
+ connector's safety classification.
25
+
26
+ ## Mode is required, and it is the whole point
27
+
28
+ `mode` accepts `"production"` or `"sandbox"` and has **no default**. There is no
29
+ safe guess between an account that moves real money and one that does not, so
30
+ the deployment has to say which it configured.
31
+
32
+ Stripe publishes exactly one endpoint — `https://mcp.stripe.com/` — and the
33
+ environment is selected by the credential, not the URL. Connecta therefore
34
+ cannot *route* by mode; what it can do is make the mode impossible for an agent
35
+ to miss, and refuse a deployment whose declaration and credential disagree.
36
+
37
+ `mode` shows up in four places an agent actually reads:
38
+
39
+ - the default `title` (`Stripe (production)` / `Stripe (sandbox)`);
40
+ - the `description`, which is what `search_tools` ranks and returns — production
41
+ reads `Stripe payments (production — live money and real customers) — …`,
42
+ sandbox reads `Stripe payments (sandbox — test data, no real money) — …`;
43
+ - the first two lines of the usage guide, which state the mode and then say
44
+ either "every write moves real money … a refund cannot be undone" or "never
45
+ answer a question about live revenue, payouts, or a named customer from this
46
+ connector";
47
+ - the admission policy, below.
48
+
49
+ And one place a deployment author reads: if `auth` is a `headers` credential
50
+ carrying a recognizable Stripe key prefix (`sk_`, `rk_`, or `pk_` with `_live_`
51
+ or `_test_`), construction throws when the key's mode contradicts the declared
52
+ one. That check reads nothing it cannot classify — an OAuth connector, or a
53
+ credential shape this release does not recognize, is left alone rather than
54
+ guessed at — and the error names only the two modes, never the key.
55
+
56
+ Deploy both side by side. Two instances are isolated exactly like two
57
+ hand-written connectors with different ids: separate addresses, catalogs,
58
+ credentials, storage, admission counters, and health.
59
+
60
+ ```ts
61
+ connectors: [
62
+ stripe("stripe_live", {
63
+ mode: "production",
64
+ purpose: "Revenue, disputes, and refunds for the real business",
65
+ }),
66
+ stripe("stripe_sandbox", {
67
+ mode: "sandbox",
68
+ purpose: "Rehearsing billing changes before they touch production",
69
+ }),
70
+ ]
71
+ ```
72
+
73
+ ## Authentication
74
+
75
+ OAuth is the default and the option Stripe recommends: it supports dynamic
76
+ client registration and PKCE, and each connector instance keeps its own flow
77
+ and tokens in connector-scoped storage. Stripe also accepts a
78
+ [restricted API key](https://docs.stripe.com/keys#create-restricted-api-key) as
79
+ a bearer token for headless agents:
80
+
81
+ ```ts
82
+ stripe("stripe_sandbox", {
83
+ mode: "sandbox",
84
+ purpose: "Automated billing rehearsal",
85
+ auth: {
86
+ type: "headers",
87
+ headers: { Authorization: `Bearer ${env.STRIPE_RESTRICTED_KEY}` },
88
+ },
89
+ });
90
+ ```
91
+
92
+ Use a restricted key, not a secret key, and scope it to the operations the
93
+ agent actually needs; Stripe's own guidance is to "limit your agent's access to
94
+ exactly the functionality it requires". Keep it in the runtime's secret store.
95
+
96
+ Connect platforms can act as a connected account with `connectedAccount`, which
97
+ adds Stripe's `Stripe-Account` header. Stripe does not support OAuth for
98
+ connected-account calls, so this requires `headers` auth and throws otherwise:
99
+
100
+ ```ts
101
+ stripe("merchant_42", {
102
+ mode: "production",
103
+ purpose: "Billing questions for the merchant on account 42",
104
+ connectedAccount: "acct_1234567890",
105
+ auth: {
106
+ type: "headers",
107
+ headers: { Authorization: `Bearer ${env.STRIPE_PLATFORM_KEY}` },
108
+ },
109
+ });
110
+ ```
111
+
112
+ Administrators must enable MCP access in the Stripe Dashboard, and Stripe
113
+ manages that setting **separately for sandbox and live mode**. A connector that
114
+ boots but cannot list tools is usually a dashboard toggle, not a bad key.
115
+
116
+ ## The eleven tools, and what they are classified as
117
+
118
+ Stripe documents eleven tools on the hosted server. Seven are reads:
119
+
120
+ `stripe_api_search`, `stripe_api_details`, `stripe_api_read`,
121
+ `get_stripe_account_info`, `get_balance_summary`,
122
+ `search_stripe_documentation`, `stripe_implementation_planner`.
123
+
124
+ Four are writes:
125
+
126
+ `stripe_api_write` and `create_refund` are classified destructive;
127
+ `stripe_report` and `send_stripe_mcp_feedback` are additive.
128
+
129
+ Two of those deserve a sentence. `stripe_api_read` is a read because Stripe
130
+ documents it as the `GET` half of a generic pair — the tool is the read
131
+ boundary, not whichever endpoint an agent names inside it, and its sibling
132
+ `stripe_api_write` carries every `POST`, `PATCH`, `PUT`, and `DELETE`.
133
+ `create_refund` is filed destructive despite its name: it reverses a settled
134
+ charge and moves money back out, which is a mutation of something that already
135
+ exists rather than a fresh object appearing beside it. Additive writes
136
+ (`stripe_report`, `send_stripe_mcp_feedback`) leave `destructiveHint` unset;
137
+ `readOnlyHint: false` already routes them through `call_destructive_tool`, and
138
+ asserting destruction only inflates the approval copy the host shows a human.
139
+
140
+ That classification is **fill-in only**, and unconditionally so: it supplies
141
+ the annotations Stripe leaves unset — Stripe documents no MCP annotations at
142
+ all — and contradicts an explicit downstream annotation in neither direction. A
143
+ tool on the read allowlist arriving with `destructiveHint: true` or
144
+ `readOnlyHint: false` keeps exactly what the downstream said and stays behind
145
+ `call_destructive_tool`. A tool on neither maintained list arriving with
146
+ `readOnlyHint: true` keeps that too, and stays callable from `execute_code`.
147
+ Both are the downstream telling you this release's allowlist is stale, and on a
148
+ name no release has reviewed its word is the only evidence there is. The one
149
+ classification that still outranks the downstream is a name this release
150
+ reviewed and filed destructive: a `create_refund` claiming `readOnlyHint: true`
151
+ is a downstream bug rather than news, and stays on the approval path.
152
+
153
+ An unfamiliar tool that annotates nothing fails closed onto
154
+ `call_destructive_tool` until a Connecta release reviews it. That is not
155
+ hypothetical here: Stripe's own MCP page still carries a `create_customer`
156
+ example that its tool table no longer lists. Whatever the server actually
157
+ serves, an unclassified and unannotated `create_customer` lands on the approval
158
+ path. Expect the undocumented Treasury tools Stripe alludes to to arrive
159
+ unclassified as well — annotated ones will be taken at their word.
160
+
161
+ Stripe publishes no stability or deprecation policy for this tool set and
162
+ invites tool requests by email, so treat the list as unversioned. `get_balance_summary`
163
+ is Treasury, which Stripe labels public preview and gates behind an access
164
+ request — expect it to be absent unless the account is allowlisted, and expect
165
+ the other Treasury tools Stripe alludes to but does not document to arrive
166
+ unclassified.
167
+
168
+ ## Rate limits
169
+
170
+ Stripe documents no rate limit specific to the MCP server. The connection
171
+ therefore transcribes the account limit that MCP traffic spends
172
+ ([rate limits](https://docs.stripe.com/rate-limits)): **100 requests per second
173
+ in live mode, 25 in a sandbox**, and any single endpoint is capped at 25 per
174
+ second regardless of mode, so paging one list is the real constraint. The
175
+ `maxConcurrency` beside it — 8 for
176
+ production, 4 for sandbox — is Connecta's own conservative choice: Stripe
177
+ documents that per-account and per-endpoint concurrency limits exist, and
178
+ surface as `429` with a `Stripe-Rate-Limited-Reason` of `global-concurrency` or
179
+ `endpoint-concurrency`, but publishes no number.
180
+
181
+ As with every connector policy this is a **best-effort approximation** of the
182
+ provider's limit, not an enforcement of it. Each runtime keeps its own counter,
183
+ so N Worker isolates or Node processes serving one deployment can each admit up
184
+ to the stated rate, and the same Stripe account may be spending its budget on
185
+ traffic Connecta never sees. Discovery traffic is outside connector call
186
+ admission and still needs restrained use.
187
+
188
+ ## What is not verified
189
+
190
+ Stripe's MCP documentation is silent on two things this connection had to reason
191
+ about rather than read:
192
+
193
+ - **How an OAuth session resolves to live versus sandbox at call time.** Stripe
194
+ says sessions are "scoped to … the current environment (live mode or a
195
+ sandbox)" and that dashboard access is managed separately per environment, but
196
+ never states the mechanism. The key-prefix check covers `headers` auth only;
197
+ for OAuth, `mode` is a declaration Connecta surfaces and cannot verify.
198
+ - **Whether pagination cursors and `Idempotency-Key` are passable through
199
+ `stripe_api_read` / `stripe_api_write`.** The conventions in the usage guide
200
+ are Stripe's documented API conventions; how they thread through the generic
201
+ tools' arguments is not documented. The guide states them because an agent
202
+ that ignores them is wrong either way.
package/ethos.md CHANGED
@@ -15,9 +15,12 @@ order, and amending it is a design decision, not a drive-by edit.
15
15
  - **A deployment is a small config-as-code file.** Changing what agents can
16
16
  reach is an edit and a redeploy. One deployment, one tenant, one audience —
17
17
  more audiences means more deployments.
18
- - **Two equal ways in.** `remoteMcp()` proxies a downstream MCP server;
19
- `api()` hand-writes a deliberate tool surface over a plain HTTP API. Both
20
- come out identical: same addresses, same catalog, same safety rules.
18
+ - **Curated when available, open when not.** Prefer an explicitly imported
19
+ prebuilt connection when Connecta maintains one: it carries the provider's
20
+ known-good endpoint, authentication defaults, tool ergonomics, and concise
21
+ usage guidance. `remoteMcp()` and `api()` remain equal, first-class
22
+ primitives for custom and unsupported integrations. Every path produces the
23
+ same `Connector`: same addresses, same catalog, same safety rules.
21
24
  - **Seven tools, an executor required.** The primary surface is a program, so
22
25
  every deployment runs an executor — a Dynamic Worker on Cloudflare, QuickJS
23
26
  behind its optional-peer subpath on Node — and one without refuses to boot
@@ -66,6 +69,8 @@ proposing one without a new argument is not.
66
69
  | Multi-tenancy / account model | refused | one deployment per tenant; deploy again instead |
67
70
  | Policy engine, approvals, pauses | refused | the host asks the human; connecta only annotates |
68
71
  | Runtime connector registration | refused | config-as-code is the security model |
72
+ | Prebuilt connections as the preferred authoring path | accepted | an a-la-carte provider constructor, imported and constructed in the deployment file, encodes maintained defaults for providers connecta actually uses — preferred *when maintained*, with no promise of one per provider; it returns exactly one ordinary `Connector` with no extra privileges — never a bundle, a group, a preset, or a registry — its tools are hand-written or proxied from a downstream MCP catalog, never generated from a schema document; its vetted annotations classify what the downstream leaves unannotated and never overrule an explicit one; `remoteMcp()` and `api()` stay first-class ([#297](https://github.com/zackbart/connecta/issues/297)) |
73
+ | Provider registry / integration marketplace | refused | prebuilt connections are imports, not listings; discovery happens in documentation, never at runtime ([#297](https://github.com/zackbart/connecta/issues/297)) |
69
74
  | Protocol sessions & server push | refused | stateless per request |
70
75
  | Resources & prompts aggregation | refused | tools only; connecta's own Apps shell is the one `resources/read` carve-out ([#266](https://github.com/zackbart/connecta/issues/266)) |
71
76
  | Elicitation passthrough | refused | no route through a stateless aggregator |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zackbart/connecta",
3
- "version": "0.12.2",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "One MCP to rule them all — a single MCP endpoint aggregating many downstream connectors behind a code-first surface of seven meta-tools.",
@@ -64,6 +64,26 @@
64
64
  "./auth/clerk": {
65
65
  "types": "./dist/auth/clerk.d.ts",
66
66
  "import": "./dist/auth/clerk.js"
67
+ },
68
+ "./providers/cloudflare": {
69
+ "types": "./dist/providers/cloudflare.d.ts",
70
+ "import": "./dist/providers/cloudflare.js"
71
+ },
72
+ "./providers/linear": {
73
+ "types": "./dist/providers/linear.d.ts",
74
+ "import": "./dist/providers/linear.js"
75
+ },
76
+ "./providers/mixpanel": {
77
+ "types": "./dist/providers/mixpanel.d.ts",
78
+ "import": "./dist/providers/mixpanel.js"
79
+ },
80
+ "./providers/notion": {
81
+ "types": "./dist/providers/notion.d.ts",
82
+ "import": "./dist/providers/notion.js"
83
+ },
84
+ "./providers/stripe": {
85
+ "types": "./dist/providers/stripe.d.ts",
86
+ "import": "./dist/providers/stripe.js"
67
87
  }
68
88
  },
69
89
  "scripts": {
@@ -25,6 +25,8 @@ import type {
25
25
  } from "./registry.js";
26
26
  import {
27
27
  connectorGuide,
28
+ connectorGuideRequired,
29
+ connectorGuideSummary,
28
30
  connectorSkillName,
29
31
  } from "./skills.js";
30
32
  import {
@@ -196,6 +198,7 @@ export interface CatalogDescribeArgs {
196
198
  interface CatalogSearchEntry {
197
199
  connector: Connector;
198
200
  guide?: string;
201
+ guideSummary?: string;
199
202
  tool: {
200
203
  name: string;
201
204
  address: string;
@@ -208,9 +211,34 @@ interface CatalogSearchEntry {
208
211
  requiredInputKeys?: string[];
209
212
  outputKeys?: string[];
210
213
  annotations?: ToolDef["annotations"];
214
+ guideRequired?: true;
215
+ guideRequiredReasons?: GuideRequiredReason[];
211
216
  };
212
217
  }
213
218
 
219
+ type GuideRequiredReason =
220
+ | "connector_required"
221
+ | "approval_required"
222
+ | "schema_truncated";
223
+
224
+ /**
225
+ * Reasons discovery can determine without reading arguments or guessing at a
226
+ * task. Summary-only conventions remain an agent decision; hard requirements
227
+ * are explicit and machine-readable.
228
+ */
229
+ function guideRequiredReasons(
230
+ connector: Connector,
231
+ tool: ToolDef,
232
+ schemaTruncated: boolean,
233
+ ): GuideRequiredReason[] | undefined {
234
+ if (!connectorGuide(connector)) return undefined;
235
+ const reasons: GuideRequiredReason[] = [];
236
+ if (connectorGuideRequired(connector)) reasons.push("connector_required");
237
+ if (!isExplicitlyReadOnly(tool)) reasons.push("approval_required");
238
+ if (schemaTruncated) reasons.push("schema_truncated");
239
+ return reasons.length > 0 ? reasons : undefined;
240
+ }
241
+
214
242
  /**
215
243
  * Code-mode key metadata for one match. Each half is omitted when its schema
216
244
  * does not resolve to an object shape, so a program reads "no metadata, use the
@@ -237,6 +265,19 @@ function schemaKeyMetadata(
237
265
  };
238
266
  }
239
267
 
268
+ /**
269
+ * The classified-failure subset a scoped search may echo: enough to tell a
270
+ * transient outage from one an operator must clear, and nothing more. Kept as
271
+ * its own type rather than `CallErrorDetails` so widening the call-path
272
+ * classifier cannot widen this discovery-surface field by accident.
273
+ */
274
+ interface CatalogFailureDetail {
275
+ code: string;
276
+ message: string;
277
+ retryable: boolean;
278
+ retryAfterMs?: number;
279
+ }
280
+
240
281
  export interface CatalogSearchPage {
241
282
  entries: CatalogSearchEntry[];
242
283
  total: number;
@@ -253,6 +294,12 @@ export interface CatalogSearchPage {
253
294
  connectorScope?: string;
254
295
  unknownConnector?: true;
255
296
  unavailableConnectorCount?: number;
297
+ /** Bounded typed failure for an explicitly scoped unavailable catalog. */
298
+ catalogError?: CatalogFailureDetail;
299
+ guide?: string;
300
+ guideSummary?: string;
301
+ guideRequired?: true;
302
+ guideRequiredReasons?: GuideRequiredReason[];
256
303
  guidance?: string;
257
304
  };
258
305
  }
@@ -262,6 +309,9 @@ export interface CatalogDescription {
262
309
  name?: string;
263
310
  description?: string;
264
311
  guide?: string;
312
+ guideSummary?: string;
313
+ guideRequired?: true;
314
+ guideRequiredReasons?: GuideRequiredReason[];
265
315
  inputSchema?: unknown;
266
316
  outputSchema?: unknown;
267
317
  annotations?: ToolDef["annotations"];
@@ -656,10 +706,19 @@ export class CatalogService {
656
706
  match.tool.description,
657
707
  args.fullDescriptions === true,
658
708
  );
709
+ const requiredReasons = guideRequiredReasons(
710
+ match.connector,
711
+ match.tool,
712
+ renderedInput?.truncated === true || renderedOutput?.truncated === true,
713
+ );
714
+ const guideSummary = connectorGuideSummary(match.connector);
659
715
  return {
660
716
  connector: match.connector,
661
717
  ...(connectorGuide(match.connector)
662
- ? { guide: connectorSkillName(match.connector.id) }
718
+ ? {
719
+ guide: connectorSkillName(match.connector.id),
720
+ ...(guideSummary ? { guideSummary } : {}),
721
+ }
663
722
  : {}),
664
723
  tool: {
665
724
  name: match.tool.name,
@@ -697,6 +756,12 @@ export class CatalogService {
697
756
  ...(match.tool.annotations
698
757
  ? { annotations: match.tool.annotations }
699
758
  : {}),
759
+ ...(requiredReasons
760
+ ? {
761
+ guideRequired: true as const,
762
+ guideRequiredReasons: requiredReasons,
763
+ }
764
+ : {}),
700
765
  },
701
766
  };
702
767
  });
@@ -732,6 +797,27 @@ export class CatalogService {
732
797
  const unavailableCatalogs = catalogs.filter(
733
798
  (catalog) => catalog.status === "rejected",
734
799
  ).length;
800
+ // Named field by field rather than spread: `CallErrorDetails` also carries
801
+ // connector, operation, recovery, and nextAction, and a discovery read is
802
+ // not a call — widening the classifier must not silently widen what a
803
+ // catalog search hands back.
804
+ const scopedCatalogError = ((): CatalogFailureDetail | undefined => {
805
+ if (!scopedConnector || catalogs[0]?.status !== "rejected") {
806
+ return undefined;
807
+ }
808
+ const error = classifyCallError(
809
+ catalogs[0].reason,
810
+ "catalog_lookup_failed",
811
+ );
812
+ return {
813
+ code: error.code,
814
+ message: boundedEchoText(error.message),
815
+ retryable: error.retryable,
816
+ ...(error.retryAfterMs === undefined
817
+ ? {}
818
+ : { retryAfterMs: error.retryAfterMs }),
819
+ };
820
+ })();
735
821
  const safetyLabel =
736
822
  safety === "readOnly"
737
823
  ? "read-only "
@@ -740,19 +826,58 @@ export class CatalogService {
740
826
  : "";
741
827
  const filterRecovery =
742
828
  safety === "all" ? "" : " Change safety to inspect the other tools.";
829
+ const scopedGuide =
830
+ matches.length === 0 && scopedConnector && connectorGuide(scopedConnector)
831
+ ? {
832
+ guide: connectorSkillName(scopedConnector.id),
833
+ guideSummary: connectorGuideSummary(scopedConnector),
834
+ required: connectorGuideRequired(scopedConnector),
835
+ }
836
+ : undefined;
837
+ // A scope that resolved to nothing is the same silence one step earlier in
838
+ // the lookup: no connector resolved, so no catalog was even attempted, so
839
+ // no catalog failed and the unavailable path below never fires. Echo only
840
+ // the ID the caller already supplied — naming what else is configured
841
+ // would answer a question they did not ask, past a filter they may not
842
+ // pass.
843
+ const unknownConnectorGuidance =
844
+ args.connector && !scopedConnector
845
+ ? `Connector "${args.connector}" is not configured in this deployment. Omit connector to search all configured tools.`
846
+ : undefined;
847
+ // Term-bearing searches report analysis only when the scorer had to
848
+ // degrade; a browse has no terms to analyse and normally reports none at
849
+ // all. But neither "this catalog is unavailable" nor "there is no such
850
+ // connector" is a statement about terms, and answering either browse with
851
+ // an empty entry list alone is indistinguishable from a connector that
852
+ // simply exposes no tools. The term partitions stay empty on those paths
853
+ // because there were no terms — the scope fields carry the whole message.
854
+ const reportsQueryAnalysis =
855
+ queryTerms.length > 0
856
+ ? matchMode === "partial"
857
+ : unknownConnectorGuidance !== undefined || unavailableCatalogs > 0;
743
858
  const guidance =
744
859
  queryTerms.length === 0
745
- ? undefined
746
- : matches.length === 0
747
- ? args.connector && !scopedConnector
748
- ? `Connector "${args.connector}" is not configured in this deployment. Omit connector to search all configured tools.`
860
+ ? // A browse has no terms to advise about, so it stays silent unless
861
+ // the scope itself failed: the guidance on a scoped miss recommends
862
+ // browsing with an empty query, and that advice must not lead into a
863
+ // dead end that looks like a connector with no tools.
864
+ (unknownConnectorGuidance ??
865
+ (unavailableCatalogs === 0
866
+ ? undefined
749
867
  : scopedConnector
868
+ ? `Connector "${scopedConnector.id}" could not be browsed because its catalog was unavailable. Inspect catalogError for the typed reason and recovery detail.`
869
+ : `${unavailableCatalogs} connector catalog${unavailableCatalogs === 1 ? " was" : "s were"} unavailable, so this browse is incomplete. Scope by connector to see the typed reason.`))
870
+ : matches.length === 0
871
+ ? (unknownConnectorGuidance ??
872
+ (scopedConnector
750
873
  ? unavailableCatalogs > 0
751
- ? `Connector "${scopedConnector.id}" could not be searched because its catalog was unavailable. Retry later.`
752
- : `No matching ${safetyLabel}capability was found on connector "${scopedConnector.id}". Refine terms or browse it with an empty query.${filterRecovery}`
874
+ ? `Connector "${scopedConnector.id}" could not be searched because its catalog was unavailable. Inspect catalogError for the typed reason and recovery detail.`
875
+ : scopedGuide?.required
876
+ ? `No matching ${safetyLabel}capability was found on connector "${scopedConnector.id}". Fetch queryAnalysis.guide before calling, then refine terms or browse with an empty query.${filterRecovery}`
877
+ : `No matching ${safetyLabel}capability was found on connector "${scopedConnector.id}". Refine terms or browse it with an empty query.${filterRecovery}`
753
878
  : unavailableCatalogs === 0
754
879
  ? `No matching ${safetyLabel}capability is configured in this deployment. Refine terms, scope by connector, or browse with an empty query.${filterRecovery}`
755
- : `No matching ${safetyLabel}capability was found in the catalogs that answered; ${unavailableCatalogs} connector catalog${unavailableCatalogs === 1 ? " was" : "s were"} unavailable. Refine terms, scope by connector, or browse with an empty query.${filterRecovery}`
880
+ : `No matching ${safetyLabel}capability was found in the catalogs that answered; ${unavailableCatalogs} connector catalog${unavailableCatalogs === 1 ? " was" : "s were"} unavailable. Refine terms, scope by connector, or browse with an empty query.${filterRecovery}`))
756
881
  : matchMode === "partial"
757
882
  ? scopedConnector
758
883
  ? `No single tool on connector "${scopedConnector.id}" matched every term. Split distinct intents into separate searches.`
@@ -770,7 +895,7 @@ export class CatalogService {
770
895
  ...(matchMode === "partial" && matches.length > 0
771
896
  ? { matchMode }
772
897
  : {}),
773
- ...(queryTerms.length > 0 && matchMode === "partial"
898
+ ...(reportsQueryAnalysis
774
899
  ? {
775
900
  queryAnalysis: {
776
901
  representedTerms,
@@ -789,6 +914,23 @@ export class CatalogService {
789
914
  ...(unavailableCatalogs > 0
790
915
  ? { unavailableConnectorCount: unavailableCatalogs }
791
916
  : {}),
917
+ ...(scopedCatalogError ? { catalogError: scopedCatalogError } : {}),
918
+ ...(scopedGuide
919
+ ? {
920
+ guide: scopedGuide.guide,
921
+ ...(scopedGuide.guideSummary
922
+ ? { guideSummary: scopedGuide.guideSummary }
923
+ : {}),
924
+ ...(scopedGuide.required
925
+ ? {
926
+ guideRequired: true as const,
927
+ guideRequiredReasons: [
928
+ "connector_required" as const,
929
+ ],
930
+ }
931
+ : {}),
932
+ }
933
+ : {}),
792
934
  ...(guidance ? { guidance } : {}),
793
935
  },
794
936
  }
@@ -853,12 +995,27 @@ export class CatalogService {
853
995
  tool.description,
854
996
  args.fullDescriptions === true,
855
997
  );
998
+ const requiredReasons = guideRequiredReasons(
999
+ addressResolution.connector,
1000
+ tool,
1001
+ false,
1002
+ );
1003
+ const guideSummary = connectorGuideSummary(addressResolution.connector);
856
1004
  return {
857
1005
  address,
858
1006
  name: tool.name,
859
1007
  ...(description !== undefined ? { description } : {}),
860
1008
  ...(connectorGuide(addressResolution.connector)
861
- ? { guide: connectorSkillName(addressResolution.connector.id) }
1009
+ ? {
1010
+ guide: connectorSkillName(addressResolution.connector.id),
1011
+ ...(guideSummary ? { guideSummary } : {}),
1012
+ }
1013
+ : {}),
1014
+ ...(requiredReasons
1015
+ ? {
1016
+ guideRequired: true as const,
1017
+ guideRequiredReasons: requiredReasons,
1018
+ }
862
1019
  : {}),
863
1020
  inputSchema: renderSchema(input, format),
864
1021
  ...(tool.outputSchema
@@ -877,6 +1034,7 @@ export function groupedSearchResult(page: CatalogSearchPage) {
877
1034
  id: string;
878
1035
  title?: string;
879
1036
  guide?: string;
1037
+ guideSummary?: string;
880
1038
  tools: CatalogSearchEntry["tool"][];
881
1039
  }> = [];
882
1040
  const byConnector = new Map<string, (typeof groups)[number]>();
@@ -889,6 +1047,9 @@ export function groupedSearchResult(page: CatalogSearchPage) {
889
1047
  id: entry.connector.id,
890
1048
  ...(entry.connector.title ? { title: entry.connector.title } : {}),
891
1049
  ...(entry.guide ? { guide: entry.guide } : {}),
1050
+ ...(entry.guideSummary
1051
+ ? { guideSummary: entry.guideSummary }
1052
+ : {}),
892
1053
  tools: [],
893
1054
  };
894
1055
  byConnector.set(entry.connector.id, group);
@@ -913,6 +1074,9 @@ export function flatSearchResult(page: CatalogSearchPage) {
913
1074
  tools: page.entries.map((entry) => ({
914
1075
  ...entry.tool,
915
1076
  ...(entry.guide ? { guide: entry.guide } : {}),
1077
+ ...(entry.guideSummary
1078
+ ? { guideSummary: entry.guideSummary }
1079
+ : {}),
916
1080
  })),
917
1081
  total: page.total,
918
1082
  offset: page.offset,
@@ -5,6 +5,7 @@ import type {
5
5
  ConnectorCredentialConfig,
6
6
  ConnectorCredentialValues,
7
7
  ConnectorContext,
8
+ ConnectorUsageGuide,
8
9
  CredentialTestResult,
9
10
  JsonSchema,
10
11
  ToolAnnotations,
@@ -41,10 +42,11 @@ export interface ApiOptions {
41
42
  /** Optional per-runtime downstream call-admission policy. */
42
43
  callAdmission?: ConnectorCallAdmissionPolicy;
43
44
  /**
44
- * Optional agent-facing usage guide (markdown) served by the `skills`
45
- * meta-tool as `connector:<id>`. See `Connector.usageGuide`.
45
+ * Optional agent-facing usage guide served by `skills` as
46
+ * `connector:<id>`. A string is markdown; the structured form adds bounded
47
+ * discovery metadata. See `Connector.usageGuide`.
46
48
  */
47
- usageGuide?: string;
49
+ usageGuide?: string | ConnectorUsageGuide;
48
50
  /** Optional operator-managed credential exposed through ctx.credential and /credentials. */
49
51
  credential?: ConnectorCredentialConfig;
50
52
  /** Optional validation behind /credentials' Test action. */
@@ -147,7 +149,11 @@ export function api(id: string, opts: ApiOptions): Connector {
147
149
  });
148
150
  if (invalid) throw invalid;
149
151
  }
150
- return tool.handler(input, ctx);
152
+ // `await` (not a bare promise return) so a handler that throws before
153
+ // its first await never sits handler-less for the thenable-adoption
154
+ // microtask — workerd and vitest both report that gap as an unhandled
155
+ // rejection even though the caller catches the failure.
156
+ return await tool.handler(input, ctx);
151
157
  },
152
158
  };
153
159
  }
@@ -21,6 +21,7 @@ import type {
21
21
  ConnectorCallAdmissionPolicy,
22
22
  ConnectorContext,
23
23
  ConnectorStatus,
24
+ ConnectorUsageGuide,
24
25
  Logger,
25
26
  ToolDef,
26
27
  } from "../types.js";
@@ -47,10 +48,11 @@ export interface RemoteMcpOptions {
47
48
  /** Optional per-runtime downstream call-admission policy. */
48
49
  callAdmission?: ConnectorCallAdmissionPolicy;
49
50
  /**
50
- * Optional agent-facing usage guide (markdown) served by the `skills`
51
- * meta-tool as `connector:<id>`. See `Connector.usageGuide`.
51
+ * Optional agent-facing usage guide served by `skills` as
52
+ * `connector:<id>`. A string is markdown; the structured form adds bounded
53
+ * discovery metadata. See `Connector.usageGuide`.
52
54
  */
53
- usageGuide?: string;
55
+ usageGuide?: string | ConnectorUsageGuide;
54
56
  auth?: RemoteMcpAuth;
55
57
  /**
56
58
  * Downstream HTTP redirect policy. Defaults to `"none"`: every redirect is