@zackbart/connecta 0.38.0 → 0.40.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.
- package/CHANGELOG.md +91 -0
- package/README.md +3 -2
- package/bin/connecta.mjs +4 -8
- package/bin/version.mjs +1 -1
- package/dist/access-tokens.js +6 -4
- package/dist/agent-routes.js +231 -0
- package/dist/auth/downstream-oauth/consent.js +519 -0
- package/dist/auth/downstream-oauth/grant-store.js +439 -0
- package/dist/auth/downstream-oauth/refresh-coordinator.js +562 -0
- package/dist/auth/downstream-oauth/refresh-records.js +447 -0
- package/dist/auth/downstream-oauth/sdk-hooks.js +242 -0
- package/dist/auth/downstream-oauth.js +66 -2382
- package/dist/auth/native.js +37 -0
- package/dist/auth/oauth-grant.js +13 -0
- package/dist/auth/static-oauth.js +153 -131
- package/dist/auth-elicitation.js +7 -1
- package/dist/authorization-handoff.js +11 -5
- package/dist/catalog-cache.js +219 -59
- package/dist/catalog-policy.js +18 -0
- package/dist/catalog-service.js +284 -47
- package/dist/catalog.js +18 -2
- package/dist/config.d.ts +16 -11
- package/dist/config.js +9 -6
- package/dist/connector-caller.d.ts +1 -1
- package/dist/connector-health.js +59 -0
- package/dist/connector-status.js +117 -0
- package/dist/connectors/api-connector.d.ts +7 -2
- package/dist/connectors/api-connector.js +7 -4
- package/dist/connectors/negotiation-cache.js +22 -4
- package/dist/connectors/remote-mcp/call.js +164 -0
- package/dist/connectors/remote-mcp/catalog.js +278 -0
- package/dist/connectors/remote-mcp/config.js +263 -0
- package/dist/connectors/remote-mcp/connection.js +682 -0
- package/dist/connectors/remote-mcp/errors.js +488 -0
- package/dist/connectors/remote-mcp/oauth.js +473 -0
- package/dist/connectors/remote-mcp/resources.js +473 -0
- package/dist/connectors/remote-mcp/runtime.js +1 -0
- package/dist/connectors/remote-mcp/transport.js +555 -0
- package/dist/connectors/remote-mcp.js +62 -2863
- package/dist/credential-contract.d.ts +23 -0
- package/dist/credential-reads.js +17 -0
- package/dist/credential-verification.js +53 -0
- package/dist/credentials.d.ts +7 -0
- package/dist/credentials.js +60 -25
- package/dist/describe-config.d.ts +12 -2
- package/dist/describe-config.js +2 -0
- package/dist/discovery-health.js +199 -0
- package/dist/downstream-input.js +2 -0
- package/dist/errors.d.ts +55 -17
- package/dist/errors.js +95 -41
- package/dist/execute.js +152 -114
- package/dist/executor-result.js +3 -1
- package/dist/guest-functions.js +83 -0
- package/dist/guest-runtime.js +60 -19
- package/dist/guest-types.d.ts +19 -5
- package/dist/http/argument-problems.js +35 -0
- package/dist/http/errors.js +5 -4
- package/dist/http/fields.js +192 -13
- package/dist/http/hatch-response.js +73 -0
- package/dist/http/hatch.js +88 -23
- package/dist/http/oauth.js +3 -2
- package/dist/http/provider.js +32 -4
- package/dist/http/safety.js +8 -0
- package/dist/http/tools.js +44 -23
- package/dist/index.d.ts +1 -1
- package/dist/index.js +9 -2
- package/dist/invocation.js +26 -8
- package/dist/membership.d.ts +13 -5
- package/dist/membership.js +34 -2
- package/dist/meta-tools.js +54 -31
- package/dist/native-recovery.js +4 -16
- package/dist/native-tools.js +5 -2
- package/dist/oauth-sealing.js +10 -0
- package/dist/operation-vocabulary.js +135 -13
- package/dist/operator-record.d.ts +2 -2
- package/dist/operator-record.js +1 -0
- package/dist/operator-ui/contract.d.ts +10 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +22 -1
- package/dist/provider-call-facts.js +6 -2
- package/dist/providers/_shared/rest/operation-index.js +48 -50
- package/dist/providers/_shared/rest/value-safety.js +80 -0
- package/dist/providers/breeze/api.js +7 -1
- package/dist/providers/breeze/skill.generated.js +1 -1
- package/dist/providers/breeze/tools.js +4 -3
- package/dist/providers/ccb/api.js +2 -1
- package/dist/providers/ccb/index.js +5 -0
- package/dist/providers/ccb/skill.generated.js +1 -1
- package/dist/providers/cloudflare/api.js +8 -1
- package/dist/providers/cloudflare/skill.generated.js +1 -1
- package/dist/providers/docs/lib/schemas.js +24 -22
- package/dist/providers/docs/safety.js +2 -0
- package/dist/providers/docs/skill.generated.js +1 -1
- package/dist/providers/docs/tools.js +2 -4
- package/dist/providers/drive/api.js +1 -0
- package/dist/providers/drive/lib/contracts.js +8 -5
- package/dist/providers/drive/skill.generated.js +1 -1
- package/dist/providers/drive/tools.js +11 -16
- package/dist/providers/forms/api.js +1 -0
- package/dist/providers/forms/skill.generated.js +1 -1
- package/dist/providers/forms/tools.js +10 -7
- package/dist/providers/github/api.js +1 -0
- package/dist/providers/github/lib/discovery.js +1 -1
- package/dist/providers/github/skill.generated.js +1 -1
- package/dist/providers/github/tools.js +61 -17
- package/dist/providers/gmail/lib/schemas.js +60 -50
- package/dist/providers/gmail/skill.generated.js +1 -1
- package/dist/providers/gmail/tools.js +7 -5
- package/dist/providers/granola/index.js +2 -0
- package/dist/providers/granola/skill.generated.js +1 -1
- package/dist/providers/overflow/api.js +7 -1
- package/dist/providers/overflow/skill.generated.js +1 -1
- package/dist/providers/planning-center/api.js +3 -1
- package/dist/providers/planning-center/lib/constants.d.ts +1 -1
- package/dist/providers/planning-center/skill.generated.js +1 -1
- package/dist/providers/quickbooks/safety.js +80 -39
- package/dist/providers/quickbooks/skill.generated.js +1 -1
- package/dist/providers/sheets/api.js +1 -0
- package/dist/providers/sheets/skill.generated.js +1 -1
- package/dist/providers/sheets/tools.js +13 -14
- package/dist/providers/slides/skill.generated.js +1 -1
- package/dist/providers/slides/tools.js +15 -13
- package/dist/providers/stripe/skill.generated.js +1 -1
- package/dist/providers/tithely/api.js +3 -1
- package/dist/providers/tithely/skill.generated.js +1 -1
- package/dist/providers/vercel/api.js +1 -1
- package/dist/providers/vercel/skill.generated.js +1 -1
- package/dist/providers/vercel/tools.js +4 -3
- package/dist/registry.d.ts +10 -4
- package/dist/registry.js +41 -13
- package/dist/routes/credentials.js +129 -60
- package/dist/routes/dispatch.js +5 -1
- package/dist/routes/mcp.js +30 -14
- package/dist/routes/operator.js +2 -2
- package/dist/routes/shared.d.ts +6 -1
- package/dist/routes/shared.js +7 -1
- package/dist/routes/ui-config.js +2 -2
- package/dist/routes/ui-data.js +126 -20
- package/dist/routes/ui.js +16 -0
- package/dist/runtime/call.js +7 -2
- package/dist/runtime/native-storage.js +12 -0
- package/dist/runtime/request-reads.js +207 -0
- package/dist/runtime/request-scope.js +16 -0
- package/dist/runtime/storage.js +12 -0
- package/dist/skills.js +37 -20
- package/dist/storage/keys.js +53 -3
- package/dist/storage/memory.js +8 -2
- package/dist/storage/sql-native.js +53 -1
- package/dist/storage/sql.js +12 -2
- package/dist/storage-digest.js +32 -0
- package/dist/timeout.js +8 -0
- package/dist/types.d.ts +33 -0
- package/dist/usage-guide.js +16 -35
- package/dist/validate.d.ts +3 -0
- package/dist/validate.js +220 -158
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/write-outcome.js +0 -3
- package/documentation/README.md +13 -13
- package/documentation/architecture.md +385 -20
- package/documentation/auth.md +233 -64
- package/documentation/code-mode.md +122 -39
- package/documentation/deploying.md +6 -3
- package/documentation/integrating.md +16 -2
- package/documentation/meta-tools.md +240 -49
- package/documentation/migration-0.33.md +5 -1
- package/documentation/migration-0.34.md +3 -1
- package/documentation/operator-tests.md +1 -1
- package/documentation/operator-ui.md +105 -12
- package/documentation/pool-membership-upgrade.md +44 -0
- package/documentation/providers.md +105 -0
- package/examples/worker/AGENTS.md +10 -10
- package/examples/worker/README.md +4 -3
- package/examples/worker/src/connecta.config.ts +1 -0
- package/guides/connecting-agents.md +2 -1
- package/guides/how-it-works.md +4 -2
- package/package.json +1 -1
- package/templates/node/AGENTS.md +13 -12
- package/templates/node/package.json +1 -1
- package/templates/node/src/connecta.config.ts +1 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// Catalog intake: every tools/resources listing page is checked before the
|
|
2
|
+
// SDK can aggregate or cache it (INV-8), and listTools serves the result.
|
|
3
|
+
import { authFlow, authCall, authInvoke, authMethod } from "../../auth/native.js";
|
|
4
|
+
import { SdkHttpError, specTypeSchemas, UnauthorizedError } from "@modelcontextprotocol/client";
|
|
5
|
+
import { redactCatalog } from "../../sent-secrets.js";
|
|
6
|
+
import { MAX_CATALOG_TOOLS, MAX_SERIALIZED_CATALOG_BYTES } from "../../catalog-limits.js";
|
|
7
|
+
import { catalogItems, observeCompletedCatalogRefresh, observeCatalogFetch, observeCatalogExpiry, } from "../../catalog-cache.js";
|
|
8
|
+
import { ConnectorCallError, msg } from "../../errors.js";
|
|
9
|
+
import { carryFailureFacts, failureRecord, logFailure } from "../../operator-record.js";
|
|
10
|
+
import { assertDownstreamOutputSafe } from "../../downstream-input-context.js";
|
|
11
|
+
import { hasSdkPayload, ownAbortReason, requiresAuthorization } from "./errors.js";
|
|
12
|
+
/** SDK aggregation cap; intake also refuses loops and non-progress (INV-8). */
|
|
13
|
+
export const MAX_TOOL_PAGES = 10_000;
|
|
14
|
+
/**
|
|
15
|
+
* Compatibility concession for hand-rolled servers that serialize
|
|
16
|
+
* end-of-pagination as `null`. Only the cursor is widened; every tool and every
|
|
17
|
+
* other result field still passes through the SDK's pinned schema.
|
|
18
|
+
*/
|
|
19
|
+
const CompatibleListToolsResultSchema = {
|
|
20
|
+
"~standard": {
|
|
21
|
+
version: 1,
|
|
22
|
+
vendor: "connecta",
|
|
23
|
+
validate(value) {
|
|
24
|
+
const normalized = typeof value === "object" && value !== null && "nextCursor" in value && value.nextCursor === null
|
|
25
|
+
? (() => {
|
|
26
|
+
const copy = { ...value };
|
|
27
|
+
delete copy.nextCursor;
|
|
28
|
+
return copy;
|
|
29
|
+
})()
|
|
30
|
+
: value;
|
|
31
|
+
return specTypeSchemas.ListToolsResult["~standard"].validate(normalized);
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* True for a result-parse failure caused by the page's `nextCursor` itself.
|
|
37
|
+
* `null` is accepted deliberately; other non-string values remain a named
|
|
38
|
+
* downstream nonconformance instead of surfacing as a raw validation dump.
|
|
39
|
+
* Duck-typed rather than `instanceof ZodError`: the SDK may parse with its own
|
|
40
|
+
* zod instance, and cross-instance `instanceof` is a coin flip.
|
|
41
|
+
*/
|
|
42
|
+
function isCursorShapeError(err) {
|
|
43
|
+
const issues = err?.issues;
|
|
44
|
+
if (Array.isArray(issues) &&
|
|
45
|
+
issues.some((issue) => {
|
|
46
|
+
const path = issue.path;
|
|
47
|
+
return Array.isArray(path) && path[0] === "nextCursor";
|
|
48
|
+
})) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
// SDK v2 wraps Standard Schema failures in a ProtocolError and preserves the
|
|
52
|
+
// failing path in the message rather than exposing the validator's issues.
|
|
53
|
+
return msg(err).startsWith("Invalid result for tools/list: nextCursor:");
|
|
54
|
+
}
|
|
55
|
+
/** One connector's catalog intake and listTools operation. */
|
|
56
|
+
export function remoteMcpCatalog(rt) {
|
|
57
|
+
const { id } = rt;
|
|
58
|
+
// Listings that must reach the downstream: a credential test proves the
|
|
59
|
+
// stored value against the server, never against a cached catalog.
|
|
60
|
+
const liveListings = new WeakSet();
|
|
61
|
+
/** Wrap the public request seam, before listTools aggregates or auto-caches.
|
|
62
|
+
* SDK 2.3.1 treats an identical repeated page as completion. Refuse it here:
|
|
63
|
+
* an advertised successor is never proof of a complete catalog (INV-8). */
|
|
64
|
+
const listingContexts = new WeakMap();
|
|
65
|
+
const installCatalogIntake = (client, ctx, cache) => {
|
|
66
|
+
let toolPages = 0;
|
|
67
|
+
const listingContext = (options) => {
|
|
68
|
+
const { signal: _signal, timeoutMs: _timeout, ...base } = ctx;
|
|
69
|
+
return ((options && listingContexts.get(options)) ?? {
|
|
70
|
+
...base,
|
|
71
|
+
requestScope: ctx.requestScope ?? ctx,
|
|
72
|
+
...(options?.signal ? { signal: options.signal } : {}),
|
|
73
|
+
...(options?.timeout ? { timeoutMs: options.timeout } : {}),
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
const listTools = client.listTools.bind(client);
|
|
77
|
+
client.listTools = authMethod((params, options) => authInvoke(cache, "withListing", [
|
|
78
|
+
listingContext(options),
|
|
79
|
+
authMethod(() => authFlow(function* () {
|
|
80
|
+
const before = toolPages;
|
|
81
|
+
let result;
|
|
82
|
+
try {
|
|
83
|
+
result = (yield authCall(listTools, [params, options]));
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
const bounded = rt.atMcpBoundary(cache.currentContext(), error, "tools/list", client.transport, [
|
|
87
|
+
cache.currentContext().signal,
|
|
88
|
+
]);
|
|
89
|
+
const transient = (error instanceof SdkHttpError && [502, 503, 504].includes(error.status)) ||
|
|
90
|
+
(bounded instanceof ConnectorCallError && bounded.code === "unavailable");
|
|
91
|
+
const fallback = transient && toolPages === before
|
|
92
|
+
? (yield authInvoke(cache, "fallbackTools", []))
|
|
93
|
+
: undefined;
|
|
94
|
+
if (!fallback)
|
|
95
|
+
throw error;
|
|
96
|
+
return fallback;
|
|
97
|
+
}
|
|
98
|
+
if (toolPages !== before && params?.cursor === undefined) {
|
|
99
|
+
const refresh = (yield authInvoke(cache, "completeCatalogRefresh", [result]));
|
|
100
|
+
if (refresh) {
|
|
101
|
+
try {
|
|
102
|
+
(yield authCall(observeCompletedCatalogRefresh, [cache.currentContext(), refresh]));
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
logFailure(cache.currentContext().logger, "catalog refresh observation failed", failureRecord({ connector: id }, error));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return result;
|
|
110
|
+
})),
|
|
111
|
+
]));
|
|
112
|
+
const listResources = client.listResources.bind(client);
|
|
113
|
+
client.listResources = authMethod((params, options) => authInvoke(cache, "withListing", [
|
|
114
|
+
listingContext(options),
|
|
115
|
+
authMethod(() => authCall(listResources, [params, options])),
|
|
116
|
+
]));
|
|
117
|
+
const listResourceTemplates = client.listResourceTemplates.bind(client);
|
|
118
|
+
client.listResourceTemplates = authMethod((params, options) => authInvoke(cache, "withListing", [
|
|
119
|
+
listingContext(options),
|
|
120
|
+
authMethod(() => authCall(listResourceTemplates, [params, options])),
|
|
121
|
+
]));
|
|
122
|
+
const request = client.request.bind(client);
|
|
123
|
+
const walks = new WeakMap();
|
|
124
|
+
client.request = authMethod((...args) => authFlow(function* () {
|
|
125
|
+
const message = args[0];
|
|
126
|
+
if (message.method !== "tools/list" &&
|
|
127
|
+
message.method !== "resources/list" &&
|
|
128
|
+
message.method !== "resources/templates/list")
|
|
129
|
+
return yield authCall(request, args, client);
|
|
130
|
+
const method = message.method;
|
|
131
|
+
const ctx = cache.currentContext();
|
|
132
|
+
const options = (args.length === 3 ? args[2] : args[1]);
|
|
133
|
+
if (rt.isClosed(rt.entryFor(ctx)))
|
|
134
|
+
throw rt.scopeEndedError();
|
|
135
|
+
if (options?.signal?.aborted)
|
|
136
|
+
throw options.signal.reason;
|
|
137
|
+
const key = options ?? message;
|
|
138
|
+
let walk = walks.get(key);
|
|
139
|
+
if (message.params?.cursor === undefined || !walk) {
|
|
140
|
+
walk = { names: new Set(), cursors: new Set(), barren: 0, bytes: 0 };
|
|
141
|
+
walks.set(key, walk);
|
|
142
|
+
}
|
|
143
|
+
let page;
|
|
144
|
+
try {
|
|
145
|
+
page = (yield authCall(request, [
|
|
146
|
+
message,
|
|
147
|
+
method === "tools/list"
|
|
148
|
+
? CompatibleListToolsResultSchema
|
|
149
|
+
: method === "resources/list"
|
|
150
|
+
? specTypeSchemas.ListResourcesResult
|
|
151
|
+
: specTypeSchemas.ListResourceTemplatesResult,
|
|
152
|
+
options,
|
|
153
|
+
]));
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (isCursorShapeError(error))
|
|
157
|
+
throw new ConnectorCallError("connector_call_failed", "Downstream catalog nextCursor must be a string, null, or absent.", { retryable: false });
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
assertDownstreamOutputSafe(ctx.requestScope ?? ctx, page);
|
|
161
|
+
const clean = cache.intake(ctx, method, page);
|
|
162
|
+
if (method === "tools/list")
|
|
163
|
+
toolPages++;
|
|
164
|
+
if (message.params?.cursor === undefined)
|
|
165
|
+
observeCatalogFetch(ctx, Date.now(), method);
|
|
166
|
+
const tools = catalogItems(method, clean).filter((item) => {
|
|
167
|
+
const name = method === "tools/list"
|
|
168
|
+
? item.name
|
|
169
|
+
: method === "resources/list"
|
|
170
|
+
? item.uri
|
|
171
|
+
: item.uriTemplate;
|
|
172
|
+
if (walk.names.has(name))
|
|
173
|
+
return false;
|
|
174
|
+
walk.names.add(name);
|
|
175
|
+
return true;
|
|
176
|
+
});
|
|
177
|
+
walk.bytes += new TextEncoder().encode(JSON.stringify(tools)).byteLength;
|
|
178
|
+
if (walk.names.size > MAX_CATALOG_TOOLS || walk.bytes > MAX_SERIALIZED_CATALOG_BYTES) {
|
|
179
|
+
throw new ConnectorCallError("connector_call_failed", "Downstream catalog exceeds the complete-catalog ceiling.", { retryable: false });
|
|
180
|
+
}
|
|
181
|
+
if (clean.nextCursor !== undefined) {
|
|
182
|
+
if (walk.cursors.has(clean.nextCursor))
|
|
183
|
+
throw new ConnectorCallError("connector_call_failed", "Downstream catalog pagination chain loops.", {
|
|
184
|
+
retryable: false,
|
|
185
|
+
});
|
|
186
|
+
if (tools.length === 0 && ++walk.barren > 1)
|
|
187
|
+
throw new ConnectorCallError("connector_call_failed", "Downstream catalog pagination is not advancing.", {
|
|
188
|
+
retryable: false,
|
|
189
|
+
});
|
|
190
|
+
if (tools.length > 0)
|
|
191
|
+
walk.barren = 0;
|
|
192
|
+
walk.cursors.add(clean.nextCursor);
|
|
193
|
+
}
|
|
194
|
+
const field = method === "tools/list" ? "tools" : method === "resources/list" ? "resources" : "resourceTemplates";
|
|
195
|
+
const result = { ...clean, [field]: tools };
|
|
196
|
+
if (!walk.first)
|
|
197
|
+
walk.first = result;
|
|
198
|
+
else {
|
|
199
|
+
walk.first.ttlMs = Math.min(walk.first.ttlMs, clean.ttlMs);
|
|
200
|
+
if (clean.cacheScope !== "public")
|
|
201
|
+
walk.first.cacheScope = "private";
|
|
202
|
+
}
|
|
203
|
+
observeCatalogExpiry(ctx, method, walk.first.ttlMs);
|
|
204
|
+
return result;
|
|
205
|
+
}));
|
|
206
|
+
};
|
|
207
|
+
// The SDK walks the whole chain and writes one complete result. Intake
|
|
208
|
+
// runs on its public request seam before any page can enter that cache.
|
|
209
|
+
const listToolsOperation = authMethod((ctx) => authFlow(function* () {
|
|
210
|
+
const state = rt.stateFor(ctx);
|
|
211
|
+
// Without a connection in this scope, a complete cached catalog is
|
|
212
|
+
// served as it stands. A live connection reads the same cache through
|
|
213
|
+
// the SDK, and a credential test always lists live.
|
|
214
|
+
const cached = state.client || state.connecting || state.authRequired || liveListings.has(ctx)
|
|
215
|
+
? {}
|
|
216
|
+
: (yield authCall(rt.cachedCatalog, [ctx]));
|
|
217
|
+
let clean;
|
|
218
|
+
if (cached.tools)
|
|
219
|
+
clean = redactCatalog(ctx, cached.tools);
|
|
220
|
+
else {
|
|
221
|
+
const client = (yield authCall(rt.ensureConnected, [ctx, state, cached]));
|
|
222
|
+
// A credential test must observe the downstream answer itself, never
|
|
223
|
+
// the SDK cache a modern connection reads without any request.
|
|
224
|
+
const options = {
|
|
225
|
+
...rt.requestOptions(ctx),
|
|
226
|
+
...(liveListings.has(ctx) ? { cacheMode: "bypass" } : {}),
|
|
227
|
+
};
|
|
228
|
+
listingContexts.set(options, ctx);
|
|
229
|
+
try {
|
|
230
|
+
const result = (yield authInvoke(client, "listTools", [undefined, options]));
|
|
231
|
+
clean = redactCatalog(ctx, result.tools);
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
if (ownAbortReason(err, [ctx.signal]) && !hasSdkPayload(err))
|
|
235
|
+
throw err;
|
|
236
|
+
const bounded = rt.atMcpBoundary(ctx, err, "tools/list", client.transport, [ctx.signal]);
|
|
237
|
+
if (requiresAuthorization(bounded)) {
|
|
238
|
+
if (state.client === client)
|
|
239
|
+
state.authRequired = true;
|
|
240
|
+
if (bounded instanceof UnauthorizedError)
|
|
241
|
+
throw carryFailureFacts(bounded, rt.authRequiredError(ctx));
|
|
242
|
+
}
|
|
243
|
+
throw bounded;
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
listingContexts.delete(options);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
state.toolDefinitions = new Map(clean.map((tool) => [tool.name, tool]));
|
|
250
|
+
return clean.map((t) => ({
|
|
251
|
+
name: t.name,
|
|
252
|
+
...(t.title !== undefined ? { title: t.title } : {}),
|
|
253
|
+
...(t.icons !== undefined
|
|
254
|
+
? {
|
|
255
|
+
icons: t.icons.filter((icon) => !/^data:/i.test(icon.src)),
|
|
256
|
+
}
|
|
257
|
+
: {}),
|
|
258
|
+
...(t.execution !== undefined ? { execution: t.execution } : {}),
|
|
259
|
+
...(t.description !== undefined ? { description: t.description } : {}),
|
|
260
|
+
...(t.inputSchema !== undefined
|
|
261
|
+
? {
|
|
262
|
+
inputSchema: t.inputSchema,
|
|
263
|
+
}
|
|
264
|
+
: {}),
|
|
265
|
+
...(t.outputSchema !== undefined
|
|
266
|
+
? {
|
|
267
|
+
outputSchema: t.outputSchema,
|
|
268
|
+
}
|
|
269
|
+
: {}),
|
|
270
|
+
...(t.annotations !== undefined
|
|
271
|
+
? {
|
|
272
|
+
annotations: t.annotations,
|
|
273
|
+
}
|
|
274
|
+
: {}),
|
|
275
|
+
}));
|
|
276
|
+
}));
|
|
277
|
+
return { installCatalogIntake, listingContexts, liveListings, listToolsOperation };
|
|
278
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// remoteMcp() options and validation: the closed option set, resource and
|
|
2
|
+
// credential header rules, and every construction-time refusal, in order.
|
|
3
|
+
import { authorizationParamsFor } from "../../auth/oauth-authorization-params.js";
|
|
4
|
+
import { assertRemoteOAuthClient } from "../../auth/downstream-client-metadata.js";
|
|
5
|
+
import { assertOAuthScope } from "../../auth/downstream-oauth.js";
|
|
6
|
+
import { shortSecretWarning } from "../../sent-secrets.js";
|
|
7
|
+
import { assertKnownOptions, keys, optionsOf, strings } from "../../config-schema.js";
|
|
8
|
+
import { READ_ONLY, CALL_ADMISSION, REMOTE_MCP_AUTH, USAGE_GUIDE } from "../option-shapes.js";
|
|
9
|
+
/** The closed options remoteMcp() accepts; see `assertKnownOptions`. */
|
|
10
|
+
const REMOTE_MCP_OPTIONS = optionsOf()({
|
|
11
|
+
...keys("url", "title", "description", "authScope", "maxResultBytes", "versionNegotiation", "redirects", "requireHttps", "logger", "fetch", "_transportFactory", "skills"),
|
|
12
|
+
readOnly: READ_ONLY,
|
|
13
|
+
callAdmission: CALL_ADMISSION,
|
|
14
|
+
usageGuide: USAGE_GUIDE,
|
|
15
|
+
auth: REMOTE_MCP_AUTH,
|
|
16
|
+
headers: strings(),
|
|
17
|
+
});
|
|
18
|
+
/** Extra headers are immutable code configuration, not another auth channel. */
|
|
19
|
+
function extraResourceHeaders(id, opts) {
|
|
20
|
+
if (opts.headers === undefined)
|
|
21
|
+
return undefined;
|
|
22
|
+
const refuse = () => new Error(`remoteMcp(${JSON.stringify(id)}).headers requires non-secret resource headers without reserved or auth-owned names; use auth for credentials.`);
|
|
23
|
+
if (opts.auth?.type === "headers")
|
|
24
|
+
throw refuse();
|
|
25
|
+
const authNames = new Set([
|
|
26
|
+
"authorization",
|
|
27
|
+
...(opts.auth?.type === "credential" ? [opts.auth.header?.trim().toLowerCase() || "authorization"] : []),
|
|
28
|
+
...(opts.auth?.type === "request" ? Object.keys(opts.auth.headers ?? {}).map((name) => name.toLowerCase()) : []),
|
|
29
|
+
]);
|
|
30
|
+
const normalized = Object.create(null);
|
|
31
|
+
for (const [name, value] of Object.entries(opts.headers)) {
|
|
32
|
+
const lower = name.toLowerCase();
|
|
33
|
+
if (!HEADER_NAME_TOKEN.test(name) ||
|
|
34
|
+
typeof value !== "string" ||
|
|
35
|
+
/[\r\n]/.test(value) ||
|
|
36
|
+
Object.hasOwn(normalized, lower) ||
|
|
37
|
+
authNames.has(lower) ||
|
|
38
|
+
/^(?:accept|cookie|host|origin|connection|keep-alive|proxy-authenticate|proxy-authorization|te|trailer|transfer-encoding|upgrade|last-event-id|dpop|www-authenticate)$/.test(lower) ||
|
|
39
|
+
/^(?:content-|mcp-)/.test(lower) ||
|
|
40
|
+
/(?:api[-_]?key|token|secret|password|credential|authorization|signature|session)/.test(lower))
|
|
41
|
+
throw refuse();
|
|
42
|
+
try {
|
|
43
|
+
normalized[lower] = new Headers({ [lower]: value }).get(lower);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
throw refuse();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return Object.freeze(Object.fromEntries(Object.entries(normalized).sort(([a], [b]) => a.localeCompare(b))));
|
|
50
|
+
}
|
|
51
|
+
const encoder = new TextEncoder();
|
|
52
|
+
/** Base64 of a UTF-8 string, Web-API only so the core still runs on workerd. */
|
|
53
|
+
function base64Utf8(value) {
|
|
54
|
+
let binary = "";
|
|
55
|
+
for (const byte of encoder.encode(value)) {
|
|
56
|
+
binary += String.fromCharCode(byte);
|
|
57
|
+
}
|
|
58
|
+
return btoa(binary);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Assemble the one header a credential-auth connector sends.
|
|
62
|
+
*
|
|
63
|
+
* A scheme ending in `Basic` names HTTP Basic credentials wherever a provider
|
|
64
|
+
* nests it, so the `user:secret` it frames is base64-encoded — that is what
|
|
65
|
+
* makes plain `Basic` and nested `Bearer Basic` one rule instead of two.
|
|
66
|
+
*/
|
|
67
|
+
export function credentialHeaderValue(scheme, value) {
|
|
68
|
+
if (scheme === null)
|
|
69
|
+
return value;
|
|
70
|
+
return /(?:^|\s)basic$/i.test(scheme) ? `${scheme} ${base64Utf8(value)}` : `${scheme} ${value}`;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* True when a stored credential carries a character a header cannot.
|
|
74
|
+
*
|
|
75
|
+
* The fetch specification refuses NUL, CR, and LF outright, and the runtime
|
|
76
|
+
* that refuses them says so in a `TypeError` that quotes the whole offending
|
|
77
|
+
* value — a message that would otherwise travel to the agent. The remaining C0
|
|
78
|
+
* controls and DEL are refused here too: no real API key contains one, and a
|
|
79
|
+
* paste that picked one up is a paste to redo rather than a request to send.
|
|
80
|
+
* Leading and trailing whitespace is already gone by the time this runs.
|
|
81
|
+
*
|
|
82
|
+
* A scan rather than a regular expression, because a character class over the
|
|
83
|
+
* control range is exactly what `no-control-regex` exists to flag, and the
|
|
84
|
+
* suppression would be less readable than the loop it suppressed.
|
|
85
|
+
*/
|
|
86
|
+
export function carriesIllegalHeaderChar(value) {
|
|
87
|
+
for (let index = 0; index < value.length; index++) {
|
|
88
|
+
const code = value.charCodeAt(index);
|
|
89
|
+
if (code <= 0x1f || code === 0x7f)
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
/** RFC 9110 field-name token, checked once at construction. */
|
|
95
|
+
const HEADER_NAME_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
96
|
+
function isLoopbackHost(hostname) {
|
|
97
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
|
|
98
|
+
}
|
|
99
|
+
/** Validate `opts` once, at construction, and derive what every seam reads. */
|
|
100
|
+
export function remoteMcpConfig(id, opts) {
|
|
101
|
+
opts = assertKnownOptions(opts, `remoteMcp(${JSON.stringify(id)})`, REMOTE_MCP_OPTIONS);
|
|
102
|
+
if (opts.readOnly !== undefined)
|
|
103
|
+
READ_ONLY.parse(opts.readOnly, `Connector(${JSON.stringify(id)}).readOnly`);
|
|
104
|
+
if (opts.callAdmission !== undefined)
|
|
105
|
+
CALL_ADMISSION.parse(opts.callAdmission, `remoteMcp(${JSON.stringify(id)}).callAdmission`);
|
|
106
|
+
const extraHeaders = extraResourceHeaders(id, opts);
|
|
107
|
+
const skillsEnabled = opts.skills;
|
|
108
|
+
if (skillsEnabled !== undefined && typeof skillsEnabled !== "boolean") {
|
|
109
|
+
throw new Error(`[connecta] connector "${id}" skills must be a boolean.`);
|
|
110
|
+
}
|
|
111
|
+
const clientMetadataUrl = opts.auth?.type === "oauth" ? opts.auth.clientMetadataUrl : undefined;
|
|
112
|
+
const oauthScope = opts.auth?.type === "oauth" ? opts.auth.scope : undefined;
|
|
113
|
+
assertOAuthScope(id, oauthScope);
|
|
114
|
+
const authorizationParams = authorizationParamsFor(opts.auth?.type === "oauth" ? opts.auth.authorizationParams : undefined, `remoteMcp(${JSON.stringify(id)}).auth.authorizationParams`);
|
|
115
|
+
const staticClient = opts.auth?.type === "oauth" && opts.auth.client ? { ...opts.auth.client } : undefined;
|
|
116
|
+
assertRemoteOAuthClient(id, staticClient);
|
|
117
|
+
if (staticClient && clientMetadataUrl !== undefined)
|
|
118
|
+
throw new Error(`[connecta] connector "${id}" OAuth client and clientMetadataUrl are mutually exclusive.`);
|
|
119
|
+
if (clientMetadataUrl !== undefined) {
|
|
120
|
+
let valid = false;
|
|
121
|
+
try {
|
|
122
|
+
const url = new URL(clientMetadataUrl);
|
|
123
|
+
valid = url.protocol === "https:" && url.pathname !== "/" && !url.username && !url.password && !url.hash;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
/* Invalid configuration is rejected below without echoing its value. */
|
|
127
|
+
}
|
|
128
|
+
if (!valid)
|
|
129
|
+
throw new Error(`[connecta] connector "${id}" clientMetadataUrl must be an HTTPS URL with a non-root path, without credentials or a fragment.`);
|
|
130
|
+
}
|
|
131
|
+
if (opts.authScope === "personal" && opts.auth?.type === "headers") {
|
|
132
|
+
throw new Error(`[connecta] connector "${id}" cannot combine authScope "personal" ` +
|
|
133
|
+
"with static headers. Use credential or OAuth auth so each principal " +
|
|
134
|
+
"can own a different grant.");
|
|
135
|
+
}
|
|
136
|
+
const isOauth = opts.auth?.type === "oauth";
|
|
137
|
+
const logger = opts.logger ?? console;
|
|
138
|
+
const warnShortSecret = shortSecretWarning();
|
|
139
|
+
warnShortSecret(staticClient?.clientSecret, logger);
|
|
140
|
+
if (opts.auth?.type === "headers") {
|
|
141
|
+
for (const [name, value] of Object.entries(opts.auth.headers)) {
|
|
142
|
+
if (!/key|token|secret|password|auth|signature|session/i.test(name))
|
|
143
|
+
continue;
|
|
144
|
+
const basic = /^Basic\s+(.+)$/i.exec(value);
|
|
145
|
+
if (basic) {
|
|
146
|
+
try {
|
|
147
|
+
const decoded = atob(basic[1]);
|
|
148
|
+
const colon = decoded.indexOf(":");
|
|
149
|
+
if (colon !== -1)
|
|
150
|
+
warnShortSecret(decoded.slice(colon + 1), logger);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
/* Invalid Basic configuration is not a short password. */
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
else
|
|
157
|
+
warnShortSecret(value.replace(/^(?:Bearer|token)\s+/i, ""), logger);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const credentialAuth = opts.auth?.type === "credential" ? opts.auth : undefined;
|
|
161
|
+
if (credentialAuth?.credential?.fields?.length) {
|
|
162
|
+
throw new Error(`[connecta] connector "${id}" credential auth declares named fields; ` +
|
|
163
|
+
"this shape sends one secret in one header and reads the reserved " +
|
|
164
|
+
"`value` field only.");
|
|
165
|
+
}
|
|
166
|
+
// Both request shapes, read field by field; a mixture is refused below.
|
|
167
|
+
const requestAuth = opts.auth?.type === "request" ? opts.auth : undefined;
|
|
168
|
+
// Opt-in only: without it the resolver keeps receiving the context alone.
|
|
169
|
+
// `principal` replaces `token`; a malformed or mixed shape has no resolver.
|
|
170
|
+
const forwarding = requestAuth?.principal;
|
|
171
|
+
const requestToken = forwarding
|
|
172
|
+
? requestAuth?.token === undefined && (forwarding.accept === "human" || forwarding.accept === "human-or-agent")
|
|
173
|
+
? forwarding.token
|
|
174
|
+
: undefined
|
|
175
|
+
: requestAuth?.token;
|
|
176
|
+
if (requestAuth && (typeof requestToken !== "function" || opts.authScope === "personal")) {
|
|
177
|
+
throw new Error(`remoteMcp(${JSON.stringify(id)}) requires a request token callback and shared authScope.`);
|
|
178
|
+
}
|
|
179
|
+
const principalPolicy = forwarding?.accept;
|
|
180
|
+
const customFetch = opts.fetch;
|
|
181
|
+
// OAuth discovery, registration and token requests reach authorization
|
|
182
|
+
// servers the downstream names, through the owned credential sender. A
|
|
183
|
+
// custom fetch would split that traffic between two transports.
|
|
184
|
+
if (customFetch !== undefined && (typeof customFetch !== "function" || opts.auth?.type === "oauth")) {
|
|
185
|
+
throw new Error(`remoteMcp(${JSON.stringify(id)}).fetch must be a function, without OAuth auth.`);
|
|
186
|
+
}
|
|
187
|
+
// No delayed dispatch: a write counts as sent once it is handed to the function.
|
|
188
|
+
const customSender = customFetch && { send: customFetch };
|
|
189
|
+
if (requestAuth?.headers) {
|
|
190
|
+
try {
|
|
191
|
+
if (Object.keys(requestAuth.headers).some((name) => name.toLowerCase() === "authorization"))
|
|
192
|
+
throw new Error();
|
|
193
|
+
new Headers(requestAuth.headers);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
throw new Error(`remoteMcp(${JSON.stringify(id)}) requires valid request auth headers without Authorization.`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const credentialConfig = credentialAuth?.credential ?? {
|
|
200
|
+
label: "API key",
|
|
201
|
+
};
|
|
202
|
+
const credentialHeader = credentialAuth?.header?.trim() || "Authorization";
|
|
203
|
+
// A structural mistake in the deployment file, beside the `fields` refusal
|
|
204
|
+
// above: an unsendable header name is not worth discovering on the first
|
|
205
|
+
// request, where only a runtime error can report it.
|
|
206
|
+
if (credentialAuth && !HEADER_NAME_TOKEN.test(credentialHeader)) {
|
|
207
|
+
throw new Error(`[connecta] connector "${id}" credential auth declares header ` +
|
|
208
|
+
`"${credentialHeader}", which is not a valid HTTP field name.`);
|
|
209
|
+
}
|
|
210
|
+
// `undefined` means "not stated" and takes the bearer default; `null` and
|
|
211
|
+
// `""` both mean "send the stored value verbatim", which some providers
|
|
212
|
+
// require for a bare API key.
|
|
213
|
+
const credentialScheme = credentialAuth === undefined || credentialAuth.scheme === undefined
|
|
214
|
+
? "Bearer"
|
|
215
|
+
: (credentialAuth.scheme?.trim() ?? "") || null;
|
|
216
|
+
// Check the destination scheme once at construction: buildTransport (and the
|
|
217
|
+
// SDK's fetch) attach any static credentials to every request, so an http://
|
|
218
|
+
// endpoint sends bearer tokens / API keys in cleartext. Loopback is exempt
|
|
219
|
+
// for local development.
|
|
220
|
+
const destination = new URL(opts.url);
|
|
221
|
+
// Only a web URL reaches a remote MCP server. Another scheme cannot, and a
|
|
222
|
+
// wrapping one (`blob:https://…`) would also carry its inner URL, userinfo
|
|
223
|
+
// and all, into every place the endpoint is reported; the value is not echoed.
|
|
224
|
+
if (destination.protocol !== "https:" && destination.protocol !== "http:") {
|
|
225
|
+
throw new Error(`[connecta] connector "${id}" url must be an http(s) URL.`);
|
|
226
|
+
}
|
|
227
|
+
const insecureDestination = destination.protocol !== "https:" && !isLoopbackHost(destination.hostname);
|
|
228
|
+
if (insecureDestination) {
|
|
229
|
+
if (opts.requireHttps || requestAuth) {
|
|
230
|
+
throw new Error(`[connecta] connector "${id}" url ${opts.url} is not https:// (and not loopback) — refusing to connect (requireHttps).`);
|
|
231
|
+
}
|
|
232
|
+
// Both static shapes send a secret on every request; that an operator
|
|
233
|
+
// typed one into the operator UI rather than a deployment file changes
|
|
234
|
+
// who owns it, not whether the wire carries it in the clear.
|
|
235
|
+
if (opts.auth?.type === "headers" || credentialAuth) {
|
|
236
|
+
logger.warn(`[connecta] connector "${id}" sends static credentials to ${opts.url} over a non-https:// connection — those tokens will be transmitted in cleartext.`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const endpointOrigin = new URL(opts.url).origin;
|
|
240
|
+
return {
|
|
241
|
+
id,
|
|
242
|
+
opts,
|
|
243
|
+
requestAuth,
|
|
244
|
+
forwarding,
|
|
245
|
+
staticClient,
|
|
246
|
+
clientMetadataUrl,
|
|
247
|
+
oauthScope,
|
|
248
|
+
authorizationParams,
|
|
249
|
+
destination,
|
|
250
|
+
isOauth,
|
|
251
|
+
extraHeaders,
|
|
252
|
+
credentialHeader,
|
|
253
|
+
credentialScheme,
|
|
254
|
+
skillsEnabled,
|
|
255
|
+
principalPolicy,
|
|
256
|
+
credentialAuth,
|
|
257
|
+
customFetch,
|
|
258
|
+
customSender,
|
|
259
|
+
requestToken,
|
|
260
|
+
endpointOrigin,
|
|
261
|
+
credentialConfig,
|
|
262
|
+
};
|
|
263
|
+
}
|