@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.
- package/AGENTS.md +18 -20
- package/CHANGELOG.md +205 -1
- package/README.md +5 -6
- package/dist/auth/bearer.js +2 -0
- package/dist/auth/downstream-oauth.d.ts +12 -1
- package/dist/auth/downstream-oauth.js +147 -35
- package/dist/branding.d.ts +31 -2
- package/dist/branding.js +116 -8
- package/dist/call-admission.d.ts +4 -0
- package/dist/call-admission.js +26 -0
- package/dist/catalog-drift.js +9 -4
- package/dist/catalog-service.d.ts +2 -0
- package/dist/catalog-service.js +25 -8
- package/dist/catalog.d.ts +2 -0
- package/dist/catalog.js +246 -121
- package/dist/connectors/api.d.ts +1 -1
- package/dist/connectors/api.js +21 -3
- package/dist/connectors/guarded-fetch.d.ts +6 -2
- package/dist/connectors/guarded-fetch.js +61 -24
- package/dist/connectors/remote-mcp.js +92 -57
- package/dist/errors.d.ts +28 -3
- package/dist/errors.js +60 -1
- package/dist/execute.js +85 -23
- package/dist/executor-result.js +3 -1
- package/dist/executors/quickjs-child.js +5 -1
- package/dist/executors/quickjs-protocol.d.ts +4 -0
- package/dist/executors/quickjs-runtime.d.ts +1 -1
- package/dist/executors/quickjs-runtime.js +38 -21
- package/dist/executors/quickjs.js +68 -27
- package/dist/index.d.ts +15 -1
- package/dist/index.js +36 -4
- package/dist/invocation.js +134 -93
- package/dist/mcp-result.js +3 -2
- package/dist/meta-tools.js +191 -36
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/view.d.ts +38 -1
- package/dist/operator-ui/view.js +71 -0
- package/dist/providers/cloudflare.d.ts +14 -2
- package/dist/providers/cloudflare.js +107 -16
- package/dist/providers/linear.d.ts +26 -4
- package/dist/providers/linear.js +19 -4
- package/dist/providers/mixpanel.d.ts +16 -3
- package/dist/providers/mixpanel.js +13 -2
- package/dist/providers/notion.d.ts +8 -1
- package/dist/providers/notion.js +83 -10
- package/dist/providers/revenuecat.d.ts +30 -4
- package/dist/providers/revenuecat.js +42 -4
- package/dist/providers/stripe.d.ts +7 -1
- package/dist/providers/stripe.js +30 -4
- package/dist/providers/vercel.js +11 -1
- package/dist/registry.d.ts +22 -2
- package/dist/registry.js +101 -13
- package/dist/routes/mcp.d.ts +4 -1
- package/dist/routes/mcp.js +84 -13
- package/dist/routes/oauth.js +4 -0
- package/dist/routes/shared.d.ts +1 -0
- package/dist/routes/shared.js +4 -4
- package/dist/server.js +15 -3
- package/dist/skills.js +6 -5
- package/dist/storage/file.d.ts +6 -2
- package/dist/storage/file.js +312 -34
- package/dist/storage/memory.js +12 -1
- package/dist/types.d.ts +37 -0
- package/dist/ui.js +18 -10
- package/dist/validate.js +3 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +193 -165
- package/documentation/auth.md +199 -145
- package/documentation/code-mode.md +433 -316
- package/documentation/meta-tools.md +363 -355
- package/examples/worker/AGENTS.md +2 -1
- package/examples/worker/README.md +12 -10
- package/examples/worker/src/index.ts +12 -15
- package/package.json +1 -2
- package/templates/node/.env.example +3 -3
- package/templates/node/AGENTS.md +5 -4
- package/templates/node/README.md +2 -1
- package/templates/node/package.json +1 -1
- package/templates/node/src/index.ts +23 -22
- package/documentation/call-admission.md +0 -142
- package/documentation/cloudflare.md +0 -471
- package/documentation/connector-guides.md +0 -176
- package/documentation/connectors.md +0 -389
- package/documentation/linear.md +0 -193
- package/documentation/mixpanel.md +0 -160
- package/documentation/notion.md +0 -308
- package/documentation/operations.md +0 -359
- package/documentation/operator-ui.md +0 -135
- package/documentation/optional-modules-upgrade.md +0 -243
- package/documentation/provider-conventions.md +0 -722
- package/documentation/request-admission.md +0 -170
- package/documentation/revenuecat.md +0 -305
- package/documentation/storage-and-credentials.md +0 -201
- package/documentation/stripe.md +0 -262
- package/documentation/upgrading.md +0 -754
- 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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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;
|
package/dist/providers/stripe.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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;
|
package/dist/providers/vercel.js
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
/**
|
|
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";
|
package/dist/registry.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
364
|
+
/** Connector totals across root and personal controllers; health removes ids. */
|
|
349
365
|
callAdmissionSnapshot() {
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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
|
|
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
|
package/dist/routes/mcp.d.ts
CHANGED
|
@@ -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):
|
|
7
|
+
export declare function createMcpRoute(opts: ServerOptions): {
|
|
8
|
+
handle(context: RouteContext): Promise<Response | null>;
|
|
9
|
+
rejectOrigin(request: Request): Response | null;
|
|
10
|
+
};
|
package/dist/routes/mcp.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
325
|
+
async function routeMcp(context) {
|
|
265
326
|
const { path, request, baseUrl, runtimeContext, } = context;
|
|
266
|
-
|
|
267
|
-
if (path !== "/mcp" && !poolPath)
|
|
327
|
+
if (path !== "/mcp" && !path.startsWith("/mcp/"))
|
|
268
328
|
return null;
|
|
269
|
-
const poolName =
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
413
|
+
return releaseAdmissionWithResponse(cors(toolkitRetired(opts.logger)), admission, request.signal);
|
|
344
414
|
}
|
|
345
|
-
return releaseAdmissionWithResponse(
|
|
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
|
}
|
package/dist/routes/oauth.js
CHANGED
|
@@ -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 {
|
package/dist/routes/shared.d.ts
CHANGED
|
@@ -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;
|