@zackbart/connecta 0.24.2 → 0.24.4

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 (97) hide show
  1. package/AGENTS.md +18 -20
  2. package/CHANGELOG.md +205 -1
  3. package/README.md +5 -6
  4. package/dist/auth/bearer.js +2 -0
  5. package/dist/auth/downstream-oauth.d.ts +12 -1
  6. package/dist/auth/downstream-oauth.js +147 -35
  7. package/dist/branding.d.ts +31 -2
  8. package/dist/branding.js +116 -8
  9. package/dist/call-admission.d.ts +4 -0
  10. package/dist/call-admission.js +26 -0
  11. package/dist/catalog-drift.js +9 -4
  12. package/dist/catalog-service.d.ts +2 -0
  13. package/dist/catalog-service.js +25 -8
  14. package/dist/catalog.d.ts +2 -0
  15. package/dist/catalog.js +246 -121
  16. package/dist/connectors/api.d.ts +1 -1
  17. package/dist/connectors/api.js +21 -3
  18. package/dist/connectors/guarded-fetch.d.ts +6 -2
  19. package/dist/connectors/guarded-fetch.js +61 -24
  20. package/dist/connectors/remote-mcp.js +92 -57
  21. package/dist/errors.d.ts +28 -3
  22. package/dist/errors.js +60 -1
  23. package/dist/execute.js +85 -23
  24. package/dist/executor-result.js +3 -1
  25. package/dist/executors/quickjs-child.js +5 -1
  26. package/dist/executors/quickjs-protocol.d.ts +4 -0
  27. package/dist/executors/quickjs-runtime.d.ts +1 -1
  28. package/dist/executors/quickjs-runtime.js +38 -21
  29. package/dist/executors/quickjs.js +68 -27
  30. package/dist/index.d.ts +15 -1
  31. package/dist/index.js +36 -4
  32. package/dist/invocation.js +134 -93
  33. package/dist/mcp-result.js +3 -2
  34. package/dist/meta-tools.js +191 -36
  35. package/dist/operator-ui/generated.js +2 -2
  36. package/dist/operator-ui/view.d.ts +38 -1
  37. package/dist/operator-ui/view.js +71 -0
  38. package/dist/providers/cloudflare.d.ts +14 -2
  39. package/dist/providers/cloudflare.js +107 -16
  40. package/dist/providers/linear.d.ts +26 -4
  41. package/dist/providers/linear.js +19 -4
  42. package/dist/providers/mixpanel.d.ts +16 -3
  43. package/dist/providers/mixpanel.js +13 -2
  44. package/dist/providers/notion.d.ts +8 -1
  45. package/dist/providers/notion.js +83 -10
  46. package/dist/providers/revenuecat.d.ts +30 -4
  47. package/dist/providers/revenuecat.js +42 -4
  48. package/dist/providers/stripe.d.ts +7 -1
  49. package/dist/providers/stripe.js +30 -4
  50. package/dist/providers/vercel.js +11 -1
  51. package/dist/registry.d.ts +22 -2
  52. package/dist/registry.js +101 -13
  53. package/dist/routes/mcp.d.ts +4 -1
  54. package/dist/routes/mcp.js +84 -13
  55. package/dist/routes/oauth.js +4 -0
  56. package/dist/routes/shared.d.ts +1 -0
  57. package/dist/routes/shared.js +4 -4
  58. package/dist/server.js +15 -3
  59. package/dist/skills.js +6 -5
  60. package/dist/storage/file.d.ts +6 -2
  61. package/dist/storage/file.js +312 -34
  62. package/dist/storage/memory.js +12 -1
  63. package/dist/types.d.ts +37 -0
  64. package/dist/ui.js +18 -10
  65. package/dist/validate.js +3 -3
  66. package/dist/version.d.ts +1 -1
  67. package/dist/version.js +1 -1
  68. package/documentation/architecture.md +193 -165
  69. package/documentation/auth.md +199 -145
  70. package/documentation/code-mode.md +433 -316
  71. package/documentation/meta-tools.md +363 -355
  72. package/examples/worker/AGENTS.md +2 -1
  73. package/examples/worker/README.md +12 -10
  74. package/examples/worker/src/index.ts +12 -15
  75. package/package.json +1 -2
  76. package/templates/node/.env.example +3 -3
  77. package/templates/node/AGENTS.md +5 -4
  78. package/templates/node/README.md +2 -1
  79. package/templates/node/package.json +1 -1
  80. package/templates/node/src/index.ts +23 -22
  81. package/documentation/call-admission.md +0 -142
  82. package/documentation/cloudflare.md +0 -471
  83. package/documentation/connector-guides.md +0 -176
  84. package/documentation/connectors.md +0 -389
  85. package/documentation/linear.md +0 -193
  86. package/documentation/mixpanel.md +0 -160
  87. package/documentation/notion.md +0 -308
  88. package/documentation/operations.md +0 -359
  89. package/documentation/operator-ui.md +0 -135
  90. package/documentation/optional-modules-upgrade.md +0 -243
  91. package/documentation/provider-conventions.md +0 -722
  92. package/documentation/request-admission.md +0 -170
  93. package/documentation/revenuecat.md +0 -305
  94. package/documentation/storage-and-credentials.md +0 -201
  95. package/documentation/stripe.md +0 -262
  96. package/documentation/upgrading.md +0 -754
  97. package/documentation/vercel.md +0 -241
