@zackbart/connecta 0.7.9 → 0.8.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 +48 -0
- package/README.md +67 -144
- package/dist/auth/bearer.d.ts +1 -1
- package/dist/auth/bearer.js +4 -4
- package/dist/auth/bearer.js.map +1 -1
- package/dist/auth/clerk.d.ts +1 -1
- package/dist/auth/clerk.js +1 -1
- package/dist/call-admission.d.ts.map +1 -1
- package/dist/call-admission.js +9 -3
- package/dist/call-admission.js.map +1 -1
- package/dist/catalog-fingerprint.d.ts +20 -0
- package/dist/catalog-fingerprint.d.ts.map +1 -0
- package/dist/catalog-fingerprint.js +34 -0
- package/dist/catalog-fingerprint.js.map +1 -0
- package/dist/catalog-limits.d.ts +10 -0
- package/dist/catalog-limits.d.ts.map +1 -0
- package/dist/catalog-limits.js +10 -0
- package/dist/catalog-limits.js.map +1 -0
- package/dist/catalog-service.d.ts +138 -0
- package/dist/catalog-service.d.ts.map +1 -0
- package/dist/catalog-service.js +407 -0
- package/dist/catalog-service.js.map +1 -0
- package/dist/catalog.js +2 -2
- package/dist/catalog.js.map +1 -1
- package/dist/concurrency.d.ts +4 -0
- package/dist/concurrency.d.ts.map +1 -0
- package/dist/concurrency.js +31 -0
- package/dist/concurrency.js.map +1 -0
- package/dist/connectors/api.d.ts.map +1 -1
- package/dist/connectors/api.js +22 -12
- package/dist/connectors/api.js.map +1 -1
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +44 -19
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/credential-health.d.ts +0 -22
- package/dist/credential-health.d.ts.map +1 -1
- package/dist/credential-health.js +14 -10
- package/dist/credential-health.js.map +1 -1
- package/dist/credentials.d.ts +3 -2
- package/dist/credentials.d.ts.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/execute.d.ts +9 -8
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +144 -321
- package/dist/execute.js.map +1 -1
- package/dist/executor-result.d.ts +0 -3
- package/dist/executor-result.d.ts.map +1 -1
- package/dist/executor-result.js +2 -2
- package/dist/executor-result.js.map +1 -1
- package/dist/executors/quickjs-child.js +3 -3
- package/dist/executors/quickjs-child.js.map +1 -1
- package/dist/executors/quickjs-protocol.d.ts +4 -1
- package/dist/executors/quickjs-protocol.d.ts.map +1 -1
- package/dist/executors/quickjs-runtime.d.ts.map +1 -1
- package/dist/executors/quickjs-runtime.js +7 -2
- package/dist/executors/quickjs-runtime.js.map +1 -1
- package/dist/executors/quickjs.d.ts.map +1 -1
- package/dist/executors/quickjs.js +4 -1
- package/dist/executors/quickjs.js.map +1 -1
- package/dist/favicon.js +2 -2
- package/dist/favicon.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +44 -17
- package/dist/index.js.map +1 -1
- package/dist/invocation.d.ts +96 -0
- package/dist/invocation.d.ts.map +1 -0
- package/dist/invocation.js +369 -0
- package/dist/invocation.js.map +1 -0
- package/dist/meta-tools.d.ts +8 -86
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +145 -583
- package/dist/meta-tools.js.map +1 -1
- package/dist/operator-ui/generated.d.ts +3 -0
- package/dist/operator-ui/generated.d.ts.map +1 -0
- package/dist/operator-ui/generated.js +5 -0
- package/dist/operator-ui/generated.js.map +1 -0
- package/dist/operator-ui/model.d.ts +94 -0
- package/dist/operator-ui/model.d.ts.map +1 -0
- package/dist/operator-ui/model.js +28 -0
- package/dist/operator-ui/model.js.map +1 -0
- package/dist/registry.d.ts +22 -6
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +328 -35
- package/dist/registry.js.map +1 -1
- package/dist/routes/activity.d.ts +3 -0
- package/dist/routes/activity.d.ts.map +1 -0
- package/dist/routes/activity.js +173 -0
- package/dist/routes/activity.js.map +1 -0
- package/dist/routes/credentials.d.ts +3 -0
- package/dist/routes/credentials.d.ts.map +1 -0
- package/dist/routes/credentials.js +181 -0
- package/dist/routes/credentials.js.map +1 -0
- package/dist/routes/mcp.d.ts +8 -0
- package/dist/routes/mcp.d.ts.map +1 -0
- package/dist/routes/mcp.js +383 -0
- package/dist/routes/mcp.js.map +1 -0
- package/dist/routes/oauth.d.ts +4 -0
- package/dist/routes/oauth.d.ts.map +1 -0
- package/dist/routes/oauth.js +268 -0
- package/dist/routes/oauth.js.map +1 -0
- package/dist/routes/shared.d.ts +97 -0
- package/dist/routes/shared.d.ts.map +1 -0
- package/dist/routes/shared.js +199 -0
- package/dist/routes/shared.js.map +1 -0
- package/dist/routes/ui.d.ts +3 -0
- package/dist/routes/ui.d.ts.map +1 -0
- package/dist/routes/ui.js +122 -0
- package/dist/routes/ui.js.map +1 -0
- package/dist/server.d.ts +9 -40
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +63 -1293
- package/dist/server.js.map +1 -1
- package/dist/skills.d.ts +0 -15
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +6 -5
- package/dist/skills.js.map +1 -1
- package/dist/storage/file.d.ts.map +1 -1
- package/dist/storage/file.js +3 -1
- package/dist/storage/file.js.map +1 -1
- package/dist/storage/memory.d.ts.map +1 -1
- package/dist/storage/memory.js +3 -1
- package/dist/storage/memory.js.map +1 -1
- package/dist/toolkits.d.ts +0 -22
- package/dist/toolkits.d.ts.map +1 -1
- package/dist/toolkits.js +2 -2
- package/dist/toolkits.js.map +1 -1
- package/dist/types.d.ts +9 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +3 -93
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +26 -1393
- package/dist/ui.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +15 -8
- package/src/auth/bearer.ts +4 -4
- package/src/auth/clerk.ts +1 -1
- package/src/call-admission.ts +8 -5
- package/src/catalog-fingerprint.ts +52 -0
- package/src/catalog-limits.ts +11 -0
- package/src/catalog-service.ts +601 -0
- package/src/catalog.ts +2 -2
- package/src/concurrency.ts +39 -0
- package/src/connectors/api.ts +22 -12
- package/src/connectors/remote-mcp.ts +52 -22
- package/src/credential-health.ts +13 -10
- package/src/credentials.ts +2 -2
- package/src/errors.ts +1 -1
- package/src/execute.ts +194 -409
- package/src/executor-result.ts +2 -2
- package/src/executors/quickjs-child.ts +8 -4
- package/src/executors/quickjs-protocol.ts +1 -1
- package/src/executors/quickjs-runtime.ts +5 -1
- package/src/executors/quickjs.ts +11 -8
- package/src/favicon.ts +2 -2
- package/src/index.ts +49 -17
- package/src/invocation.ts +527 -0
- package/src/meta-tools.ts +366 -906
- package/src/operator-ui/browser.css +413 -0
- package/src/operator-ui/browser.ts +1121 -0
- package/src/operator-ui/generated.ts +4 -0
- package/src/operator-ui/model.ts +127 -0
- package/src/registry.ts +434 -44
- package/src/routes/activity.ts +220 -0
- package/src/routes/credentials.ts +266 -0
- package/src/routes/mcp.ts +485 -0
- package/src/routes/oauth.ts +328 -0
- package/src/routes/shared.ts +330 -0
- package/src/routes/ui.ts +153 -0
- package/src/server.ts +66 -1703
- package/src/skills.ts +6 -5
- package/src/storage/file.ts +3 -1
- package/src/storage/memory.ts +3 -1
- package/src/toolkits.ts +2 -2
- package/src/types.ts +9 -2
- package/src/ui.ts +52 -1493
- package/src/version.ts +1 -1
package/src/server.ts
CHANGED
|
@@ -1,1443 +1,37 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { CONNECTA_INSTRUCTIONS } from "./skills.js";
|
|
6
|
-
import type {
|
|
7
|
-
ActivityActor,
|
|
8
|
-
ActivityPage,
|
|
9
|
-
ActivityReadGate,
|
|
10
|
-
ActivityReadPage,
|
|
11
|
-
ActivityRequestContext,
|
|
12
|
-
ActivityStore,
|
|
13
|
-
} from "./activity.js";
|
|
14
|
-
import { InvalidActivityCursorError } from "./activity.js";
|
|
1
|
+
import { isAdmittingExecutor } from "./executor-admission.js";
|
|
2
|
+
import { routeActivity } from "./routes/activity.js";
|
|
3
|
+
import { routeCredentials } from "./routes/credentials.js";
|
|
4
|
+
import { createMcpRoute, MCP_CORS_HEADERS } from "./routes/mcp.js";
|
|
15
5
|
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
} from "./credentials.js";
|
|
20
|
-
import type { CredentialVault } from "./credentials.js";
|
|
21
|
-
import { ScopedRegistry, type Registry, type RegistryView } from "./registry.js";
|
|
6
|
+
routeOAuthCallback,
|
|
7
|
+
routeOAuthManagement,
|
|
8
|
+
} from "./routes/oauth.js";
|
|
22
9
|
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
type
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
ConnectorCredentialConfig,
|
|
30
|
-
ConnectorCredentialValues,
|
|
31
|
-
ConnectorStatus,
|
|
32
|
-
ConnectaBranding,
|
|
33
|
-
Executor,
|
|
34
|
-
InboundAuth,
|
|
35
|
-
Logger,
|
|
36
|
-
ToolkitBinding,
|
|
37
|
-
} from "./types.js";
|
|
38
|
-
import { CONNECTA_FAVICON_ICO } from "./favicon.js";
|
|
39
|
-
import {
|
|
40
|
-
ExecutorAdmissionError,
|
|
41
|
-
isAdmittingExecutor,
|
|
42
|
-
type AdmissionController,
|
|
43
|
-
type AdmissionLease,
|
|
44
|
-
} from "./executor-admission.js";
|
|
45
|
-
import {
|
|
46
|
-
buildUiData,
|
|
47
|
-
CONNECTA_FAVICON_SVG,
|
|
48
|
-
credentialManagementCapability,
|
|
49
|
-
isSafeHttpUrl,
|
|
50
|
-
operatorPageForPath,
|
|
51
|
-
resolveBranding,
|
|
52
|
-
renderUiHtml,
|
|
53
|
-
} from "./ui.js";
|
|
54
|
-
import { oauthValueStorageKey } from "./auth/downstream-oauth.js";
|
|
55
|
-
import {
|
|
56
|
-
closeConnectorScope,
|
|
57
|
-
type DeferredWork,
|
|
58
|
-
} from "./connector-scope.js";
|
|
10
|
+
withSecurityHeaders,
|
|
11
|
+
type RouteContext,
|
|
12
|
+
type RuntimeExecutionContext,
|
|
13
|
+
type ServerOptions,
|
|
14
|
+
} from "./routes/shared.js";
|
|
15
|
+
import { routeUi } from "./routes/ui.js";
|
|
59
16
|
|
|
60
|
-
|
|
61
|
-
"Access-Control-Allow-Origin": "*",
|
|
62
|
-
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
|
63
|
-
"Access-Control-Allow-Headers":
|
|
64
|
-
"Content-Type, Authorization, mcp-protocol-version, mcp-session-id",
|
|
65
|
-
};
|
|
17
|
+
export type { ServerOptions } from "./routes/shared.js";
|
|
66
18
|
|
|
67
19
|
/**
|
|
68
|
-
*
|
|
69
|
-
* The SVG route is the sharp one: `image/svg+xml` is an *active* content type,
|
|
70
|
-
* so a `<script>` inside a branding SVG would run on the deployment origin the
|
|
71
|
-
* moment anyone navigated straight to `/favicon.svg` — strictly more powerful
|
|
72
|
-
* than the `favicon.href` vector the branding gates close, because the payload
|
|
73
|
-
* is same-origin. Neutralizing the response rather than inspecting the body
|
|
74
|
-
* keeps every valid static SVG (the built-in mark included) byte-identical:
|
|
20
|
+
* Build the Web-standard fetch handler.
|
|
75
21
|
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* - `default-src 'none'` denies script, network, and framing outright.
|
|
80
|
-
* - `style-src 'unsafe-inline'` is the single allowance: the default mark styles
|
|
81
|
-
* itself inline to follow the OS colour scheme, and CSS cannot script.
|
|
82
|
-
* - `nosniff` keeps the declared type authoritative in both directions — an SVG
|
|
83
|
-
* can never be re-read as HTML, and `.ico` bytes can never be re-read as SVG.
|
|
84
|
-
*
|
|
85
|
-
* `.ico` bodies are deliberately in scope: they are inert bytes rather than
|
|
86
|
-
* active content, so they are still served verbatim, but they carry the same
|
|
87
|
-
* headers so the invariant is "every favicon route is neutralized" rather than
|
|
88
|
-
* "whichever route got attention".
|
|
89
|
-
*/
|
|
90
|
-
const INERT_ICON_HEADERS = {
|
|
91
|
-
"Content-Security-Policy":
|
|
92
|
-
"default-src 'none'; style-src 'unsafe-inline'; sandbox",
|
|
93
|
-
"X-Content-Type-Options": "nosniff",
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
export interface ServerOptions {
|
|
97
|
-
registry: Registry;
|
|
98
|
-
auth: InboundAuth[];
|
|
99
|
-
publicUrl?: string;
|
|
100
|
-
// The SDK's Implementation shape: name/version plus optional title,
|
|
101
|
-
// websiteUrl, and icons (MCP icons spec) that clients may render.
|
|
102
|
-
serverInfo: ConstructorParameters<typeof McpServer>[0];
|
|
103
|
-
logger: Logger;
|
|
104
|
-
activity?: ActivityStore;
|
|
105
|
-
activityReadGate?: ActivityReadGate;
|
|
106
|
-
activityDeploymentId?: string;
|
|
107
|
-
deploymentInfo?: Record<string, unknown>;
|
|
108
|
-
/** Deadline for call_tool/batch_call calls that pass no timeoutMs. Off when unset. */
|
|
109
|
-
defaultToolTimeoutMs?: number;
|
|
110
|
-
/** Per-connector deadline for the list/search/describe probe fan-out. Default 30_000. */
|
|
111
|
-
probeTimeoutMs?: number;
|
|
112
|
-
/** When set, the execute_code meta-tool is registered on top of the nine. */
|
|
113
|
-
executor?: Executor;
|
|
114
|
-
/** Global FIFO boundary for all non-preflight `/mcp` requests. */
|
|
115
|
-
requestAdmission: AdmissionController;
|
|
116
|
-
/** Encrypted connector-credential storage backing the Credentials page. */
|
|
117
|
-
credentialVault?: CredentialVault;
|
|
118
|
-
/** Optional browser UI and OAuth result-page labels. */
|
|
119
|
-
branding?: ConnectaBranding;
|
|
120
|
-
/**
|
|
121
|
-
* Validated named scopes, selected per connection with `?toolkit=<name>` on
|
|
122
|
-
* `/mcp`. Omit (or leave empty) and every connection sees the full registry.
|
|
123
|
-
*/
|
|
124
|
-
toolkits?: ReadonlyMap<string, Toolkit>;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function msg(err: unknown): string {
|
|
128
|
-
return err instanceof Error ? err.message : String(err);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** Per-request base64 nonce for an operator shell's scripts (Node 20+ and Workers). */
|
|
132
|
-
function uiScriptNonce(): string {
|
|
133
|
-
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
134
|
-
let binary = "";
|
|
135
|
-
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
136
|
-
return btoa(binary);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function escapeHtml(s: string): string {
|
|
140
|
-
return s
|
|
141
|
-
.replaceAll("&", "&")
|
|
142
|
-
.replaceAll("<", "<")
|
|
143
|
-
.replaceAll(">", ">")
|
|
144
|
-
.replaceAll('"', """);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** `body` is escaped — callback params and error messages are attacker-influenced. */
|
|
148
|
-
function html(
|
|
149
|
-
body: string,
|
|
150
|
-
status = 200,
|
|
151
|
-
branding?: ConnectaBranding,
|
|
152
|
-
): Response {
|
|
153
|
-
const brand = resolveBranding(branding);
|
|
154
|
-
const title = brand.pageTitle;
|
|
155
|
-
const owner = brand.ownerName
|
|
156
|
-
? brand.ownerUrl
|
|
157
|
-
? `<a class="brand" href="${escapeHtml(brand.ownerUrl)}">${escapeHtml(brand.ownerName)}</a>`
|
|
158
|
-
: `<span class="brand">${escapeHtml(brand.ownerName)}</span>`
|
|
159
|
-
: brand.productUrl
|
|
160
|
-
? `<a class="brand" href="${escapeHtml(brand.productUrl)}">${escapeHtml(brand.productName)}</a>`
|
|
161
|
-
: `<span class="brand">${escapeHtml(brand.productName)}</span>`;
|
|
162
|
-
const product = brand.ownerName
|
|
163
|
-
? brand.productUrl
|
|
164
|
-
? `<a class="product" href="${escapeHtml(brand.productUrl)}">${escapeHtml(brand.productName)}</a>`
|
|
165
|
-
: `<span class="product">${escapeHtml(brand.productName)}</span>`
|
|
166
|
-
: "";
|
|
167
|
-
return new Response(
|
|
168
|
-
`<!doctype html>
|
|
169
|
-
<html lang="en">
|
|
170
|
-
<head>
|
|
171
|
-
<meta charset="utf-8">
|
|
172
|
-
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
173
|
-
<meta name="theme-color" content="${escapeHtml(brand.themeColor)}">
|
|
174
|
-
<link rel="icon" href="${escapeHtml(brand.faviconHref)}" type="image/svg+xml">
|
|
175
|
-
<link rel="shortcut icon" href="/favicon.ico">
|
|
176
|
-
<title>${escapeHtml(title)}</title>
|
|
177
|
-
<style>
|
|
178
|
-
* { border-radius: 0; box-sizing: border-box; }
|
|
179
|
-
html { color: #000; background: #fff; font: 16px/1.5 "Helvetica Neue",
|
|
180
|
-
Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; }
|
|
181
|
-
body { margin: 0; min-height: 100vh; }
|
|
182
|
-
::selection { color: #fff; background: #000; }
|
|
183
|
-
.shell { margin: 0 auto; max-width: 70rem; padding: 1rem; }
|
|
184
|
-
.grid { display: grid; gap: 1rem 1.5rem;
|
|
185
|
-
grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
186
|
-
.brand { font-weight: 500; grid-column: 1; text-decoration: none; }
|
|
187
|
-
.product { grid-column: 2 / -1; }
|
|
188
|
-
main { margin-top: 5rem; }
|
|
189
|
-
h1, p { font: inherit; margin: 0; }
|
|
190
|
-
h1 { grid-column: 1; }
|
|
191
|
-
.copy { grid-column: 2 / -1; max-width: 34em; }
|
|
192
|
-
.copy > * + * { margin-top: 1.5rem; }
|
|
193
|
-
a { color: inherit; text-decoration: underline; text-decoration-thickness: 1.5px;
|
|
194
|
-
text-underline-offset: .22em; }
|
|
195
|
-
a:hover { text-decoration-color: transparent; }
|
|
196
|
-
a:focus-visible { outline: 1px solid #000; outline-offset: 2px; }
|
|
197
|
-
@media (max-width: 36.99rem) {
|
|
198
|
-
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
199
|
-
.product { grid-column: 2; }
|
|
200
|
-
main { margin-top: 3rem; }
|
|
201
|
-
h1, .copy { grid-column: 1 / -1; }
|
|
202
|
-
}
|
|
203
|
-
</style>
|
|
204
|
-
</head>
|
|
205
|
-
<body>
|
|
206
|
-
<header class="shell grid">
|
|
207
|
-
${owner}
|
|
208
|
-
${product}
|
|
209
|
-
</header>
|
|
210
|
-
<main class="shell grid">
|
|
211
|
-
<h1>Connection status</h1>
|
|
212
|
-
<div class="copy">
|
|
213
|
-
<p>${escapeHtml(body)}</p>
|
|
214
|
-
<p><a href="/">Return to ${escapeHtml(brand.productName)}</a></p>
|
|
215
|
-
</div>
|
|
216
|
-
</main>
|
|
217
|
-
</body>
|
|
218
|
-
</html>`,
|
|
219
|
-
{ status, headers: { "Content-Type": "text/html; charset=utf-8" } },
|
|
220
|
-
);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Refusal for an identity whose toolkit binding cannot be trusted — a malformed
|
|
225
|
-
* declaration, or a malformed per-identity binding out of `authorize`. The
|
|
226
|
-
* caller is authenticated, so this is a 403, and it is deliberately opaque: the
|
|
227
|
-
* cause is an operator bug, and the operator reads it in the log, not the client.
|
|
22
|
+
* Route ordering is the contract: private mutation routes precede wildcard
|
|
23
|
+
* OPTIONS, every built-in precedes connector-owned routes, and the security
|
|
24
|
+
* wrapper is applied to every response.
|
|
228
25
|
*/
|
|
229
|
-
function unusableBinding(): Response {
|
|
230
|
-
return privateJson({ error: "forbidden" }, { status: 403 });
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
async function authorize(
|
|
234
|
-
request: Request,
|
|
235
|
-
baseUrl: string,
|
|
236
|
-
auth: InboundAuth[],
|
|
237
|
-
logger: Logger,
|
|
238
|
-
): Promise<
|
|
239
|
-
| {
|
|
240
|
-
ok: true;
|
|
241
|
-
actor: ActivityActor;
|
|
242
|
-
/** True only when the admitting provider can also authorize UI mutation. */
|
|
243
|
-
uiAdminEligible?: boolean;
|
|
244
|
-
/** The admitting identity's toolkit binding (docs/toolkits.md). */
|
|
245
|
-
toolkitBinding?: ToolkitBinding;
|
|
246
|
-
}
|
|
247
|
-
| { ok: false; response: Response }
|
|
248
|
-
> {
|
|
249
|
-
if (auth.length === 0) {
|
|
250
|
-
return { ok: true, actor: { kind: "anonymous" } };
|
|
251
|
-
}
|
|
252
|
-
let lastResponse: Response | null = null;
|
|
253
|
-
for (const provider of auth) {
|
|
254
|
-
const result = await provider.authorize(request, baseUrl);
|
|
255
|
-
if (result.ok) {
|
|
256
|
-
const subjectId = result.subjectId ?? result.userId;
|
|
257
|
-
// Re-validate both halves and cap the per-identity one by the provider's
|
|
258
|
-
// declaration (see resolveIdentityBinding). A binding that does not
|
|
259
|
-
// type-check at runtime refuses the request rather than evaporating:
|
|
260
|
-
// dropping it would hand the caller the full registry, which is the one
|
|
261
|
-
// outcome a binding exists to prevent.
|
|
262
|
-
const binding = resolveIdentityBinding(
|
|
263
|
-
provider.toolkitBinding,
|
|
264
|
-
result.toolkitBinding,
|
|
265
|
-
);
|
|
266
|
-
if (!binding.ok) {
|
|
267
|
-
logger.warn(
|
|
268
|
-
`[connecta] refused a request admitted by inbound auth provider ` +
|
|
269
|
-
`"${provider.kind}" with 403: ${binding.reason}. Until it is fixed ` +
|
|
270
|
-
"this provider cannot admit anyone, because connecta cannot tell " +
|
|
271
|
-
"which toolkits the identity may use.",
|
|
272
|
-
);
|
|
273
|
-
return { ok: false, response: unusableBinding() };
|
|
274
|
-
}
|
|
275
|
-
const actorNamespace = activityActorNamespace(provider);
|
|
276
|
-
return {
|
|
277
|
-
ok: true,
|
|
278
|
-
actor: {
|
|
279
|
-
kind: provider.kind,
|
|
280
|
-
...(subjectId ? { id: subjectId } : {}),
|
|
281
|
-
...(subjectId && actorNamespace
|
|
282
|
-
? { namespace: actorNamespace }
|
|
283
|
-
: {}),
|
|
284
|
-
},
|
|
285
|
-
...(result.userId && provider.uiAuth?.kind === "clerk"
|
|
286
|
-
? { uiAdminEligible: true }
|
|
287
|
-
: {}),
|
|
288
|
-
...(binding.binding ? { toolkitBinding: binding.binding } : {}),
|
|
289
|
-
};
|
|
290
|
-
}
|
|
291
|
-
lastResponse = result.response;
|
|
292
|
-
}
|
|
293
|
-
return {
|
|
294
|
-
ok: false,
|
|
295
|
-
response:
|
|
296
|
-
lastResponse ??
|
|
297
|
-
new Response(JSON.stringify({ error: "unauthorized" }), {
|
|
298
|
-
status: 401,
|
|
299
|
-
headers: {
|
|
300
|
-
"Content-Type": "application/json",
|
|
301
|
-
"WWW-Authenticate": "Bearer",
|
|
302
|
-
},
|
|
303
|
-
}),
|
|
304
|
-
};
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
function privateJson(body: unknown, init: ResponseInit = {}): Response {
|
|
308
|
-
const headers = new Headers(init.headers);
|
|
309
|
-
headers.set("Content-Type", "application/json");
|
|
310
|
-
headers.set("Cache-Control", "no-store");
|
|
311
|
-
headers.set("Referrer-Policy", "no-referrer");
|
|
312
|
-
return new Response(JSON.stringify(body), { ...init, headers });
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// Browser-based MCP clients call /mcp cross-origin. Without CORS on every
|
|
316
|
-
// response — errors included — the browser hides the 401, the client cannot
|
|
317
|
-
// read WWW-Authenticate, and OAuth discovery silently never starts.
|
|
318
|
-
function withMcpCors(response: Response): Response {
|
|
319
|
-
const headers = new Headers(response.headers);
|
|
320
|
-
for (const [name, value] of Object.entries(CORS_HEADERS)) {
|
|
321
|
-
headers.set(name, value);
|
|
322
|
-
}
|
|
323
|
-
headers.set(
|
|
324
|
-
"Access-Control-Expose-Headers",
|
|
325
|
-
"WWW-Authenticate, Retry-After, mcp-session-id, mcp-protocol-version",
|
|
326
|
-
);
|
|
327
|
-
return new Response(response.body, {
|
|
328
|
-
status: response.status,
|
|
329
|
-
statusText: response.statusText,
|
|
330
|
-
headers,
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function requestAdmissionFailure(error: ExecutorAdmissionError): Response {
|
|
335
|
-
const overloaded = error.code === "executor_overloaded";
|
|
336
|
-
const data = {
|
|
337
|
-
code: overloaded ? "server_overloaded" : "server_shutting_down",
|
|
338
|
-
retryable: overloaded,
|
|
339
|
-
...(overloaded && error.retryAfterMs !== undefined
|
|
340
|
-
? { retryAfterMs: error.retryAfterMs }
|
|
341
|
-
: {}),
|
|
342
|
-
};
|
|
343
|
-
const headers = new Headers({
|
|
344
|
-
"Content-Type": "application/json",
|
|
345
|
-
"Cache-Control": "no-store",
|
|
346
|
-
});
|
|
347
|
-
if (overloaded && error.retryAfterMs !== undefined) {
|
|
348
|
-
headers.set(
|
|
349
|
-
"Retry-After",
|
|
350
|
-
String(Math.max(1, Math.ceil(error.retryAfterMs / 1_000))),
|
|
351
|
-
);
|
|
352
|
-
}
|
|
353
|
-
return new Response(
|
|
354
|
-
JSON.stringify({
|
|
355
|
-
jsonrpc: "2.0",
|
|
356
|
-
id: null,
|
|
357
|
-
error: {
|
|
358
|
-
code: overloaded ? -32001 : -32002,
|
|
359
|
-
message: overloaded
|
|
360
|
-
? "Server capacity is exhausted. Retry later."
|
|
361
|
-
: "Server is shutting down.",
|
|
362
|
-
data,
|
|
363
|
-
},
|
|
364
|
-
}),
|
|
365
|
-
{ status: 503, headers },
|
|
366
|
-
);
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
/**
|
|
370
|
-
* A request owns its permit through the response body, not merely until the
|
|
371
|
-
* handler returns. This is what makes slow clients and response-stream failure
|
|
372
|
-
* part of the same bounded lifecycle as success, error, and cancellation.
|
|
373
|
-
*/
|
|
374
|
-
function releaseAdmissionWithResponse(
|
|
375
|
-
response: Response,
|
|
376
|
-
lease: AdmissionLease,
|
|
377
|
-
signal: AbortSignal,
|
|
378
|
-
): Response {
|
|
379
|
-
let released = false;
|
|
380
|
-
let onAbort = () => {};
|
|
381
|
-
const release = () => {
|
|
382
|
-
if (released) return;
|
|
383
|
-
released = true;
|
|
384
|
-
signal.removeEventListener("abort", onAbort);
|
|
385
|
-
lease.release();
|
|
386
|
-
};
|
|
387
|
-
if (!response.body) {
|
|
388
|
-
release();
|
|
389
|
-
return response;
|
|
390
|
-
}
|
|
391
|
-
const reader = response.body.getReader();
|
|
392
|
-
onAbort = () => {
|
|
393
|
-
// `cancel()` belongs to an operator/auth/SDK-provided stream and may
|
|
394
|
-
// reject. Consume both outcomes: `.finally(release)` would release the
|
|
395
|
-
// permit but preserve the rejection as an unhandled promise.
|
|
396
|
-
void reader.cancel(signal.reason).then(release, release);
|
|
397
|
-
};
|
|
398
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
399
|
-
if (signal.aborted) onAbort();
|
|
400
|
-
const body = new ReadableStream<Uint8Array>({
|
|
401
|
-
async pull(controller) {
|
|
402
|
-
try {
|
|
403
|
-
const next = await reader.read();
|
|
404
|
-
if (next.done) {
|
|
405
|
-
release();
|
|
406
|
-
controller.close();
|
|
407
|
-
} else {
|
|
408
|
-
controller.enqueue(next.value);
|
|
409
|
-
}
|
|
410
|
-
} catch (error) {
|
|
411
|
-
release();
|
|
412
|
-
controller.error(error);
|
|
413
|
-
}
|
|
414
|
-
},
|
|
415
|
-
async cancel(reason) {
|
|
416
|
-
try {
|
|
417
|
-
await reader.cancel(reason);
|
|
418
|
-
} finally {
|
|
419
|
-
release();
|
|
420
|
-
}
|
|
421
|
-
},
|
|
422
|
-
});
|
|
423
|
-
return new Response(body, {
|
|
424
|
-
status: response.status,
|
|
425
|
-
statusText: response.statusText,
|
|
426
|
-
headers: response.headers,
|
|
427
|
-
});
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
function withSecurityHeaders(
|
|
431
|
-
response: Response,
|
|
432
|
-
requestUrl: URL,
|
|
433
|
-
path: string,
|
|
434
|
-
): Response {
|
|
435
|
-
const headers = new Headers(response.headers);
|
|
436
|
-
headers.set("X-Content-Type-Options", "nosniff");
|
|
437
|
-
headers.set("Referrer-Policy", "no-referrer");
|
|
438
|
-
if (requestUrl.protocol === "https:") {
|
|
439
|
-
headers.set("Strict-Transport-Security", "max-age=31536000");
|
|
440
|
-
}
|
|
441
|
-
if (operatorPageForPath(path) || path === "/ui") {
|
|
442
|
-
// Operator HTML responses ship their own nonce-based script CSP (which
|
|
443
|
-
// already includes frame-ancestors 'none'); only fall back to the
|
|
444
|
-
// framing-only directive when no CSP is present (for example redirects).
|
|
445
|
-
if (!headers.has("Content-Security-Policy")) {
|
|
446
|
-
headers.set("Content-Security-Policy", "frame-ancestors 'none'");
|
|
447
|
-
}
|
|
448
|
-
headers.set("X-Frame-Options", "DENY");
|
|
449
|
-
}
|
|
450
|
-
return new Response(response.body, {
|
|
451
|
-
status: response.status,
|
|
452
|
-
statusText: response.statusText,
|
|
453
|
-
headers,
|
|
454
|
-
});
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
async function authorizeUiAdmin(
|
|
458
|
-
request: Request,
|
|
459
|
-
baseUrl: string,
|
|
460
|
-
auth: InboundAuth[],
|
|
461
|
-
logger: Logger,
|
|
462
|
-
purpose = "credential management",
|
|
463
|
-
): Promise<{ ok: true; userId: string } | { ok: false; response: Response }> {
|
|
464
|
-
// Operator mutation is intentionally narrower than /mcp and /ui/data: only
|
|
465
|
-
// an interactive Clerk provider may admit it. A static bearer token is useful
|
|
466
|
-
// for headless tool calls but must not become a deployment-admin key.
|
|
467
|
-
//
|
|
468
|
-
// EVERY Clerk provider gets a turn, the way the /mcp gate does, because the
|
|
469
|
-
// documented per-team pattern is several `clerkAuth(...)`s that differ only in
|
|
470
|
-
// `gate` and `toolkits` (docs/toolkits.md). Stopping at the first would make
|
|
471
|
-
// admission depend on config order: the team-bound provider listed first
|
|
472
|
-
// would refuse the operator outright, and a refusal here — a failed gate, a
|
|
473
|
-
// missing user, a toolkit-bound identity — is exactly the case where a later
|
|
474
|
-
// provider is the one meant to admit. The last refusal is returned if none do.
|
|
475
|
-
const providers = auth.filter(
|
|
476
|
-
(candidate) => candidate.uiAuth?.kind === "clerk",
|
|
477
|
-
);
|
|
478
|
-
if (providers.length === 0) {
|
|
479
|
-
return {
|
|
480
|
-
ok: false,
|
|
481
|
-
response: privateJson(
|
|
482
|
-
{ error: `${purpose} requires Clerk authentication` },
|
|
483
|
-
{ status: 403 },
|
|
484
|
-
),
|
|
485
|
-
};
|
|
486
|
-
}
|
|
487
|
-
let lastResponse: Response | null = null;
|
|
488
|
-
for (const provider of providers) {
|
|
489
|
-
const result = await provider.authorize(request, baseUrl);
|
|
490
|
-
if (!result.ok) {
|
|
491
|
-
lastResponse = result.response;
|
|
492
|
-
continue;
|
|
493
|
-
}
|
|
494
|
-
if (!result.userId) {
|
|
495
|
-
lastResponse = privateJson(
|
|
496
|
-
{ error: "authenticated user required" },
|
|
497
|
-
{ status: 403 },
|
|
498
|
-
);
|
|
499
|
-
continue;
|
|
500
|
-
}
|
|
501
|
-
const binding = resolveIdentityBinding(
|
|
502
|
-
provider.toolkitBinding,
|
|
503
|
-
result.toolkitBinding,
|
|
504
|
-
);
|
|
505
|
-
if (!binding.ok) {
|
|
506
|
-
logger.warn(
|
|
507
|
-
`[connecta] refused an operator-mutation request admitted by inbound auth ` +
|
|
508
|
-
`provider "${provider.kind}" with 403: ${binding.reason}.`,
|
|
509
|
-
);
|
|
510
|
-
lastResponse = unusableBinding();
|
|
511
|
-
continue;
|
|
512
|
-
}
|
|
513
|
-
// A toolkit-bound identity is a team's credential, not a vault admin key:
|
|
514
|
-
// credentials are deployment-wide, so writing one reaches every toolkit.
|
|
515
|
-
if (isToolkitRestricted(binding.binding)) {
|
|
516
|
-
lastResponse = restrictedOperatorSurface();
|
|
517
|
-
continue;
|
|
518
|
-
}
|
|
519
|
-
return { ok: true, userId: result.userId };
|
|
520
|
-
}
|
|
521
|
-
return {
|
|
522
|
-
ok: false,
|
|
523
|
-
response:
|
|
524
|
-
lastResponse ??
|
|
525
|
-
privateJson({ error: "forbidden" }, { status: 403 }),
|
|
526
|
-
};
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
function isSameOrigin(request: Request, baseUrl: string): boolean {
|
|
530
|
-
const origin = request.headers.get("origin");
|
|
531
|
-
if (!origin) return false;
|
|
532
|
-
try {
|
|
533
|
-
return new URL(origin).origin === new URL(baseUrl).origin;
|
|
534
|
-
} catch {
|
|
535
|
-
return false;
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
type CredentialInput =
|
|
540
|
-
| { kind: "single"; value: string }
|
|
541
|
-
| { kind: "multiple"; values: ConnectorCredentialValues };
|
|
542
|
-
|
|
543
|
-
async function readCredentialInput(
|
|
544
|
-
request: Request,
|
|
545
|
-
config: ConnectorCredentialConfig,
|
|
546
|
-
): Promise<
|
|
547
|
-
{ ok: true; input: CredentialInput } | { ok: false; response: Response }
|
|
548
|
-
> {
|
|
549
|
-
if (
|
|
550
|
-
!request.headers
|
|
551
|
-
.get("content-type")
|
|
552
|
-
?.toLowerCase()
|
|
553
|
-
.startsWith("application/json")
|
|
554
|
-
) {
|
|
555
|
-
return {
|
|
556
|
-
ok: false,
|
|
557
|
-
response: privateJson(
|
|
558
|
-
{ error: "Content-Type must be application/json" },
|
|
559
|
-
{ status: 415 },
|
|
560
|
-
),
|
|
561
|
-
};
|
|
562
|
-
}
|
|
563
|
-
const raw = await request.text();
|
|
564
|
-
if (raw.length > 20_000) {
|
|
565
|
-
return {
|
|
566
|
-
ok: false,
|
|
567
|
-
response: privateJson(
|
|
568
|
-
{ error: "request body is too large" },
|
|
569
|
-
{ status: 413 },
|
|
570
|
-
),
|
|
571
|
-
};
|
|
572
|
-
}
|
|
573
|
-
try {
|
|
574
|
-
const body = JSON.parse(raw) as {
|
|
575
|
-
value?: unknown;
|
|
576
|
-
values?: unknown;
|
|
577
|
-
};
|
|
578
|
-
if (!config.fields?.length) {
|
|
579
|
-
if (typeof body.value !== "string" || !body.value.trim()) {
|
|
580
|
-
return {
|
|
581
|
-
ok: false,
|
|
582
|
-
response: privateJson(
|
|
583
|
-
{ error: "value must be a non-empty string" },
|
|
584
|
-
{ status: 400 },
|
|
585
|
-
),
|
|
586
|
-
};
|
|
587
|
-
}
|
|
588
|
-
return { ok: true, input: { kind: "single", value: body.value } };
|
|
589
|
-
}
|
|
590
|
-
if (
|
|
591
|
-
!body.values ||
|
|
592
|
-
typeof body.values !== "object" ||
|
|
593
|
-
Array.isArray(body.values)
|
|
594
|
-
) {
|
|
595
|
-
return {
|
|
596
|
-
ok: false,
|
|
597
|
-
response: privateJson(
|
|
598
|
-
{ error: "values must be an object" },
|
|
599
|
-
{ status: 400 },
|
|
600
|
-
),
|
|
601
|
-
};
|
|
602
|
-
}
|
|
603
|
-
const rawValues = body.values as Record<string, unknown>;
|
|
604
|
-
const expected = new Set(config.fields.map((field) => field.name));
|
|
605
|
-
const unexpected = Object.keys(rawValues).find(
|
|
606
|
-
(field) => !expected.has(field),
|
|
607
|
-
);
|
|
608
|
-
if (unexpected) {
|
|
609
|
-
return {
|
|
610
|
-
ok: false,
|
|
611
|
-
response: privateJson(
|
|
612
|
-
{ error: `unexpected credential field "${unexpected}"` },
|
|
613
|
-
{ status: 400 },
|
|
614
|
-
),
|
|
615
|
-
};
|
|
616
|
-
}
|
|
617
|
-
const values: ConnectorCredentialValues = {};
|
|
618
|
-
for (const field of config.fields) {
|
|
619
|
-
const value = rawValues[field.name];
|
|
620
|
-
if (typeof value !== "string" || !value.trim()) {
|
|
621
|
-
return {
|
|
622
|
-
ok: false,
|
|
623
|
-
response: privateJson(
|
|
624
|
-
{ error: `${field.name} must be a non-empty string` },
|
|
625
|
-
{ status: 400 },
|
|
626
|
-
),
|
|
627
|
-
};
|
|
628
|
-
}
|
|
629
|
-
values[field.name] = value;
|
|
630
|
-
}
|
|
631
|
-
return { ok: true, input: { kind: "multiple", values } };
|
|
632
|
-
} catch {
|
|
633
|
-
return {
|
|
634
|
-
ok: false,
|
|
635
|
-
response: privateJson({ error: "invalid JSON body" }, { status: 400 }),
|
|
636
|
-
};
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
async function handleCredentialRequest(
|
|
641
|
-
request: Request,
|
|
642
|
-
connectorId: string,
|
|
643
|
-
action: string | undefined,
|
|
644
|
-
opts: ServerOptions,
|
|
645
|
-
baseUrl: string,
|
|
646
|
-
): Promise<Response> {
|
|
647
|
-
if (!opts.credentialVault) {
|
|
648
|
-
return privateJson(
|
|
649
|
-
{ error: "credential storage is not configured" },
|
|
650
|
-
{ status: 503 },
|
|
651
|
-
);
|
|
652
|
-
}
|
|
653
|
-
if (!isSameOrigin(request, baseUrl)) {
|
|
654
|
-
return privateJson(
|
|
655
|
-
{ error: "same-origin request required" },
|
|
656
|
-
{ status: 403 },
|
|
657
|
-
);
|
|
658
|
-
}
|
|
659
|
-
const admin = await authorizeUiAdmin(
|
|
660
|
-
request,
|
|
661
|
-
baseUrl,
|
|
662
|
-
opts.auth,
|
|
663
|
-
opts.logger,
|
|
664
|
-
);
|
|
665
|
-
if (!admin.ok) return admin.response;
|
|
666
|
-
|
|
667
|
-
const connector = opts.registry.getConnector(connectorId);
|
|
668
|
-
if (!connector?.credential) {
|
|
669
|
-
return privateJson({ error: "unknown credential slot" }, { status: 404 });
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
if (action === "test") {
|
|
673
|
-
if (request.method !== "POST") {
|
|
674
|
-
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
675
|
-
}
|
|
676
|
-
// The declared credential shape picks the hook — the same single rule the
|
|
677
|
-
// Credentials page asks for its Test affordance, so a shown button reaches
|
|
678
|
-
// that reads the shape the credential was stored in.
|
|
679
|
-
const rule = credentialTestRule(connector);
|
|
680
|
-
if (!rule.mode) {
|
|
681
|
-
return privateJson(
|
|
682
|
-
{
|
|
683
|
-
error: rule.mismatch
|
|
684
|
-
? "this connector cannot test its credential: " +
|
|
685
|
-
describeCredentialTestMismatch(rule.mismatch)
|
|
686
|
-
: "this connector does not support credential testing",
|
|
687
|
-
},
|
|
688
|
-
{ status: 400 },
|
|
689
|
-
);
|
|
690
|
-
}
|
|
691
|
-
try {
|
|
692
|
-
const values = await opts.credentialVault.getAll(connectorId);
|
|
693
|
-
const shape = storedCredentialShape(connector.credential, values);
|
|
694
|
-
if (shape.state === "missing") {
|
|
695
|
-
return privateJson(
|
|
696
|
-
{
|
|
697
|
-
error:
|
|
698
|
-
rule.mode === "multiple"
|
|
699
|
-
? "configure the credentials before testing them"
|
|
700
|
-
: "configure the credential before testing it",
|
|
701
|
-
},
|
|
702
|
-
{ status: 409 },
|
|
703
|
-
);
|
|
704
|
-
}
|
|
705
|
-
if (shape.state === "mismatch") {
|
|
706
|
-
return privateJson({ error: shape.message }, { status: 409 });
|
|
707
|
-
}
|
|
708
|
-
const storedValues = values!;
|
|
709
|
-
const ctx = opts.registry.contextFor(connectorId, baseUrl);
|
|
710
|
-
let result;
|
|
711
|
-
if (rule.mode === "multiple") {
|
|
712
|
-
result = await connector.testCredentials!(storedValues, ctx);
|
|
713
|
-
} else {
|
|
714
|
-
result = await connector.testCredential!(storedValues.value, ctx);
|
|
715
|
-
}
|
|
716
|
-
// The operator just ran the very check the liveness sweep runs; record it
|
|
717
|
-
// so cached status surfaces agree with what the operator page showed.
|
|
718
|
-
await opts.registry.recordCredentialHealth(connectorId, {
|
|
719
|
-
state: result.ok ? "ok" : "auth_required",
|
|
720
|
-
checkedAt: new Date().toISOString(),
|
|
721
|
-
...(result.message ? { message: result.message } : {}),
|
|
722
|
-
});
|
|
723
|
-
return privateJson(result);
|
|
724
|
-
} catch (err) {
|
|
725
|
-
return privateJson({ ok: false, message: msg(err) });
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
if (action) {
|
|
730
|
-
return privateJson({ error: "not found" }, { status: 404 });
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
if (request.method === "PUT") {
|
|
734
|
-
const input = await readCredentialInput(request, connector.credential);
|
|
735
|
-
if (!input.ok) return input.response;
|
|
736
|
-
try {
|
|
737
|
-
const metadata =
|
|
738
|
-
input.input.kind === "single"
|
|
739
|
-
? await opts.credentialVault.set(
|
|
740
|
-
connectorId,
|
|
741
|
-
input.input.value,
|
|
742
|
-
admin.userId,
|
|
743
|
-
)
|
|
744
|
-
: await opts.credentialVault.setAll(
|
|
745
|
-
connectorId,
|
|
746
|
-
input.input.values,
|
|
747
|
-
admin.userId,
|
|
748
|
-
);
|
|
749
|
-
await opts.registry.invalidateStored(connectorId);
|
|
750
|
-
// The credential the last verdict judged is gone; judging its replacement
|
|
751
|
-
// is the next check's job, not this one's.
|
|
752
|
-
await opts.registry.clearCredentialHealth(connectorId);
|
|
753
|
-
return privateJson({ credential: metadata });
|
|
754
|
-
} catch (err) {
|
|
755
|
-
return privateJson({ error: msg(err) }, { status: 400 });
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
if (request.method === "DELETE") {
|
|
760
|
-
await opts.credentialVault.delete(connectorId);
|
|
761
|
-
await opts.registry.invalidateStored(connectorId);
|
|
762
|
-
await opts.registry.clearCredentialHealth(connectorId);
|
|
763
|
-
return new Response(null, {
|
|
764
|
-
status: 204,
|
|
765
|
-
headers: {
|
|
766
|
-
"Cache-Control": "no-store",
|
|
767
|
-
"Referrer-Policy": "no-referrer",
|
|
768
|
-
},
|
|
769
|
-
});
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
async function handleOAuthManagementRequest(
|
|
776
|
-
request: Request,
|
|
777
|
-
connectorId: string,
|
|
778
|
-
opts: ServerOptions,
|
|
779
|
-
baseUrl: string,
|
|
780
|
-
defer?: DeferredWork,
|
|
781
|
-
): Promise<Response> {
|
|
782
|
-
if (!isSameOrigin(request, baseUrl)) {
|
|
783
|
-
return privateJson(
|
|
784
|
-
{ error: "same-origin request required" },
|
|
785
|
-
{ status: 403 },
|
|
786
|
-
);
|
|
787
|
-
}
|
|
788
|
-
const admin = await authorizeUiAdmin(
|
|
789
|
-
request,
|
|
790
|
-
baseUrl,
|
|
791
|
-
opts.auth,
|
|
792
|
-
opts.logger,
|
|
793
|
-
"OAuth management",
|
|
794
|
-
);
|
|
795
|
-
if (!admin.ok) return admin.response;
|
|
796
|
-
|
|
797
|
-
const connector = opts.registry.getConnector(connectorId);
|
|
798
|
-
if (!connector?.disconnectAuth || !connector.startAuth) {
|
|
799
|
-
return privateJson(
|
|
800
|
-
{ error: "unknown OAuth connector" },
|
|
801
|
-
{ status: 404 },
|
|
802
|
-
);
|
|
803
|
-
}
|
|
804
|
-
if (request.method !== "DELETE" && request.method !== "POST") {
|
|
805
|
-
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
const requestScope = {};
|
|
809
|
-
const ctx = opts.registry.contextFor(connectorId, baseUrl, requestScope);
|
|
810
|
-
try {
|
|
811
|
-
let result: ConnectorStatus | undefined;
|
|
812
|
-
let operationError: unknown;
|
|
813
|
-
try {
|
|
814
|
-
if (request.method === "DELETE") {
|
|
815
|
-
await connector.disconnectAuth(ctx);
|
|
816
|
-
} else {
|
|
817
|
-
result = await connector.startAuth(ctx, { force: true });
|
|
818
|
-
}
|
|
819
|
-
} catch (error) {
|
|
820
|
-
operationError = error;
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
// The old grant and its catalog verdict are invalid after either operation,
|
|
824
|
-
// including a partially failed physical cleanup whose epoch fence succeeded.
|
|
825
|
-
try {
|
|
826
|
-
await opts.registry.invalidateStored(connectorId);
|
|
827
|
-
await opts.registry.clearCredentialHealth(connectorId);
|
|
828
|
-
} catch (error) {
|
|
829
|
-
operationError ??= error;
|
|
830
|
-
}
|
|
831
|
-
if (operationError) {
|
|
832
|
-
return privateJson({ error: msg(operationError) }, { status: 400 });
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
if (request.method === "DELETE") {
|
|
836
|
-
return new Response(null, {
|
|
837
|
-
status: 204,
|
|
838
|
-
headers: {
|
|
839
|
-
"Cache-Control": "no-store",
|
|
840
|
-
"Referrer-Policy": "no-referrer",
|
|
841
|
-
},
|
|
842
|
-
});
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
const authorizationUrl = isSafeHttpUrl(result!.authorizationUrl)
|
|
846
|
-
? result!.authorizationUrl
|
|
847
|
-
: undefined;
|
|
848
|
-
if (result!.state === "error") {
|
|
849
|
-
return privateJson(
|
|
850
|
-
{ error: result!.message || "OAuth authorization could not start" },
|
|
851
|
-
{ status: 502 },
|
|
852
|
-
);
|
|
853
|
-
}
|
|
854
|
-
if (result!.state === "auth_required" && !authorizationUrl) {
|
|
855
|
-
return privateJson(
|
|
856
|
-
{
|
|
857
|
-
error:
|
|
858
|
-
result!.message ||
|
|
859
|
-
"OAuth authorization requires consent but no safe URL is available",
|
|
860
|
-
},
|
|
861
|
-
{ status: 502 },
|
|
862
|
-
);
|
|
863
|
-
}
|
|
864
|
-
return privateJson({
|
|
865
|
-
state: result!.state,
|
|
866
|
-
...(result!.message ? { message: result!.message } : {}),
|
|
867
|
-
...(authorizationUrl ? { authorizationUrl } : {}),
|
|
868
|
-
});
|
|
869
|
-
} finally {
|
|
870
|
-
await closeConnectorScope(connector, ctx, defer);
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
/** Length beyond which a rejected toolkit name is not echoed back. */
|
|
875
|
-
const MAX_ECHOED_TOOLKIT_NAME = 64;
|
|
876
|
-
|
|
877
|
-
/** What one MCP connection may see: the full registry, or one toolkit's view. */
|
|
878
|
-
interface McpScope {
|
|
879
|
-
registry: RegistryView;
|
|
880
|
-
/** Set only under `?toolkit=`; recorded on activity events. */
|
|
881
|
-
toolkitId?: string;
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
/**
|
|
885
|
-
* Bounded, escaped form of a caller-influenced value (a rejected toolkit name,
|
|
886
|
-
* identity id, or OAuth callback connector id) for the operator log. Goes
|
|
887
|
-
* through JSON.stringify so a caller-controlled newline or control character
|
|
888
|
-
* cannot forge a log line, plus a hand-rolled escape for U+2028/U+2029, which
|
|
889
|
-
* JSON.stringify leaves raw even though a log reader treats them as line
|
|
890
|
-
* terminators. Truncated to a small shared cap (also the toolkit response's echo
|
|
891
|
-
* limit), so an oversized value cannot flood the log either.
|
|
892
|
-
*/
|
|
893
|
-
function loggableValue(requested: string): string {
|
|
894
|
-
const bounded = requested.slice(0, MAX_ECHOED_TOOLKIT_NAME);
|
|
895
|
-
const escaped = JSON.stringify(bounded).replace(
|
|
896
|
-
/[\u2028\u2029]/g,
|
|
897
|
-
(ch) => `\\u${ch.charCodeAt(0).toString(16)}`,
|
|
898
|
-
);
|
|
899
|
-
return escaped + (bounded.length < requested.length ? " (truncated)" : "");
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
/** The one refusal a bound identity ever sees. Constant on purpose — see below. */
|
|
903
|
-
const TOOLKIT_FORBIDDEN_BODY = JSON.stringify({
|
|
904
|
-
jsonrpc: "2.0",
|
|
905
|
-
id: null,
|
|
906
|
-
error: {
|
|
907
|
-
code: -32600,
|
|
908
|
-
message:
|
|
909
|
-
"Not permitted to use the requested toolkit. This credential is bound " +
|
|
910
|
-
"to a specific toolkit — check the ?toolkit= value in this deployment's " +
|
|
911
|
-
"MCP endpoint URL with the operator.",
|
|
912
|
-
},
|
|
913
|
-
});
|
|
914
|
-
|
|
915
|
-
/**
|
|
916
|
-
* 403 for every binding refusal, with a body that does not depend on WHY.
|
|
917
|
-
*
|
|
918
|
-
* A bound identity asking for a toolkit it may not open, for a toolkit that does
|
|
919
|
-
* not exist, or for no toolkit at all gets byte-identical responses, so a team
|
|
920
|
-
* credential cannot be used to enumerate the org's other teams — the boundary
|
|
921
|
-
* would leak the very structure it exists to hide. The operator log below is
|
|
922
|
-
* where the three cases are told apart.
|
|
923
|
-
*/
|
|
924
|
-
function toolkitForbidden(): Response {
|
|
925
|
-
return new Response(TOOLKIT_FORBIDDEN_BODY, {
|
|
926
|
-
status: 403,
|
|
927
|
-
headers: {
|
|
928
|
-
"Content-Type": "application/json",
|
|
929
|
-
"Cache-Control": "no-store",
|
|
930
|
-
},
|
|
931
|
-
});
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
/** How a rejected connection is named in the operator log. */
|
|
935
|
-
function identityLabel(actor: ActivityActor): string {
|
|
936
|
-
return actor.id ? `${actor.kind} ${loggableValue(actor.id)}` : actor.kind;
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
const ACTIVITY_ACTOR_NAMESPACE_RE = /^[\x21-\x7e]{1,256}$/;
|
|
940
|
-
const ACTIVITY_LABEL_CONCURRENCY = 8;
|
|
941
|
-
const ACTIVITY_LABEL_PAGE_BUDGET_MS = 1_500;
|
|
942
|
-
const ACTIVITY_LABEL_MAX_LENGTH = 160;
|
|
943
|
-
|
|
944
|
-
function activityActorNamespace(
|
|
945
|
-
provider: InboundAuth,
|
|
946
|
-
): string | undefined {
|
|
947
|
-
return typeof provider.activityActorNamespace === "string" &&
|
|
948
|
-
ACTIVITY_ACTOR_NAMESPACE_RE.test(provider.activityActorNamespace)
|
|
949
|
-
? provider.activityActorNamespace
|
|
950
|
-
: undefined;
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
function cleanActivityActorLabel(value: unknown): string | undefined {
|
|
954
|
-
if (typeof value !== "string") return undefined;
|
|
955
|
-
const compact = value.replace(/\s+/gu, " ").trim();
|
|
956
|
-
if (!compact) return undefined;
|
|
957
|
-
return Array.from(compact).slice(0, ACTIVITY_LABEL_MAX_LENGTH).join("");
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
async function boundedActivityActorLabel(
|
|
961
|
-
hook: NonNullable<InboundAuth["activityActorLabel"]>,
|
|
962
|
-
id: string,
|
|
963
|
-
budgetMs: number,
|
|
964
|
-
): Promise<string | undefined> {
|
|
965
|
-
let timer: number | undefined;
|
|
966
|
-
try {
|
|
967
|
-
return await Promise.race([
|
|
968
|
-
Promise.resolve(hook(id))
|
|
969
|
-
.then(cleanActivityActorLabel)
|
|
970
|
-
.catch(() => undefined),
|
|
971
|
-
new Promise<undefined>((resolve) => {
|
|
972
|
-
timer = setTimeout(resolve, budgetMs);
|
|
973
|
-
}),
|
|
974
|
-
]);
|
|
975
|
-
} catch {
|
|
976
|
-
return undefined;
|
|
977
|
-
} finally {
|
|
978
|
-
if (timer !== undefined) clearTimeout(timer);
|
|
979
|
-
}
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
/**
|
|
983
|
-
* Add display-only actor labels to one authorized activity page. Resolution is
|
|
984
|
-
* best-effort, bounded, and read-time only: stored events retain stable ids and
|
|
985
|
-
* a profile-provider outage falls back to those ids without failing the page.
|
|
986
|
-
*/
|
|
987
|
-
async function enrichActivityActorLabels(
|
|
988
|
-
page: ActivityPage,
|
|
989
|
-
auth: readonly InboundAuth[],
|
|
990
|
-
): Promise<ActivityReadPage> {
|
|
991
|
-
const identities = new Map<
|
|
992
|
-
string,
|
|
993
|
-
{ kind: string; id: string; namespace?: string }
|
|
994
|
-
>();
|
|
995
|
-
for (const event of page.events) {
|
|
996
|
-
if (!event.actor.id) continue;
|
|
997
|
-
identities.set(JSON.stringify([
|
|
998
|
-
event.actor.kind,
|
|
999
|
-
event.actor.namespace,
|
|
1000
|
-
event.actor.id,
|
|
1001
|
-
]), {
|
|
1002
|
-
kind: event.actor.kind,
|
|
1003
|
-
id: event.actor.id,
|
|
1004
|
-
...(event.actor.namespace
|
|
1005
|
-
? { namespace: event.actor.namespace }
|
|
1006
|
-
: {}),
|
|
1007
|
-
});
|
|
1008
|
-
}
|
|
1009
|
-
const queue = [...identities.entries()];
|
|
1010
|
-
const labels = new Map<string, string>();
|
|
1011
|
-
let next = 0;
|
|
1012
|
-
const deadline = Date.now() + ACTIVITY_LABEL_PAGE_BUDGET_MS;
|
|
1013
|
-
const workers = Array.from(
|
|
1014
|
-
{ length: Math.min(ACTIVITY_LABEL_CONCURRENCY, queue.length) },
|
|
1015
|
-
async () => {
|
|
1016
|
-
while (next < queue.length) {
|
|
1017
|
-
const [key, identity] = queue[next++];
|
|
1018
|
-
const sameKindProviders = auth
|
|
1019
|
-
.map((provider, index) => ({ provider, index }))
|
|
1020
|
-
.filter(({ provider }) => provider.kind === identity.kind);
|
|
1021
|
-
const candidates = sameKindProviders.filter(({ provider }) =>
|
|
1022
|
-
Boolean(provider.activityActorLabel),
|
|
1023
|
-
);
|
|
1024
|
-
const eligible = identity.namespace
|
|
1025
|
-
? candidates.filter(
|
|
1026
|
-
({ provider }) =>
|
|
1027
|
-
activityActorNamespace(provider) === identity.namespace,
|
|
1028
|
-
)
|
|
1029
|
-
: (() => {
|
|
1030
|
-
const directoryKey = ({
|
|
1031
|
-
provider,
|
|
1032
|
-
index,
|
|
1033
|
-
}: (typeof sameKindProviders)[number]) => {
|
|
1034
|
-
const namespace = activityActorNamespace(provider);
|
|
1035
|
-
return namespace === undefined
|
|
1036
|
-
? `provider:${index}`
|
|
1037
|
-
: `namespace:${namespace}`;
|
|
1038
|
-
};
|
|
1039
|
-
// Every same-kind provider participates in the ambiguity check,
|
|
1040
|
-
// even if it cannot resolve labels. Otherwise a legacy ID owned
|
|
1041
|
-
// by a provider without a resolver could be disclosed to a
|
|
1042
|
-
// different provider that happens to have one.
|
|
1043
|
-
const directories = new Set(
|
|
1044
|
-
sameKindProviders.map(directoryKey),
|
|
1045
|
-
);
|
|
1046
|
-
if (directories.size !== 1) return [];
|
|
1047
|
-
const [directory] = directories;
|
|
1048
|
-
return candidates.filter(
|
|
1049
|
-
(candidate) => directoryKey(candidate) === directory,
|
|
1050
|
-
);
|
|
1051
|
-
})();
|
|
1052
|
-
// One namespace is one directory. Use its first configured resolver so
|
|
1053
|
-
// duplicate gate adapters over the same Clerk instance do not multiply
|
|
1054
|
-
// the provider-level concurrency cap.
|
|
1055
|
-
const provider = eligible[0]?.provider;
|
|
1056
|
-
if (!provider) continue;
|
|
1057
|
-
const remaining = deadline - Date.now();
|
|
1058
|
-
if (remaining <= 0) return;
|
|
1059
|
-
const label = await boundedActivityActorLabel(
|
|
1060
|
-
provider.activityActorLabel!.bind(provider),
|
|
1061
|
-
identity.id,
|
|
1062
|
-
remaining,
|
|
1063
|
-
);
|
|
1064
|
-
if (label) {
|
|
1065
|
-
labels.set(key, label);
|
|
1066
|
-
}
|
|
1067
|
-
}
|
|
1068
|
-
},
|
|
1069
|
-
);
|
|
1070
|
-
await Promise.all(workers);
|
|
1071
|
-
return {
|
|
1072
|
-
...page,
|
|
1073
|
-
events: page.events.map((event) => {
|
|
1074
|
-
const resolved = event.actor.id
|
|
1075
|
-
? labels.get(JSON.stringify([
|
|
1076
|
-
event.actor.kind,
|
|
1077
|
-
event.actor.namespace,
|
|
1078
|
-
event.actor.id,
|
|
1079
|
-
]))
|
|
1080
|
-
: undefined;
|
|
1081
|
-
// Never trust or echo a `label` supplied by storage. The persisted event
|
|
1082
|
-
// schema has no label; only this authenticated read path may add one.
|
|
1083
|
-
const actor: ActivityActor = {
|
|
1084
|
-
kind: event.actor.kind,
|
|
1085
|
-
...(event.actor.id ? { id: event.actor.id } : {}),
|
|
1086
|
-
...(event.actor.namespace
|
|
1087
|
-
? { namespace: event.actor.namespace }
|
|
1088
|
-
: {}),
|
|
1089
|
-
};
|
|
1090
|
-
return {
|
|
1091
|
-
...event,
|
|
1092
|
-
actor: resolved ? { ...actor, label: resolved } : actor,
|
|
1093
|
-
};
|
|
1094
|
-
}),
|
|
1095
|
-
};
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
/**
|
|
1099
|
-
* Resolve `?toolkit=<name>` into the registry view this connection may see,
|
|
1100
|
-
* enforcing the caller's toolkit binding (docs/toolkits.md) on the way.
|
|
1101
|
-
*
|
|
1102
|
-
* For an UNBOUND identity (no binding configured — the pre-#37 shape):
|
|
1103
|
-
*
|
|
1104
|
-
* - absent → the full registry, byte-identical to a deployment with no toolkits
|
|
1105
|
-
* - known → a `ScopedRegistry` over that toolkit (the one visibility boundary)
|
|
1106
|
-
* - anything else, including `?toolkit=` with an empty value → an explicit
|
|
1107
|
-
* 404. Never a silent fallback to the full registry.
|
|
1108
|
-
*
|
|
1109
|
-
* For a BOUND identity, membership is checked FIRST and refusal is a flat 403:
|
|
1110
|
-
* a toolkit outside the binding, an unknown name, and (without `unscoped`) an
|
|
1111
|
-
* omitted `?toolkit=` are all refused before any `ScopedRegistry` is built, and
|
|
1112
|
-
* all three produce the same response.
|
|
1113
|
-
*
|
|
1114
|
-
* Neither error enumerates the configured toolkits: the name selects a scope, so
|
|
1115
|
-
* a wrong guess gets a flat refusal, not a directory.
|
|
1116
|
-
*
|
|
1117
|
-
* Because of that — and because SDK clients treat a 404/403 on the transport
|
|
1118
|
-
* endpoint as a transport failure and discard the body — every rejection is also
|
|
1119
|
-
* logged operator-side (issue #47), which is the channel that actually reaches a
|
|
1120
|
-
* human. The log line may name the configured or bound toolkits; the response
|
|
1121
|
-
* still may not.
|
|
1122
|
-
*/
|
|
1123
|
-
function resolveToolkitScope(
|
|
1124
|
-
url: URL,
|
|
1125
|
-
registry: Registry,
|
|
1126
|
-
toolkits: ReadonlyMap<string, Toolkit> | undefined,
|
|
1127
|
-
logger: Logger,
|
|
1128
|
-
identity: { actor: ActivityActor; binding?: ToolkitBinding },
|
|
1129
|
-
):
|
|
1130
|
-
| { ok: true; scope: McpScope }
|
|
1131
|
-
| { ok: false; response: Response } {
|
|
1132
|
-
const requested = url.searchParams.get("toolkit");
|
|
1133
|
-
const binding = identity.binding;
|
|
1134
|
-
const scopeFor = (toolkit: Toolkit) => ({
|
|
1135
|
-
ok: true as const,
|
|
1136
|
-
scope: {
|
|
1137
|
-
registry: new ScopedRegistry(registry, toolkit),
|
|
1138
|
-
toolkitId: toolkit.name,
|
|
1139
|
-
},
|
|
1140
|
-
});
|
|
1141
|
-
|
|
1142
|
-
if (binding) {
|
|
1143
|
-
const who = identityLabel(identity.actor);
|
|
1144
|
-
const bound = `Bound toolkits: ${binding.toolkits.join(", ") || "(none)"}${
|
|
1145
|
-
binding.unscoped ? ", plus unscoped access" : ""
|
|
1146
|
-
}.`;
|
|
1147
|
-
if (requested === null) {
|
|
1148
|
-
if (binding.unscoped) return { ok: true, scope: { registry } };
|
|
1149
|
-
logger.warn(
|
|
1150
|
-
`[connecta] refused an unscoped /mcp connection from ${who} with 403: ` +
|
|
1151
|
-
"its toolkit binding does not allow the full registry. " +
|
|
1152
|
-
bound +
|
|
1153
|
-
" The client sees a transport-level failure and never the reason, so " +
|
|
1154
|
-
"give it an MCP endpoint URL with a ?toolkit= value it is bound to.",
|
|
1155
|
-
);
|
|
1156
|
-
return { ok: false, response: toolkitForbidden() };
|
|
1157
|
-
}
|
|
1158
|
-
const permitted = binding.toolkits.includes(requested);
|
|
1159
|
-
const toolkit = permitted ? toolkits?.get(requested) : undefined;
|
|
1160
|
-
if (toolkit) return scopeFor(toolkit);
|
|
1161
|
-
logger.warn(
|
|
1162
|
-
`[connecta] refused an /mcp connection from ${who} with 403: it asked ` +
|
|
1163
|
-
`for toolkit ${loggableValue(requested)}, which ` +
|
|
1164
|
-
(permitted
|
|
1165
|
-
? "its binding allows but this deployment does not configure"
|
|
1166
|
-
: "its toolkit binding does not include") +
|
|
1167
|
-
". " +
|
|
1168
|
-
bound +
|
|
1169
|
-
" The client sees a transport-level failure and never the reason, so " +
|
|
1170
|
-
"check the ?toolkit= value in its MCP endpoint URL.",
|
|
1171
|
-
);
|
|
1172
|
-
return { ok: false, response: toolkitForbidden() };
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
if (requested === null) return { ok: true, scope: { registry } };
|
|
1176
|
-
const toolkit = toolkits?.get(requested);
|
|
1177
|
-
if (toolkit) return scopeFor(toolkit);
|
|
1178
|
-
const configured = toolkits && toolkits.size > 0 ? [...toolkits.keys()] : [];
|
|
1179
|
-
logger.warn(
|
|
1180
|
-
"[connecta] rejected an /mcp connection asking for unknown toolkit " +
|
|
1181
|
-
`${loggableValue(requested)} with 404. ` +
|
|
1182
|
-
(configured.length > 0
|
|
1183
|
-
? `Configured toolkits: ${configured.join(", ")}.`
|
|
1184
|
-
: "This deployment configures no toolkits, so no ?toolkit= value is accepted.") +
|
|
1185
|
-
" The client sees a transport-level failure and never the reason, so " +
|
|
1186
|
-
"check the ?toolkit= value in its MCP endpoint URL.",
|
|
1187
|
-
);
|
|
1188
|
-
const label =
|
|
1189
|
-
requested.length <= MAX_ECHOED_TOOLKIT_NAME &&
|
|
1190
|
-
TOOLKIT_NAME_RE.test(requested)
|
|
1191
|
-
? `"${requested}"`
|
|
1192
|
-
: "requested";
|
|
1193
|
-
return {
|
|
1194
|
-
ok: false,
|
|
1195
|
-
response: new Response(
|
|
1196
|
-
JSON.stringify({
|
|
1197
|
-
jsonrpc: "2.0",
|
|
1198
|
-
id: null,
|
|
1199
|
-
error: {
|
|
1200
|
-
code: -32600,
|
|
1201
|
-
message:
|
|
1202
|
-
`Unknown toolkit ${label}. Check the ?toolkit= value in this ` +
|
|
1203
|
-
"deployment's MCP endpoint URL with the operator.",
|
|
1204
|
-
},
|
|
1205
|
-
}),
|
|
1206
|
-
{
|
|
1207
|
-
status: 404,
|
|
1208
|
-
headers: {
|
|
1209
|
-
"Content-Type": "application/json",
|
|
1210
|
-
"Cache-Control": "no-store",
|
|
1211
|
-
},
|
|
1212
|
-
},
|
|
1213
|
-
),
|
|
1214
|
-
};
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
/**
|
|
1218
|
-
* True when this identity is confined to one or more toolkits — bound, without
|
|
1219
|
-
* `unscoped`. Such a credential belongs to a team's agent, not to the operator
|
|
1220
|
-
* running the deployment, so the deployment-wide operator surfaces (`/ui/data`,
|
|
1221
|
-
* `/ui/activity`, the credential API) refuse it: their payloads describe every
|
|
1222
|
-
* connector in the org, which is exactly what the binding exists to withhold.
|
|
1223
|
-
*/
|
|
1224
|
-
function isToolkitRestricted(binding: ToolkitBinding | undefined): boolean {
|
|
1225
|
-
return Boolean(binding && !binding.unscoped);
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
/** The refusal the deployment-wide operator surfaces give a bound identity. */
|
|
1229
|
-
function restrictedOperatorSurface(): Response {
|
|
1230
|
-
return privateJson(
|
|
1231
|
-
{
|
|
1232
|
-
error:
|
|
1233
|
-
"this credential is bound to a toolkit and may not read " +
|
|
1234
|
-
"deployment-wide operator data",
|
|
1235
|
-
},
|
|
1236
|
-
{ status: 403 },
|
|
1237
|
-
);
|
|
1238
|
-
}
|
|
1239
|
-
|
|
1240
|
-
async function serveMcp(
|
|
1241
|
-
request: Request,
|
|
1242
|
-
opts: ServerOptions,
|
|
1243
|
-
baseUrl: string,
|
|
1244
|
-
actor: ActivityActor,
|
|
1245
|
-
scope: McpScope,
|
|
1246
|
-
runtimeContext?: RuntimeExecutionContext,
|
|
1247
|
-
): Promise<Response> {
|
|
1248
|
-
// Fresh McpServer + transport per request (SDK ≥1.26 requirement), stateless.
|
|
1249
|
-
const server = new McpServer(opts.serverInfo, {
|
|
1250
|
-
instructions: CONNECTA_INSTRUCTIONS,
|
|
1251
|
-
});
|
|
1252
|
-
const activity: ActivityRequestContext | undefined = opts.activity
|
|
1253
|
-
? {
|
|
1254
|
-
sink: opts.activity,
|
|
1255
|
-
actor,
|
|
1256
|
-
requestId: crypto.randomUUID(),
|
|
1257
|
-
serverInfo: opts.serverInfo,
|
|
1258
|
-
...(opts.activityDeploymentId
|
|
1259
|
-
? { deploymentId: opts.activityDeploymentId }
|
|
1260
|
-
: {}),
|
|
1261
|
-
...(scope.toolkitId ? { toolkitId: scope.toolkitId } : {}),
|
|
1262
|
-
...(runtimeContext?.waitUntil
|
|
1263
|
-
? { defer: runtimeContext.waitUntil.bind(runtimeContext) }
|
|
1264
|
-
: {}),
|
|
1265
|
-
logger: opts.logger,
|
|
1266
|
-
}
|
|
1267
|
-
: undefined;
|
|
1268
|
-
// `scope.registry` is the connection's VIEW — the full registry, or one
|
|
1269
|
-
// toolkit's ScopedRegistry. Nothing below may reach for `opts.registry`.
|
|
1270
|
-
const registry = scope.registry;
|
|
1271
|
-
registerMetaTools(server, registry, {
|
|
1272
|
-
baseUrl,
|
|
1273
|
-
activity,
|
|
1274
|
-
defaultToolTimeoutMs: opts.defaultToolTimeoutMs,
|
|
1275
|
-
probeTimeoutMs: opts.probeTimeoutMs,
|
|
1276
|
-
requestSignal: request.signal,
|
|
1277
|
-
...(runtimeContext
|
|
1278
|
-
? { defer: runtimeContext.waitUntil.bind(runtimeContext) }
|
|
1279
|
-
: {}),
|
|
1280
|
-
});
|
|
1281
|
-
if (opts.executor) {
|
|
1282
|
-
registerExecuteTool(server, registry, {
|
|
1283
|
-
baseUrl,
|
|
1284
|
-
executor: opts.executor,
|
|
1285
|
-
logger: opts.logger,
|
|
1286
|
-
activity,
|
|
1287
|
-
requestSignal: request.signal,
|
|
1288
|
-
});
|
|
1289
|
-
}
|
|
1290
|
-
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
1291
|
-
enableJsonResponse: true,
|
|
1292
|
-
});
|
|
1293
|
-
await server.connect(transport);
|
|
1294
|
-
return transport.handleRequest(request);
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
/**
|
|
1298
|
-
* Pay the storage read a real downstream-OAuth refusal pays, on the refusal
|
|
1299
|
-
* paths that would otherwise pay nothing.
|
|
1300
|
-
*
|
|
1301
|
-
* Identical bodies do not hide a connector id if the clock still sorts them.
|
|
1302
|
-
* `KvOAuthProvider.verifyState` reads `oauth:state` and its generation before
|
|
1303
|
-
* it can reject a mismatched value, so a configured id costs two storage round
|
|
1304
|
-
* trips on the ordinary path while an id naming nothing used to touch no I/O.
|
|
1305
|
-
* That gap is an oracle: sample the two and a wordlist recovers the connector
|
|
1306
|
-
* list the flat 400 was meant to withhold. So zero-I/O refusals read the same
|
|
1307
|
-
* keys in the same `conn:<id>:` namespace, where an unconfigured id gets misses.
|
|
1308
|
-
*
|
|
1309
|
-
* This is deliberately *not* a constant-time claim, and docs/connectors.md says
|
|
1310
|
-
* so in prose: a hit and a miss are not identical in a KV store, and a connector
|
|
1311
|
-
* shipping its own `verifyState` may do more or less work. What it
|
|
1312
|
-
* removes is the order-of-magnitude "no I/O versus a round trip" difference,
|
|
1313
|
-
* which is the only part of the signal that makes enumeration cheap.
|
|
1314
|
-
*
|
|
1315
|
-
* A throwing read is swallowed: the refusal is the answer either way, and
|
|
1316
|
-
* turning it into a 500 would hand back exactly the distinguishable response
|
|
1317
|
-
* this whole path exists to deny.
|
|
1318
|
-
*/
|
|
1319
|
-
async function equalizeRefusalCost(context: ConnectorContext): Promise<void> {
|
|
1320
|
-
try {
|
|
1321
|
-
const generation = await context.storage.get("oauth:generation");
|
|
1322
|
-
await context.storage.get(
|
|
1323
|
-
oauthValueStorageKey("oauth:state", generation),
|
|
1324
|
-
);
|
|
1325
|
-
} catch {
|
|
1326
|
-
// Deliberately ignored — see above.
|
|
1327
|
-
}
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
|
-
async function handleOAuthCallback(
|
|
1331
|
-
url: URL,
|
|
1332
|
-
registry: Registry,
|
|
1333
|
-
baseUrl: string,
|
|
1334
|
-
logger: Logger,
|
|
1335
|
-
branding?: ConnectaBranding,
|
|
1336
|
-
): Promise<Response> {
|
|
1337
|
-
const error = url.searchParams.get("error");
|
|
1338
|
-
if (error) return html(`Authorization denied: ${error}`, 400, branding);
|
|
1339
|
-
const code = url.searchParams.get("code");
|
|
1340
|
-
if (!code) return html("Missing authorization code.", 400, branding);
|
|
1341
|
-
const id = url.pathname.slice("/oauth/callback/".length);
|
|
1342
|
-
const connector = registry.getConnector(id);
|
|
1343
|
-
// Safe to build before we know the id names anything: `contextFor` is a pure
|
|
1344
|
-
// constructor — a namespaced storage view over `conn:<id>:` and, only for a
|
|
1345
|
-
// connector that declares one, a lazy credential accessor. It neither throws
|
|
1346
|
-
// nor touches storage for an unknown id, which is what lets the refusals
|
|
1347
|
-
// below borrow it to equalize their cost.
|
|
1348
|
-
const context = registry.contextFor(id, baseUrl);
|
|
1349
|
-
const refused = () =>
|
|
1350
|
-
html(
|
|
1351
|
-
"Authorization could not be completed. Re-run authorization from " +
|
|
1352
|
-
"connecta and try again.",
|
|
1353
|
-
400,
|
|
1354
|
-
branding,
|
|
1355
|
-
);
|
|
1356
|
-
if (!connector || !connector.finishAuth) {
|
|
1357
|
-
await equalizeRefusalCost(context);
|
|
1358
|
-
return refused();
|
|
1359
|
-
}
|
|
1360
|
-
// CSRF / login-fixation guard: this route is intentionally public, so verify
|
|
1361
|
-
// the `state` matches the flow connecta started BEFORE exchanging the code.
|
|
1362
|
-
if (!connector.verifyState) {
|
|
1363
|
-
await equalizeRefusalCost(context);
|
|
1364
|
-
logger.warn(
|
|
1365
|
-
`[connecta] refused an OAuth callback for connector ` +
|
|
1366
|
-
`${loggableValue(id)} with 400: it implements finishAuth but no ` +
|
|
1367
|
-
"verifyState, so connecta cannot establish that it started this flow. " +
|
|
1368
|
-
"No authorization code was exchanged. Implement verifyState before " +
|
|
1369
|
-
"trying again.",
|
|
1370
|
-
);
|
|
1371
|
-
return refused();
|
|
1372
|
-
}
|
|
1373
|
-
const state = url.searchParams.get("state");
|
|
1374
|
-
let stateMatches: boolean;
|
|
1375
|
-
try {
|
|
1376
|
-
stateMatches = await connector.verifyState(state, context);
|
|
1377
|
-
} catch (err) {
|
|
1378
|
-
logger.warn(
|
|
1379
|
-
`[connecta] refused an OAuth callback for connector ` +
|
|
1380
|
-
`${loggableValue(id)} with 400: verifyState threw ` +
|
|
1381
|
-
`${loggableValue(msg(err))}. No authorization code was exchanged. ` +
|
|
1382
|
-
"Re-run authorization from connecta and check the verifier if it " +
|
|
1383
|
-
"fails again.",
|
|
1384
|
-
);
|
|
1385
|
-
return refused();
|
|
1386
|
-
}
|
|
1387
|
-
if (!stateMatches) {
|
|
1388
|
-
logger.warn(
|
|
1389
|
-
`[connecta] refused an OAuth callback for connector ` +
|
|
1390
|
-
`${loggableValue(id)} with 400: ` +
|
|
1391
|
-
(state === null
|
|
1392
|
-
? "the state parameter was missing"
|
|
1393
|
-
: "the state did not match the pending authorization flow") +
|
|
1394
|
-
". No authorization code was exchanged. Re-run authorization from " +
|
|
1395
|
-
"connecta and try again.",
|
|
1396
|
-
);
|
|
1397
|
-
return refused();
|
|
1398
|
-
}
|
|
1399
|
-
try {
|
|
1400
|
-
await connector.finishAuth(code, context);
|
|
1401
|
-
await registry.invalidateStored(id);
|
|
1402
|
-
// Recovery, without a restart: the grant this connector was reported dead
|
|
1403
|
-
// for has just been replaced, so drop the verdict rather than let a stale
|
|
1404
|
-
// `auth_required` survive until the next scheduled check.
|
|
1405
|
-
await registry.clearCredentialHealth(id);
|
|
1406
|
-
return html(
|
|
1407
|
-
`Connected "${id}". You can close this window.`,
|
|
1408
|
-
200,
|
|
1409
|
-
branding,
|
|
1410
|
-
);
|
|
1411
|
-
} catch (err) {
|
|
1412
|
-
return html(`Authorization failed: ${msg(err)}`, 500, branding);
|
|
1413
|
-
}
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
/** Build the Web-standard fetch handler that serves connecta. */
|
|
1417
26
|
export function createFetchHandler(
|
|
1418
27
|
opts: ServerOptions,
|
|
1419
28
|
): (
|
|
1420
29
|
request: Request,
|
|
1421
30
|
runtimeContext?: RuntimeExecutionContext,
|
|
1422
31
|
) => Promise<Response> {
|
|
1423
|
-
const { registry, auth, publicUrl
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
const warnAdmissionRejected = (error: ExecutorAdmissionError): void => {
|
|
1427
|
-
const now = Date.now();
|
|
1428
|
-
if (now - lastAdmissionWarningAt < 1_000) {
|
|
1429
|
-
suppressedAdmissionWarnings++;
|
|
1430
|
-
return;
|
|
1431
|
-
}
|
|
1432
|
-
opts.logger.warn("[connecta] MCP request admission rejected", {
|
|
1433
|
-
retryAfterMs: error.retryAfterMs,
|
|
1434
|
-
active: opts.requestAdmission.activeCount,
|
|
1435
|
-
queued: opts.requestAdmission.queuedCount,
|
|
1436
|
-
suppressedSinceLastWarning: suppressedAdmissionWarnings,
|
|
1437
|
-
});
|
|
1438
|
-
lastAdmissionWarningAt = now;
|
|
1439
|
-
suppressedAdmissionWarnings = 0;
|
|
1440
|
-
};
|
|
32
|
+
const { registry, auth, publicUrl } = opts;
|
|
33
|
+
const routeMcp = createMcpRoute(opts);
|
|
34
|
+
|
|
1441
35
|
return async function fetch(
|
|
1442
36
|
request: Request,
|
|
1443
37
|
runtimeContext?: RuntimeExecutionContext,
|
|
@@ -1477,6 +71,7 @@ export function createFetchHandler(
|
|
|
1477
71
|
opts.logger.warn("[connecta] credential health sweep failed", err);
|
|
1478
72
|
}
|
|
1479
73
|
};
|
|
74
|
+
|
|
1480
75
|
// Container and orchestrator probes reach /health over plain HTTP on
|
|
1481
76
|
// loopback, where no proxy has set X-Forwarded-Proto. Redirecting them to
|
|
1482
77
|
// the public origin would make an internal liveness check depend on
|
|
@@ -1489,16 +84,15 @@ export function createFetchHandler(
|
|
|
1489
84
|
new URL(publicUrl).protocol === "https:" &&
|
|
1490
85
|
url.protocol === "http:"
|
|
1491
86
|
) {
|
|
1492
|
-
// Canonicalize the legacy bookmark while upgrading it so an old /ui URL
|
|
1493
|
-
// reaches the new Connections entry point in one permanent redirect.
|
|
1494
|
-
const targetPath = path === "/ui" ? "/" : url.pathname;
|
|
1495
87
|
// Assign the path and query onto the configured URL instead of resolving
|
|
1496
88
|
// attacker-controlled text against it. A pathname beginning with `//`
|
|
1497
89
|
// (including a backslash form normalized by URL parsing) is an authority
|
|
1498
90
|
// when passed to `new URL(value, base)` and would otherwise replace the
|
|
1499
|
-
// deployment host.
|
|
91
|
+
// deployment host. `/ui` is canonicalized while upgrading it so an old
|
|
92
|
+
// bookmark reaches the new Connections entry point in one permanent
|
|
93
|
+
// redirect.
|
|
1500
94
|
const target = new URL(publicUrl);
|
|
1501
|
-
target.pathname =
|
|
95
|
+
target.pathname = path === "/ui" ? "/" : url.pathname;
|
|
1502
96
|
target.search = url.search;
|
|
1503
97
|
target.hash = "";
|
|
1504
98
|
return withSecurityHeaders(
|
|
@@ -1511,53 +105,43 @@ export function createFetchHandler(
|
|
|
1511
105
|
);
|
|
1512
106
|
}
|
|
1513
107
|
|
|
108
|
+
const context: RouteContext = {
|
|
109
|
+
request,
|
|
110
|
+
url,
|
|
111
|
+
path,
|
|
112
|
+
baseUrl,
|
|
113
|
+
opts,
|
|
114
|
+
defer,
|
|
115
|
+
runtimeContext,
|
|
116
|
+
sweepCredentials,
|
|
117
|
+
};
|
|
118
|
+
|
|
1514
119
|
const route = async (): Promise<Response> => {
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
if (
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
1522
|
-
}
|
|
1523
|
-
return handleCredentialRequest(
|
|
1524
|
-
request,
|
|
1525
|
-
credentialMatch[1],
|
|
1526
|
-
credentialMatch[2],
|
|
1527
|
-
opts,
|
|
1528
|
-
baseUrl,
|
|
1529
|
-
);
|
|
1530
|
-
}
|
|
1531
|
-
const oauthManagementMatch =
|
|
1532
|
-
/^\/ui\/oauth\/([a-z0-9_-]+)$/.exec(path);
|
|
1533
|
-
if (oauthManagementMatch) {
|
|
1534
|
-
if (request.method === "OPTIONS") {
|
|
1535
|
-
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
1536
|
-
}
|
|
1537
|
-
return handleOAuthManagementRequest(
|
|
1538
|
-
request,
|
|
1539
|
-
oauthManagementMatch[1],
|
|
1540
|
-
opts,
|
|
1541
|
-
baseUrl,
|
|
1542
|
-
defer,
|
|
1543
|
-
);
|
|
1544
|
-
}
|
|
120
|
+
// Private mutations own OPTIONS so they never inherit wildcard CORS.
|
|
121
|
+
const credentials = await routeCredentials(context);
|
|
122
|
+
if (credentials) return credentials;
|
|
123
|
+
|
|
124
|
+
const oauthManagement = await routeOAuthManagement(context);
|
|
125
|
+
if (oauthManagement) return oauthManagement;
|
|
1545
126
|
|
|
1546
127
|
if (request.method === "OPTIONS") {
|
|
1547
|
-
for (const
|
|
1548
|
-
if (
|
|
1549
|
-
const
|
|
1550
|
-
if (
|
|
128
|
+
for (const provider of auth) {
|
|
129
|
+
if (provider.handleMetadata) {
|
|
130
|
+
const response = await provider.handleMetadata(request, baseUrl);
|
|
131
|
+
if (response) return response;
|
|
1551
132
|
}
|
|
1552
133
|
}
|
|
1553
|
-
return new Response(null, {
|
|
134
|
+
return new Response(null, {
|
|
135
|
+
status: 204,
|
|
136
|
+
headers: MCP_CORS_HEADERS,
|
|
137
|
+
});
|
|
1554
138
|
}
|
|
1555
139
|
|
|
1556
140
|
if (path.startsWith("/.well-known/")) {
|
|
1557
|
-
for (const
|
|
1558
|
-
if (
|
|
1559
|
-
const
|
|
1560
|
-
if (
|
|
141
|
+
for (const provider of auth) {
|
|
142
|
+
if (provider.handleMetadata) {
|
|
143
|
+
const response = await provider.handleMetadata(request, baseUrl);
|
|
144
|
+
if (response) return response;
|
|
1561
145
|
}
|
|
1562
146
|
}
|
|
1563
147
|
return new Response("Not Found", { status: 404 });
|
|
@@ -1595,235 +179,17 @@ export function createFetchHandler(
|
|
|
1595
179
|
});
|
|
1596
180
|
}
|
|
1597
181
|
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
url,
|
|
1601
|
-
registry,
|
|
1602
|
-
baseUrl,
|
|
1603
|
-
opts.logger,
|
|
1604
|
-
opts.branding,
|
|
1605
|
-
);
|
|
1606
|
-
}
|
|
1607
|
-
|
|
1608
|
-
if (request.method === "GET" && path === "/favicon.svg") {
|
|
1609
|
-
return new Response(opts.branding?.favicon?.svg ?? CONNECTA_FAVICON_SVG, {
|
|
1610
|
-
headers: {
|
|
1611
|
-
"Content-Type": "image/svg+xml",
|
|
1612
|
-
"Cache-Control": "public, max-age=86400",
|
|
1613
|
-
...INERT_ICON_HEADERS,
|
|
1614
|
-
},
|
|
1615
|
-
});
|
|
1616
|
-
}
|
|
1617
|
-
|
|
1618
|
-
if (request.method === "GET" && path === "/favicon.ico") {
|
|
1619
|
-
return new Response(opts.branding?.favicon?.ico ?? CONNECTA_FAVICON_ICO, {
|
|
1620
|
-
headers: {
|
|
1621
|
-
"Content-Type": "image/x-icon",
|
|
1622
|
-
"Cache-Control": "public, max-age=86400",
|
|
1623
|
-
...INERT_ICON_HEADERS,
|
|
1624
|
-
},
|
|
1625
|
-
});
|
|
1626
|
-
}
|
|
1627
|
-
|
|
1628
|
-
if (path === "/ui") {
|
|
1629
|
-
if (request.method !== "GET") {
|
|
1630
|
-
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
1631
|
-
}
|
|
1632
|
-
const target = new URL(`/${url.search}`, baseUrl);
|
|
1633
|
-
return new Response(null, {
|
|
1634
|
-
status: 308,
|
|
1635
|
-
headers: { Location: target.toString() },
|
|
1636
|
-
});
|
|
1637
|
-
}
|
|
1638
|
-
|
|
1639
|
-
const operatorPage = operatorPageForPath(path);
|
|
1640
|
-
if (operatorPage) {
|
|
1641
|
-
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
1642
|
-
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
1643
|
-
}
|
|
1644
|
-
// Open shell — carries no operator data; everything comes from the
|
|
1645
|
-
// authenticated /ui/* APIs after the browser establishes a session.
|
|
1646
|
-
const uiAuth = auth.find((provider) => provider.uiAuth)?.uiAuth;
|
|
1647
|
-
const mcpUrl = new URL("/mcp", baseUrl).toString();
|
|
1648
|
-
// Nonce the page's inline script (and the Clerk loader). 'strict-dynamic'
|
|
1649
|
-
// lets scripts the nonced Clerk loader injects at runtime execute; the
|
|
1650
|
-
// https:/'unsafe-inline' fallbacks are ignored by CSP3 browsers that
|
|
1651
|
-
// honour the nonce and only cover legacy ones. No default-src, so Clerk's
|
|
1652
|
-
// style/font/network needs and the page's inline <style> stay unrestricted
|
|
1653
|
-
// — only script execution, the XSS sink, is gated.
|
|
1654
|
-
const nonce = uiScriptNonce();
|
|
1655
|
-
return new Response(
|
|
1656
|
-
request.method === "HEAD"
|
|
1657
|
-
? null
|
|
1658
|
-
: renderUiHtml(uiAuth, mcpUrl, opts.branding, nonce, operatorPage),
|
|
1659
|
-
{
|
|
1660
|
-
status: 200,
|
|
1661
|
-
headers: {
|
|
1662
|
-
"Content-Type": "text/html; charset=utf-8",
|
|
1663
|
-
"Content-Security-Policy":
|
|
1664
|
-
`script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'; ` +
|
|
1665
|
-
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
|
1666
|
-
"X-Content-Type-Options": "nosniff",
|
|
1667
|
-
},
|
|
1668
|
-
},
|
|
1669
|
-
);
|
|
1670
|
-
}
|
|
182
|
+
const oauthCallback = await routeOAuthCallback(context);
|
|
183
|
+
if (oauthCallback) return oauthCallback;
|
|
1671
184
|
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
if (!authz.ok) return authz.response;
|
|
1675
|
-
if (isToolkitRestricted(authz.toolkitBinding)) {
|
|
1676
|
-
return restrictedOperatorSurface();
|
|
1677
|
-
}
|
|
1678
|
-
// After the restriction check, not before: an identity that may not
|
|
1679
|
-
// read this surface should not get to trigger background work from it.
|
|
1680
|
-
sweepCredentials();
|
|
1681
|
-
const eligibleClerkOperator = authz.uiAdminEligible === true;
|
|
1682
|
-
const credentialManagement = credentialManagementCapability({
|
|
1683
|
-
eligibleClerkOperator,
|
|
1684
|
-
hasCredentialSlots: registry
|
|
1685
|
-
.listConnectors()
|
|
1686
|
-
.some((connector) => Boolean(connector.credential)),
|
|
1687
|
-
hasCredentialVault: Boolean(opts.credentialVault),
|
|
1688
|
-
});
|
|
1689
|
-
const data = await buildUiData(
|
|
1690
|
-
registry,
|
|
1691
|
-
baseUrl,
|
|
1692
|
-
serverInfo,
|
|
1693
|
-
// The static headless bearer may read connector health, but only a
|
|
1694
|
-
// Clerk-authenticated operator receives credential metadata.
|
|
1695
|
-
eligibleClerkOperator ? opts.credentialVault : undefined,
|
|
1696
|
-
Boolean(opts.activity?.list),
|
|
1697
|
-
credentialManagement,
|
|
1698
|
-
opts.toolkits,
|
|
1699
|
-
defer,
|
|
1700
|
-
eligibleClerkOperator,
|
|
1701
|
-
);
|
|
1702
|
-
return privateJson(data);
|
|
1703
|
-
}
|
|
185
|
+
const ui = await routeUi(context);
|
|
186
|
+
if (ui) return ui;
|
|
1704
187
|
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
1708
|
-
}
|
|
1709
|
-
const authz = await authorize(request, baseUrl, auth, opts.logger);
|
|
1710
|
-
if (!authz.ok) return authz.response;
|
|
1711
|
-
if (isToolkitRestricted(authz.toolkitBinding)) {
|
|
1712
|
-
return restrictedOperatorSurface();
|
|
1713
|
-
}
|
|
1714
|
-
if (
|
|
1715
|
-
opts.activityReadGate &&
|
|
1716
|
-
!(await opts.activityReadGate(authz.actor))
|
|
1717
|
-
) {
|
|
1718
|
-
return privateJson({ error: "forbidden" }, { status: 403 });
|
|
1719
|
-
}
|
|
1720
|
-
if (!opts.activity?.list) {
|
|
1721
|
-
return privateJson(
|
|
1722
|
-
{ error: "activity history is not configured" },
|
|
1723
|
-
{ status: 404 },
|
|
1724
|
-
);
|
|
1725
|
-
}
|
|
1726
|
-
const cursor = url.searchParams.get("cursor") ?? undefined;
|
|
1727
|
-
if (cursor && cursor.length > 500) {
|
|
1728
|
-
return privateJson({ error: "invalid cursor" }, { status: 400 });
|
|
1729
|
-
}
|
|
1730
|
-
const requestedLimit = Number(url.searchParams.get("limit") ?? "50");
|
|
1731
|
-
const limit = Number.isFinite(requestedLimit)
|
|
1732
|
-
? Math.min(100, Math.max(1, Math.trunc(requestedLimit)))
|
|
1733
|
-
: 50;
|
|
1734
|
-
try {
|
|
1735
|
-
const page = await opts.activity.list({ cursor, limit });
|
|
1736
|
-
return privateJson(await enrichActivityActorLabels(page, auth));
|
|
1737
|
-
} catch (error) {
|
|
1738
|
-
if (error instanceof InvalidActivityCursorError) {
|
|
1739
|
-
return privateJson({ error: error.message }, { status: 400 });
|
|
1740
|
-
}
|
|
1741
|
-
opts.logger.error("[connecta] activity read failed", error);
|
|
1742
|
-
return privateJson(
|
|
1743
|
-
{ error: "activity history is temporarily unavailable" },
|
|
1744
|
-
{ status: 503 },
|
|
1745
|
-
);
|
|
1746
|
-
}
|
|
1747
|
-
}
|
|
188
|
+
const activity = await routeActivity(context);
|
|
189
|
+
if (activity) return activity;
|
|
1748
190
|
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
try {
|
|
1752
|
-
admission = await opts.requestAdmission.acquire({
|
|
1753
|
-
signal: request.signal,
|
|
1754
|
-
});
|
|
1755
|
-
if (admission.waitMs > 0) {
|
|
1756
|
-
opts.logger.debug("[connecta] MCP request admitted after queue wait", {
|
|
1757
|
-
waitMs: admission.waitMs,
|
|
1758
|
-
active: opts.requestAdmission.activeCount,
|
|
1759
|
-
queued: opts.requestAdmission.queuedCount,
|
|
1760
|
-
});
|
|
1761
|
-
}
|
|
1762
|
-
} catch (error) {
|
|
1763
|
-
if (
|
|
1764
|
-
error instanceof ExecutorAdmissionError &&
|
|
1765
|
-
error.code === "executor_cancelled"
|
|
1766
|
-
) {
|
|
1767
|
-
throw request.signal.reason ?? error;
|
|
1768
|
-
}
|
|
1769
|
-
if (error instanceof ExecutorAdmissionError) {
|
|
1770
|
-
if (error.code === "executor_overloaded") {
|
|
1771
|
-
warnAdmissionRejected(error);
|
|
1772
|
-
}
|
|
1773
|
-
return withMcpCors(requestAdmissionFailure(error));
|
|
1774
|
-
}
|
|
1775
|
-
throw error;
|
|
1776
|
-
}
|
|
1777
|
-
try {
|
|
1778
|
-
// Authenticate BEFORE resolving ?toolkit=: an unauthenticated caller
|
|
1779
|
-
// must not be able to probe which toolkit names exist.
|
|
1780
|
-
const authz = await authorize(request, baseUrl, auth, opts.logger);
|
|
1781
|
-
if (!authz.ok) {
|
|
1782
|
-
return releaseAdmissionWithResponse(
|
|
1783
|
-
withMcpCors(authz.response),
|
|
1784
|
-
admission,
|
|
1785
|
-
request.signal,
|
|
1786
|
-
);
|
|
1787
|
-
}
|
|
1788
|
-
const selected = resolveToolkitScope(
|
|
1789
|
-
url,
|
|
1790
|
-
registry,
|
|
1791
|
-
opts.toolkits,
|
|
1792
|
-
opts.logger,
|
|
1793
|
-
{
|
|
1794
|
-
actor: authz.actor,
|
|
1795
|
-
...(authz.toolkitBinding
|
|
1796
|
-
? { binding: authz.toolkitBinding }
|
|
1797
|
-
: {}),
|
|
1798
|
-
},
|
|
1799
|
-
);
|
|
1800
|
-
if (!selected.ok) {
|
|
1801
|
-
return releaseAdmissionWithResponse(
|
|
1802
|
-
withMcpCors(selected.response),
|
|
1803
|
-
admission,
|
|
1804
|
-
request.signal,
|
|
1805
|
-
);
|
|
1806
|
-
}
|
|
1807
|
-
sweepCredentials();
|
|
1808
|
-
return releaseAdmissionWithResponse(
|
|
1809
|
-
withMcpCors(
|
|
1810
|
-
await serveMcp(
|
|
1811
|
-
request,
|
|
1812
|
-
opts,
|
|
1813
|
-
baseUrl,
|
|
1814
|
-
authz.actor,
|
|
1815
|
-
selected.scope,
|
|
1816
|
-
runtimeContext,
|
|
1817
|
-
),
|
|
1818
|
-
),
|
|
1819
|
-
admission,
|
|
1820
|
-
request.signal,
|
|
1821
|
-
);
|
|
1822
|
-
} catch (error) {
|
|
1823
|
-
admission.release();
|
|
1824
|
-
throw error;
|
|
1825
|
-
}
|
|
1826
|
-
}
|
|
191
|
+
const mcp = await routeMcp(context);
|
|
192
|
+
if (mcp) return mcp;
|
|
1827
193
|
|
|
1828
194
|
// Connector-owned public routes, dispatched last: a connector can add a
|
|
1829
195
|
// route but never shadow one of connecta's own. A throw here is the
|
|
@@ -1832,11 +198,11 @@ export function createFetchHandler(
|
|
|
1832
198
|
for (const connector of registry.listConnectors()) {
|
|
1833
199
|
if (!connector.handleRequest) continue;
|
|
1834
200
|
try {
|
|
1835
|
-
const
|
|
201
|
+
const response = await connector.handleRequest(
|
|
1836
202
|
request,
|
|
1837
203
|
registry.contextFor(connector.id, baseUrl),
|
|
1838
204
|
);
|
|
1839
|
-
if (
|
|
205
|
+
if (response) return response;
|
|
1840
206
|
} catch (error) {
|
|
1841
207
|
opts.logger.error(
|
|
1842
208
|
`[connecta] connector "${connector.id}" handleRequest failed`,
|
|
@@ -1848,10 +214,7 @@ export function createFetchHandler(
|
|
|
1848
214
|
|
|
1849
215
|
return new Response("Not Found", { status: 404 });
|
|
1850
216
|
};
|
|
217
|
+
|
|
1851
218
|
return withSecurityHeaders(await route(), url, path);
|
|
1852
219
|
};
|
|
1853
220
|
}
|
|
1854
|
-
|
|
1855
|
-
interface RuntimeExecutionContext {
|
|
1856
|
-
waitUntil(promise: Promise<unknown>): void;
|
|
1857
|
-
}
|