@@ -3,7 +3,12 @@ import { vettedCatalog, withVettedCatalog } from "../catalog-drift.js";
3
3
  import { defined } from "../connectors/api.js";
4
4
  /** RevenueCat publishes one hosted MCP endpoint, streamable HTTP. */
5
5
  export const REVENUECAT_MCP_ENDPOINT = "https://mcp.revenuecat.ai/mcp";
6
- /** Reviewed reads; see `documentation/revenuecat.md` and convention P5. */
6
+ /**
7
+ * Reviewed reads, every `Read` row of RevenueCat's tool reference as read on
8
+ * 2026-08-30 (P5). The list is a superset: plan, platform, and beta enrollment
9
+ * gate parts of the catalog, so a name this account never returns costs
10
+ * nothing while an unlisted new one fails closed.
11
+ */
7
12
  const READ_ONLY_TOOLS = new Set([
8
13
  // Projects and apps
9
14
  "get-account-billing",
@@ -70,26 +75,48 @@ const READ_ONLY_TOOLS = new Set([
70
75
  // *start* one are writes and sit below.
71
76
  "get-paywall-ai-task",
72
77
  ]);
73
- /** Reviewed writes and verb exceptions: `documentation/revenuecat.md`. */
78
+ /**
79
+ * Reviewed writes with their destructive verdict. Most follow the verb, and
80
+ * additive writes stay additive because `readOnlyHint: false` already routes
81
+ * them through the approval path — asserting a destruction that does not happen
82
+ * only inflates the copy a human reads. The verdicts the verb does not decide
83
+ * carry their reason on the row.
84
+ *
85
+ * `render-paywall-screenshot` is deliberately in neither map: RevenueCat's
86
+ * reference gives it no access column, so the live annotation stands and a
87
+ * catalog that omits one fails closed. Guessing from a harmless-sounding name
88
+ * is what P5 exists to prevent.
89
+ */
74
90
  const WRITE_TOOLS = new Map([
75
91
  // Projects and apps
76
92
  ["create-app", "additive"],
77
93
  ["create-project", "additive"],
78
94
  ["update-app", "destructive"],
79
95
  ["update-project-ui-config", "destructive"],
96
+ // Filed `Write` by RevenueCat, so it cannot take the read path, but it leaves
97
+ // the saved credentials alone and only records a check's outcome.
80
98
  ["validate-app-credentials", "additive"],
81
99
  // Products and prices
82
100
  ["archive-product", "destructive"],
83
101
  ["create-product", "additive"],
102
+ // Named `create-`, but "Configure prices for a product": the price set
103
+ // already exists, so this overwrites it, and it is money-facing.
84
104
  ["create-product-prices", "destructive"],
105
+ // "Fills *missing* App Store subscription territory prices" — by
106
+ // RevenueCat's own word it writes only where nothing is set.
85
107
  ["equalize-subscription-prices", "additive"],
108
+ // An upsert.
86
109
  ["set-product-store-state", "destructive"],
110
+ // Sends products to Apple for review.
87
111
  ["submit-products-to-store", "destructive"],
88
112
  ["unarchive-product", "destructive"],
89
113
  ["update-product", "destructive"],
114
+ // Reserves a new App Store Connect review screenshot slot; replaces nothing.
90
115
  ["upload-product-store-state-screenshot", "additive"],
91
116
  // Entitlements
92
117
  ["archive-entitlement", "destructive"],
118
+ // Attach adds membership and removes nothing; detach is the destructive half.
119
+ // Filing both destructive would make the pair read identically to a human.
93
120
  ["attach-products-to-entitlement", "additive"],
94
121
  ["create-entitlement", "additive"],
95
122
  ["detach-products-from-entitlement", "destructive"],
@@ -97,6 +124,7 @@ const WRITE_TOOLS = new Map([
97
124
  ["update-entitlement", "destructive"],
98
125
  // Offerings and packages
99
126
  ["archive-offering", "destructive"],
127
+ // The attach/detach argument again, one level down.
100
128
  ["attach-products-to-package", "additive"],
101
129
  ["create-offering", "additive"],
102
130
  ["create-packages", "additive"],
@@ -110,6 +138,7 @@ const WRITE_TOOLS = new Map([
110
138
  // Paywalls
111
139
  ["attach-offering-to-paywall", "destructive"],
112
140
  ["detach-offering-from-paywall", "destructive"],
141
+ // Copies an existing paywall's current draft; the original is untouched.
113
142
  ["duplicate-paywall", "additive"],
114
143
  ["publish-paywall", "destructive"],
115
144
  ["unpublish-paywall", "destructive"],
@@ -130,14 +159,23 @@ const WRITE_TOOLS = new Map([
130
159
  ["unarchive-virtual-currency", "destructive"],
131
160
  ["update-virtual-currency", "destructive"],
132
161
  // Integrations and webhooks
162
+ // Destructive on consequence, not on the verb: no existing integration
163
+ // changes, but with filters omitted the new one starts delivering every
164
+ // customer event in the project to a URL the caller typed.
133
165
  ["create-webhook-integration", "destructive"],
134
166
  ["delete-webhook-integration", "destructive"],
135
167
  ["update-webhook-integration", "destructive"],
136
- // Paywall editing
168
+ // Paywall editing. Both start an async task; the difference is what the task
169
+ // touches — a new paywall, or a draft that already exists.
137
170
  ["create-paywall-ai", "additive"],
138
171
  ["edit-paywall-ai", "destructive"],
139
172
  ]);
140
- /** Release-reviewed manifest; see provider conventions P5 and P13. */
173
+ /**
174
+ * One release-reviewed manifest, used both to classify a live tool and as the
175
+ * baseline the drift check compares against, so the annotation a caller gets
176
+ * and the verdict a check reads can never disagree. Names and verdicts only —
177
+ * no schemas are vendored; the live `tools/list` response stays authoritative.
178
+ */
141
179
  export const REVENUECAT_VETTED_CATALOG = vettedCatalog({
142
180
  reads: READ_ONLY_TOOLS,
143
181
  writes: WRITE_TOOLS,
@@ -40,7 +40,13 @@ export interface StripeHeaderOptions extends StripeCommonOptions {
40
40
  connectedAccount?: string;
41
41
  }
42
42
  export type StripeOptions = StripeOAuthOptions | StripeHeaderOptions;
43
- /** Release-reviewed manifest; see provider conventions P5 and P13. */
43
+ /**
44
+ * One release-reviewed manifest, used both to classify a live tool and as the
45
+ * baseline the drift check compares against, so the annotation a caller gets and
46
+ * the verdict a check reads can never disagree. Stripe publishes no stability or
47
+ * deprecation policy for this tool set, so treat it as unversioned: an
48
+ * unclassified, unannotated name fails closed onto the approval path (P5).
49
+ */
44
50
  export declare const STRIPE_VETTED_CATALOG: import("../catalog-drift.js").VettedCatalog;
45
51
  /** A maintained Stripe hosted-MCP connection. */
46
52
  export declare function stripe(id: string, options: StripeOptions): Connector;
@@ -3,7 +3,14 @@ import { vettedCatalog, withVettedCatalog } from "../catalog-drift.js";
3
3
  import { defined } from "../connectors/api.js";
4
4
  /** Stripe publishes one hosted MCP endpoint for every account and mode. */
5
5
  export const STRIPE_MCP_ENDPOINT = "https://mcp.stripe.com/";
6
- /** Account limits and local concurrency rationale: `documentation/stripe.md`. */
6
+ /**
7
+ * Stripe documents no MCP-specific limit, so these transcribe the account limit
8
+ * MCP traffic spends: 100 requests per second live, 25 in a sandbox. OAuth takes
9
+ * the sandbox rule (`mode ?? "sandbox"` below) because one session can reach
10
+ * either mode and the policy is fixed before the account-scoped call begins.
11
+ * The `maxConcurrency` figures are Connecta's own choice — Stripe says
12
+ * per-account and per-endpoint concurrency limits exist but publishes no number.
13
+ */
7
14
  const STRIPE_ADMISSION = {
8
15
  production: {
9
16
  rules: [
@@ -47,16 +54,30 @@ const READ_ONLY_TOOLS = new Set([
47
54
  "manage_stripe_accounts",
48
55
  "search_stripe_documentation",
49
56
  ]);
50
- /** Reviewed writes, including mixed read/create tools: `documentation/stripe.md`. */
57
+ /**
58
+ * Reviewed writes with their destructive verdict. `stripe_api_write` is the
59
+ * sibling of `stripe_api_read` and carries every `POST`, `PATCH`, `PUT`, and
60
+ * `DELETE`. Additive writes leave `destructiveHint` unset: `readOnlyHint: false`
61
+ * already routes them through the approval path, and asserting destruction they
62
+ * do not perform would misstate their effect.
63
+ */
51
64
  const WRITE_TOOLS = new Map([
52
65
  ["stripe_api_write", "destructive"],
53
66
  ["create_refund", "destructive"],
67
+ // Creates and continues provider-side guide state; destroys nothing.
54
68
  ["stripe_implementation_planner", "additive"],
69
+ // Retrieval mixed with query-run creation behind one tool.
55
70
  ["stripe_analytics", "additive"],
56
71
  ["stripe_report", "additive"],
57
72
  ["send_stripe_mcp_feedback", "additive"],
58
73
  ]);
59
- /** Release-reviewed manifest; see provider conventions P5 and P13. */
74
+ /**
75
+ * One release-reviewed manifest, used both to classify a live tool and as the
76
+ * baseline the drift check compares against, so the annotation a caller gets and
77
+ * the verdict a check reads can never disagree. Stripe publishes no stability or
78
+ * deprecation policy for this tool set, so treat it as unversioned: an
79
+ * unclassified, unannotated name fails closed onto the approval path (P5).
80
+ */
60
81
  export const STRIPE_VETTED_CATALOG = vettedCatalog({
61
82
  reads: READ_ONLY_TOOLS,
62
83
  writes: WRITE_TOOLS,
@@ -64,7 +85,12 @@ export const STRIPE_VETTED_CATALOG = vettedCatalog({
64
85
  /** Stripe key prefixes carry their own mode; only a clear reading counts. */
65
86
  const LIVE_KEY = /\b(?:sk|rk|pk)_live_/;
66
87
  const TEST_KEY = /\b(?:sk|rk|pk)_test_/;
67
- /** Refuse a recognizable key/mode mismatch; see `documentation/stripe.md`. */
88
+ /**
89
+ * Refuse a recognizable key/mode mismatch. This is the only mode guard Connecta
90
+ * can offer, and it needs the key as a literal header to read the prefix: an
91
+ * operator-managed key is not in the deployment file, so its declared mode
92
+ * stands alone and a key pointed at the other environment fails at Stripe.
93
+ */
68
94
  function assertModeMatchesKey(id, mode, auth) {
69
95
  if (auth.type !== "headers")
70
96
  return;
@@ -1,4 +1,14 @@
1
- /** See documentation/vercel.md#no-sdk-on-purpose. */
1
+ /**
2
+ * No `@vercel/sdk` on purpose — not a dependency and not an optional peer.
3
+ * Direct fetch keeps the root Workers-safe, avoids shipping the SDK's generated
4
+ * model graph, and lets the reviewed named operations and the REST hatches share
5
+ * one guarded transport. The trade is API drift, handled explicitly rather than
6
+ * by the SDK's version bumps: `scripts/drift/vercel-endpoints.json` records the
7
+ * method, versioned path, spec revision, and request/response digest for every
8
+ * fixed endpoint, and `npm run providers:check -- --provider vercel` compares
9
+ * those rows with Vercel's published OpenAPI document at
10
+ * https://openapi.vercel.sh/ without needing a credential.
11
+ */
2
12
  import { api, defined } from "../connectors/api.js";
3
13
  import { remoteMcp } from "../connectors/remote-mcp.js";
4
14
  import { vettedCatalog, withVettedCatalog } from "../catalog-drift.js";
@@ -61,6 +61,10 @@ export interface RegistryOptions {
61
61
  * to the default 50_000.
62
62
  */
63
63
  maxResultBytes?: number | undefined;
64
+ results?: {
65
+ maxStashBytes?: number;
66
+ maxStashEntries?: number;
67
+ } | undefined;
64
68
  /**
65
69
  * Where payload-free catalog-drift observations go. Present only when the
66
70
  * deployment configured an activity store; drift is reported through
@@ -103,6 +107,8 @@ export interface RegistryView {
103
107
  signal?: AbortSignal;
104
108
  }): Promise<CallAdmissionPermit>;
105
109
  resultsStorage(): KVStorage;
110
+ /** Reserve runtime-wide capacity before writing a paging envelope's chunks. */
111
+ stashResult(key: string, chunks: readonly string[], ttlSeconds: number): Promise<boolean>;
106
112
  /** Local declared-vs-stored credential mismatch, with no downstream I/O. */
107
113
  credentialDriftFor(id: string): Promise<string | undefined>;
108
114
  /** Value-free shape learned from successful calls, never a provider declaration. */
@@ -156,9 +162,13 @@ export declare class Registry implements RegistryView {
156
162
  private readonly persistToolCatalog;
157
163
  /** Result-size guard cap threaded to the meta-tools. */
158
164
  readonly maxResultBytes: number;
165
+ /** Only keys, byte counts, and expiry survive requests; never write promises. */
166
+ private readonly resultStash;
167
+ private resultStashBytes;
159
168
  private readonly configuredConnectors;
160
169
  private readonly personalRegistries;
161
- /** `connector.tool` grants that matched nothing, warned once per isolate. */
170
+ private callAdmissionClosed;
171
+ /** Bounded FIFO of absent grants already warned about. */
162
172
  private readonly warnedAbsentGrants;
163
173
  constructor(connectors: Connector[], opts: RegistryOptions);
164
174
  personalRegistry(principalKey: string): Registry;
@@ -202,7 +212,7 @@ export declare class Registry implements RegistryView {
202
212
  args: unknown;
203
213
  signal?: AbortSignal;
204
214
  }): Promise<CallAdmissionPermit>;
205
- /** Payload-free aggregate state for the open health endpoint. */
215
+ /** Connector totals across root and personal controllers; health removes ids. */
206
216
  callAdmissionSnapshot(): Record<string, ConnectorCallAdmissionSnapshot>;
207
217
  /**
208
218
  * Payload-free drift counts for the open health endpoint, so `connecta
@@ -230,6 +240,16 @@ export declare class Registry implements RegistryView {
230
240
  private observeCatalogDrift;
231
241
  /** Reject queued/future downstream admission; active permits release safely. */
232
242
  closeCallAdmission(): void;
243
+ /**
244
+ * Reserve capacity and write one ASCII paging envelope in this runtime.
245
+ *
246
+ * `chunks[0]` lands on `<prefix><key>` and `chunks[n]` on `<prefix><key>#<n>`
247
+ * — the layout get_result reads back, so a page fetches only the chunks it
248
+ * covers instead of the whole stored result (issue #540). Chunking is a
249
+ * read-cost decision, not a capacity one: however many keys an envelope
250
+ * occupies, it is one stash entry charged its total ASCII length.
251
+ */
252
+ stashResult(key: string, chunks: readonly string[], ttlSeconds: number, prefix?: string): Promise<boolean>;
233
253
  /**
234
254
  * Storage namespaced to the meta-tool result store (`results:` prefix), kept
235
255
  * separate from any connector's `conn:<id>:` namespace. Backs get_result.
package/dist/registry.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { closeConnectorScope, } from "./connector-scope.js";
2
2
  import { storedCredentialShape, } from "./credential-rules.js";
3
3
  import { ConnectorCallError, msg } from "./errors.js";
4
- import { ConnectorCallAdmissionController, } from "./call-admission.js";
4
+ import { ConnectorCallAdmissionController, aggregateCallAdmissionSnapshots, } from "./call-admission.js";
5
5
  import { boundedCatalogDrift } from "./catalog-drift.js";
6
6
  import { fingerprintSerializedCatalog, snapshotCatalog, } from "./catalog-fingerprint.js";
7
7
  import { MAX_CATALOG_CHUNK_BYTES, MAX_CATALOG_TOOLS, MAX_SERIALIZED_CATALOG_BYTES, } from "./catalog-limits.js";
@@ -80,6 +80,7 @@ function namespaced(storage, prefix) {
80
80
  };
81
81
  }
82
82
  const MAX_PERSONAL_REGISTRIES = 1_024;
83
+ const MAX_ABSENT_GRANT_WARNINGS = 1_024;
83
84
  const OAUTH_HANDOFF_TTL_SECONDS = 15 * 60;
84
85
  async function sha256Hex(value) {
85
86
  const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(value)));
@@ -117,9 +118,13 @@ export class Registry {
117
118
  persistToolCatalog;
118
119
  /** Result-size guard cap threaded to the meta-tools. */
119
120
  maxResultBytes;
121
+ /** Only keys, byte counts, and expiry survive requests; never write promises. */
122
+ resultStash = new Map();
123
+ resultStashBytes = 0;
120
124
  configuredConnectors;
121
125
  personalRegistries = new Map();
122
- /** `connector.tool` grants that matched nothing, warned once per isolate. */
126
+ callAdmissionClosed = false;
127
+ /** Bounded FIFO of absent grants already warned about. */
123
128
  warnedAbsentGrants = new Set();
124
129
  constructor(connectors, opts) {
125
130
  this.opts = opts;
@@ -174,18 +179,24 @@ export class Registry {
174
179
  this.personalRegistries.set(principalKey, existing);
175
180
  return existing;
176
181
  }
182
+ if (this.personalRegistries.size >= MAX_PERSONAL_REGISTRIES) {
183
+ // Eviction must not reset a live rolling budget or orphan queued calls.
184
+ const idle = [...this.personalRegistries].find(([, candidate]) => [...candidate.callAdmission.values()].every(admission => admission.isIdle()));
185
+ if (!idle) {
186
+ throw new Error("Personal connector capacity is exhausted; retry after calls and rolling budgets drain.");
187
+ }
188
+ idle[1].closeCallAdmission();
189
+ this.personalRegistries.delete(idle[0]);
190
+ }
177
191
  const registry = new Registry(this.configuredConnectors.filter((connector) => connector.authScope === "personal"), {
178
192
  ...this.opts,
179
193
  storage: namespaced(this.opts.storage, `principal:${principalKey}:`),
180
194
  credentialOwner: principalKey,
181
195
  constructionChecks: false,
182
196
  });
197
+ if (this.callAdmissionClosed)
198
+ registry.closeCallAdmission();
183
199
  this.personalRegistries.set(principalKey, registry);
184
- const oldest = this.personalRegistries.keys().next().value;
185
- if (this.personalRegistries.size > MAX_PERSONAL_REGISTRIES &&
186
- typeof oldest === "string") {
187
- this.personalRegistries.delete(oldest);
188
- }
189
200
  return registry;
190
201
  }
191
202
  /** Build the only connector view an authenticated request receives. */
@@ -214,6 +225,11 @@ export class Registry {
214
225
  if (this.warnedAbsentGrants.has(key))
215
226
  return;
216
227
  this.warnedAbsentGrants.add(key);
228
+ if (this.warnedAbsentGrants.size > MAX_ABSENT_GRANT_WARNINGS) {
229
+ const oldest = this.warnedAbsentGrants.values().next().value;
230
+ if (oldest !== undefined)
231
+ this.warnedAbsentGrants.delete(oldest);
232
+ }
217
233
  // Grant names are operator data but may carry any non-control character;
218
234
  // quote them so a line terminator a log reader honours cannot forge a line.
219
235
  const quoted = JSON.stringify(key).replace(/[\u2028\u2029]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16)}`);
@@ -345,11 +361,19 @@ export class Registry {
345
361
  return admission.acquire(input);
346
362
  return Promise.resolve({ waitMs: 0, release() { } });
347
363
  }
348
- /** Payload-free aggregate state for the open health endpoint. */
364
+ /** Connector totals across root and personal controllers; health removes ids. */
349
365
  callAdmissionSnapshot() {
350
- return Object.fromEntries([...this.callAdmission].map(([id, admission]) => [
351
- id,
352
- admission.snapshot(),
366
+ const snapshots = new Map();
367
+ for (const [id, admission] of this.callAdmission) {
368
+ snapshots.set(id, [admission.snapshot()]);
369
+ }
370
+ for (const registry of this.personalRegistries.values()) {
371
+ for (const [id, admission] of registry.callAdmission) {
372
+ snapshots.get(id)?.push(admission.snapshot());
373
+ }
374
+ }
375
+ return Object.fromEntries([...snapshots].map(([id, values]) => [
376
+ id, aggregateCallAdmissionSnapshots(values),
353
377
  ]));
354
378
  }
355
379
  /**
@@ -416,8 +440,68 @@ export class Registry {
416
440
  }
417
441
  /** Reject queued/future downstream admission; active permits release safely. */
418
442
  closeCallAdmission() {
443
+ this.callAdmissionClosed = true;
419
444
  for (const admission of this.callAdmission.values())
420
445
  admission.close();
446
+ for (const registry of this.personalRegistries.values())
447
+ registry.closeCallAdmission();
448
+ }
449
+ /**
450
+ * Reserve capacity and write one ASCII paging envelope in this runtime.
451
+ *
452
+ * `chunks[0]` lands on `<prefix><key>` and `chunks[n]` on `<prefix><key>#<n>`
453
+ * — the layout get_result reads back, so a page fetches only the chunks it
454
+ * covers instead of the whole stored result (issue #540). Chunking is a
455
+ * read-cost decision, not a capacity one: however many keys an envelope
456
+ * occupies, it is one stash entry charged its total ASCII length.
457
+ */
458
+ async stashResult(key, chunks, ttlSeconds, prefix = "results:") {
459
+ const maxBytes = this.opts.results?.maxStashBytes ?? 8 * 1024 * 1024;
460
+ const maxEntries = this.opts.results?.maxStashEntries ?? 64;
461
+ // The paging envelope is ASCII, so its string length is its stored byte count.
462
+ const bytes = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
463
+ if (bytes > maxBytes || maxEntries === 0)
464
+ return false;
465
+ const now = Date.now();
466
+ for (const [oldKey, entry] of this.resultStash) {
467
+ if (entry.busy || entry.expiresAt > now)
468
+ continue;
469
+ entry.busy = true;
470
+ try {
471
+ // TTL alone cannot reclaim a lazy backend. Keep the charge until deletion
472
+ // succeeds, including writes which persisted before throwing.
473
+ for (const staleKey of entry.keys)
474
+ await this.opts.storage.delete(staleKey);
475
+ this.resultStash.delete(oldKey);
476
+ this.resultStashBytes -= entry.bytes;
477
+ }
478
+ finally {
479
+ entry.busy = false;
480
+ }
481
+ }
482
+ if (this.resultStash.size >= maxEntries || this.resultStashBytes + bytes > maxBytes)
483
+ return false;
484
+ const fullKey = prefix + key;
485
+ const keys = chunks.map((_, index) => index === 0 ? fullKey : `${fullKey}#${index}`);
486
+ const entry = { keys, bytes, expiresAt: Infinity, busy: true };
487
+ this.resultStash.set(fullKey, entry);
488
+ this.resultStashBytes += bytes;
489
+ try {
490
+ // Trailing chunks first: the header chunk is what makes an id readable, so
491
+ // a write that fails midway leaves no envelope pointing at absent chunks.
492
+ for (let index = keys.length - 1; index >= 0; index--) {
493
+ await this.opts.storage.set(keys[index], chunks[index], { ttlSeconds });
494
+ }
495
+ entry.expiresAt = Date.now() + ttlSeconds * 1000;
496
+ return true;
497
+ }
498
+ catch (error) {
499
+ entry.expiresAt = 0;
500
+ throw error;
501
+ }
502
+ finally {
503
+ entry.busy = false;
504
+ }
421
505
  }
422
506
  /**
423
507
  * Storage namespaced to the meta-tool result store (`results:` prefix), kept
@@ -1125,10 +1209,14 @@ class ScopedRegistryView {
1125
1209
  return registry.contextFor(...args);
1126
1210
  }
1127
1211
  admitCall(...args) {
1128
- if (!this.registryFor(args[0])) {
1212
+ const registry = this.registryFor(args[0]);
1213
+ if (!registry) {
1129
1214
  return Promise.reject(new Error(`Unknown connector "${args[0]}"`));
1130
1215
  }
1131
- return this.root.admitCall(...args);
1216
+ return registry.admitCall(...args);
1217
+ }
1218
+ stashResult(key, chunks, ttlSeconds) {
1219
+ return this.root.stashResult(key, chunks, ttlSeconds, this.scope.subjectKey ? `subject:${this.scope.subjectKey}:` : "results:");
1132
1220
  }
1133
1221
  resultsStorage() {
1134
1222
  return this.scope.subjectKey
@@ -4,4 +4,7 @@ export declare const MCP_CORS_HEADERS: {
4
4
  "Access-Control-Allow-Methods": string;
5
5
  "Access-Control-Allow-Headers": string;
6
6
  };
7
- export declare function createMcpRoute(opts: ServerOptions): (context: RouteContext) => Promise<Response | null>;
7
+ export declare function createMcpRoute(opts: ServerOptions): {
8
+ handle(context: RouteContext): Promise<Response | null>;
9
+ rejectOrigin(request: Request): Response | null;
10
+ };
@@ -14,11 +14,26 @@ export const MCP_CORS_HEADERS = {
14
14
  // Browser-based MCP clients call /mcp cross-origin. Without CORS on every
15
15
  // response — errors included — the browser hides the 401, the client cannot
16
16
  // read WWW-Authenticate, and OAuth discovery silently never starts.
17
- function withMcpCors(response) {
17
+ function withMcpCors(response, request, allowedOrigin) {
18
18
  const headers = new Headers(response.headers);
19
19
  for (const [name, value] of Object.entries(MCP_CORS_HEADERS)) {
20
20
  headers.set(name, value);
21
21
  }
22
+ headers.delete("Access-Control-Allow-Origin");
23
+ if (allowedOrigin !== null)
24
+ headers.set("Access-Control-Allow-Origin", allowedOrigin);
25
+ headers.append("Vary", "Origin");
26
+ if (request.method === "OPTIONS") {
27
+ // Browsers do not interpret a prefix wildcard in Allow-Headers. Echo only
28
+ // valid SEP-2243 field names; unrelated requested headers stay disallowed.
29
+ const paramHeaders = (request.headers.get("Access-Control-Request-Headers") ?? "")
30
+ .toLowerCase().split(",").map(name => name.trim())
31
+ .filter(name => /^mcp-param-[!#$%&'*+.^_`|~0-9a-z-]+$/.test(name));
32
+ if (paramHeaders.length) {
33
+ headers.append("Access-Control-Allow-Headers", [...new Set(paramHeaders)].join(", "));
34
+ }
35
+ headers.append("Vary", "Access-Control-Request-Headers");
36
+ }
22
37
  headers.set("Access-Control-Expose-Headers", "WWW-Authenticate, Retry-After, mcp-session-id, mcp-protocol-version");
23
38
  return new Response(response.body, {
24
39
  status: response.status,
@@ -46,7 +61,10 @@ function requestAdmissionFailure(error) {
46
61
  jsonrpc: "2.0",
47
62
  id: null,
48
63
  error: {
49
- code: overloaded ? -32001 : -32002,
64
+ // MCP 2026-07-28 basic#error-codes forbids new allocations in the
65
+ // legacy -32000..-32019 range. Use application codes outside the
66
+ // JSON-RPC reserved range, avoiding retired protocol meanings.
67
+ code: overloaded ? -31001 : -31002,
50
68
  message: overloaded
51
69
  ? "Server capacity is exhausted. Retry later."
52
70
  : "Server is shutting down.",
@@ -244,6 +262,49 @@ async function serveMcp(request, opts, baseUrl, actor, registry, canManageAuth,
244
262
  return transport.handleRequest(request);
245
263
  }
246
264
  export function createMcpRoute(opts) {
265
+ const configuredOrigins = opts.allowedOrigins;
266
+ const isExactOrigin = (value) => {
267
+ if (typeof value !== "string")
268
+ return false;
269
+ try {
270
+ const url = new URL(value);
271
+ return (url.protocol === "http:" || url.protocol === "https:") && url.origin === value;
272
+ }
273
+ catch {
274
+ return false;
275
+ }
276
+ };
277
+ if (configuredOrigins !== undefined && configuredOrigins !== "*" &&
278
+ (!Array.isArray(configuredOrigins) || !configuredOrigins.every(isExactOrigin))) {
279
+ throw new TypeError('ConnectaConfig.allowedOrigins must be an array of exact HTTP(S) origins or "*".');
280
+ }
281
+ const origins = new Set(configuredOrigins === undefined
282
+ ? opts.publicUrl ? [new URL(opts.publicUrl).origin] : []
283
+ : configuredOrigins === "*" ? [] : configuredOrigins);
284
+ const allowsOrigin = (origin) => {
285
+ if (configuredOrigins === "*")
286
+ return true;
287
+ if (!isExactOrigin(origin))
288
+ return false;
289
+ if (origins.has(origin))
290
+ return true;
291
+ if (configuredOrigins !== undefined)
292
+ return false;
293
+ const hostname = new URL(origin).hostname;
294
+ return hostname === "localhost" || hostname === "[::1]" || /^127\.\d+\.\d+\.\d+$/.test(hostname);
295
+ };
296
+ const rejectOrigin = (request) => {
297
+ const path = new URL(request.url).pathname;
298
+ if (path !== "/mcp" && !path.startsWith("/mcp/"))
299
+ return null;
300
+ const origin = request.headers.get("Origin");
301
+ if (origin === null || allowsOrigin(origin))
302
+ return null;
303
+ return withMcpCors(new Response('{"error":"origin not allowed"}', {
304
+ status: 403,
305
+ headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
306
+ }), request, null);
307
+ };
247
308
  let lastAdmissionWarningAt = 0;
248
309
  let suppressedAdmissionWarnings = 0;
249
310
  const warnAdmissionRejected = (error) => {
@@ -261,12 +322,21 @@ export function createMcpRoute(opts) {
261
322
  lastAdmissionWarningAt = now;
262
323
  suppressedAdmissionWarnings = 0;
263
324
  };
264
- return async function routeMcp(context) {
325
+ async function routeMcp(context) {
265
326
  const { path, request, baseUrl, runtimeContext, } = context;
266
- const poolPath = /^\/mcp\/([a-z0-9_-]+)$/.exec(path);
267
- if (path !== "/mcp" && !poolPath)
327
+ if (path !== "/mcp" && !path.startsWith("/mcp/"))
268
328
  return null;
269
- const poolName = poolPath?.[1];
329
+ const poolName = path === "/mcp" ? undefined : path.slice("/mcp/".length);
330
+ const origin = request.headers.get("Origin");
331
+ const allowed = origin === null || allowsOrigin(origin);
332
+ const cors = (response) => withMcpCors(response, request, configuredOrigins === "*" ? "*" : allowed ? origin : null);
333
+ // DNS-rebinding refusals cost neither a permit nor an auth lookup. This
334
+ // local header check also guards OPTIONS before any provider metadata.
335
+ const refusal = rejectOrigin(request);
336
+ if (refusal)
337
+ return refusal;
338
+ if (request.method === "OPTIONS")
339
+ return cors(new Response(null, { status: 204 }));
270
340
  let admission;
271
341
  try {
272
342
  admission = await opts.requestAdmission.acquire({
@@ -289,14 +359,14 @@ export function createMcpRoute(opts) {
289
359
  if (error.code === "executor_overloaded") {
290
360
  warnAdmissionRejected(error);
291
361
  }
292
- return withMcpCors(requestAdmissionFailure(error));
362
+ return cors(requestAdmissionFailure(error));
293
363
  }
294
364
  throw error;
295
365
  }
296
366
  try {
297
367
  const authz = await authorize(request, baseUrl, opts.auth, runtimeContext, opts.identity);
298
368
  if (!authz.ok) {
299
- return releaseAdmissionWithResponse(withMcpCors(authz.response), admission, request.signal);
369
+ return releaseAdmissionWithResponse(cors(authz.response), admission, request.signal);
300
370
  }
301
371
  // A pool endpoint narrows the identity's own view and nothing else. An
302
372
  // undeclared name, a grant that refuses, and a grant that throws are
@@ -319,7 +389,7 @@ export function createMcpRoute(opts) {
319
389
  if (!pool || !granted) {
320
390
  opts.logger.warn(`[connecta] refused /mcp/${poolName} with 404: pool ${reason}` +
321
391
  (authz.actor.id ? ` for ${loggableValue(authz.actor.id)}` : ""));
322
- return releaseAdmissionWithResponse(withMcpCors(new Response("Not Found", { status: 404 })), admission, request.signal);
392
+ return releaseAdmissionWithResponse(cors(new Response("Not Found", { status: 404 })), admission, request.signal);
323
393
  }
324
394
  access = intersectAccess(authz, pool.access);
325
395
  }
@@ -334,19 +404,20 @@ export function createMcpRoute(opts) {
334
404
  });
335
405
  }
336
406
  catch (error) {
337
- return releaseAdmissionWithResponse(withMcpCors(new Response(JSON.stringify({ error: msg(error) }), {
407
+ return releaseAdmissionWithResponse(cors(new Response(JSON.stringify({ error: msg(error) }), {
338
408
  status: 403,
339
409
  headers: { "Content-Type": "application/json" },
340
410
  })), admission, request.signal);
341
411
  }
342
412
  if (new URL(request.url).searchParams.has("toolkit")) {
343
- return releaseAdmissionWithResponse(withMcpCors(toolkitRetired(opts.logger)), admission, request.signal);
413
+ return releaseAdmissionWithResponse(cors(toolkitRetired(opts.logger)), admission, request.signal);
344
414
  }
345
- return releaseAdmissionWithResponse(withMcpCors(await serveMcp(request, opts, baseUrl, authz.actor, scopedRegistry, id => { const connector = scopedRegistry.getConnector(id); return Boolean(connector && mayManageConnector(authz, connector)); }, runtimeContext)), admission, request.signal);
415
+ return releaseAdmissionWithResponse(cors(await serveMcp(request, opts, baseUrl, authz.actor, scopedRegistry, id => { const connector = scopedRegistry.getConnector(id); return Boolean(connector && mayManageConnector(authz, connector)); }, runtimeContext)), admission, request.signal);
346
416
  }
347
417
  catch (error) {
348
418
  admission.release();
349
419
  throw error;
350
420
  }
351
- };
421
+ }
422
+ return { handle: routeMcp, rejectOrigin };
352
423
  }
@@ -137,6 +137,10 @@ export async function routeOAuthCallback(context) {
137
137
  return refused();
138
138
  }
139
139
  const expectedPrincipalKey = callbackTarget?.principalKey;
140
+ // A browser returning from consent normally has no MCP Authorization
141
+ // header. An interactive bearer provider therefore answers 401 here; state
142
+ // and the saved state-to-principal handoff still prove ownership below.
143
+ // Rejecting 401 would break that callback. A 403 is an explicit denial.
140
144
  const browserIdentity = await authorizeUiIdentity(context.request, baseUrl, opts.auth, "OAuth callback", context.runtimeContext, opts.identity);
141
145
  if (browserIdentity.ok) {
142
146
  try {
@@ -16,6 +16,7 @@ export interface ServerOptions {
16
16
  /** Validated named pools served at `/mcp/<name>`; empty when none declared. */
17
17
  pools?: ReadonlyMap<string, ResolvedPool> | undefined;
18
18
  publicUrl?: string | undefined;
19
+ allowedOrigins?: readonly string[] | "*" | undefined;
19
20
  serverInfo: Implementation;
20
21
  logger: Logger;
21
22
  activity?: ActivityStore | undefined;