@synapsor/runner 1.5.3 → 1.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -2
- package/README.md +20 -12
- package/dist/cli.d.ts.map +1 -1
- package/dist/runner.mjs +2332 -501
- package/dist/runtime.mjs +788 -148
- package/dist/shadow.mjs +101 -1
- package/docs/capability-authoring.md +6 -1
- package/docs/cloud-mode.md +18 -0
- package/docs/database-enforced-scope.md +8 -0
- package/docs/dsl-reference.md +45 -4
- package/docs/getting-started-own-database.md +6 -3
- package/docs/guarded-crud-writeback.md +10 -1
- package/docs/http-mcp.md +222 -207
- package/docs/limitations.md +9 -2
- package/docs/local-mode.md +5 -2
- package/docs/mcp-client-setup.md +24 -11
- package/docs/mcp-clients.md +10 -4
- package/docs/openai-agents-sdk.md +16 -2
- package/docs/production.md +72 -7
- package/docs/release-notes.md +44 -3
- package/docs/runner-bundles.md +7 -2
- package/docs/runner-config-reference.md +96 -6
- package/docs/running-a-runner-fleet.md +42 -13
- package/docs/security-boundary.md +38 -3
- package/docs/store-lifecycle.md +93 -5
- package/examples/openai-agents-http/README.md +10 -4
- package/examples/openai-agents-http/agent.py +2 -2
- package/examples/runner-fleet/Dockerfile +7 -2
- package/examples/runner-fleet/README.md +11 -7
- package/examples/runner-fleet/docker-compose.yml +4 -4
- package/examples/runner-fleet/mint-dev-token.mjs +1 -1
- package/examples/runner-fleet/start-tls-runner.sh +21 -0
- package/examples/runner-fleet/synapsor.runner.json +27 -1
- package/examples/support-plan-credit/README.md +6 -1
- package/examples/support-plan-credit/mcp-client-examples/generic-streamable-http.json +4 -1
- package/examples/support-plan-credit/mcp-client-examples/openai-agents-streamable-http.ts +4 -0
- package/examples/support-plan-credit/synapsor.contract.json +0 -3
- package/package.json +1 -1
- package/schemas/synapsor.runner.schema.json +80 -0
package/dist/runtime.mjs
CHANGED
|
@@ -11,9 +11,11 @@ import fs2 from "node:fs";
|
|
|
11
11
|
import { createServer } from "node:http";
|
|
12
12
|
import { createServer as createHttpsServer } from "node:https";
|
|
13
13
|
import path2 from "node:path";
|
|
14
|
+
import { createSecureContext } from "node:tls";
|
|
14
15
|
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
15
16
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
16
17
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
18
|
+
import { OAuthProtectedResourceMetadataSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
17
19
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
18
20
|
|
|
19
21
|
// node_modules/.pnpm/@modelcontextprotocol+ext-apps@1.7.4_@modelcontextprotocol+sdk@1.29.0_zod@3.25.76__zod@3.25.76/node_modules/@modelcontextprotocol/ext-apps/dist/src/server/index.js
|
|
@@ -118,7 +120,7 @@ function N3(Z, $, J, X, V) {
|
|
|
118
120
|
}
|
|
119
121
|
|
|
120
122
|
// packages/config/src/index.ts
|
|
121
|
-
var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "mode", "storage", "sources", "trusted_context", "contexts", "executors", "capabilities", "contracts", "policies", "approvals", "operator_identity", "session_auth", "rate_limits", "metrics", "graduated_trust", "cloud", "governance", "strict", "result_format"]);
|
|
123
|
+
var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "mode", "storage", "sources", "trusted_context", "contexts", "executors", "capabilities", "contracts", "policies", "approvals", "operator_identity", "session_auth", "http_security", "rate_limits", "metrics", "graduated_trust", "cloud", "governance", "strict", "result_format"]);
|
|
122
124
|
var STORAGE_KEYS = /* @__PURE__ */ new Set(["sqlite_path", "shared_postgres"]);
|
|
123
125
|
var SHARED_POSTGRES_STORAGE_KEYS = /* @__PURE__ */ new Set(["mode", "url_env", "schema", "lock_timeout_ms", "max_entries"]);
|
|
124
126
|
var APPROVALS_KEYS = /* @__PURE__ */ new Set(["disable_auto_approval"]);
|
|
@@ -180,6 +182,26 @@ var SESSION_AUTH_KEYS = /* @__PURE__ */ new Set([
|
|
|
180
182
|
"fetch_timeout_ms",
|
|
181
183
|
"max_response_bytes"
|
|
182
184
|
]);
|
|
185
|
+
var HTTP_SECURITY_KEYS = /* @__PURE__ */ new Set(["deployment", "channel", "static_token", "oauth_resource", "allowed_origins", "allowed_hosts", "limits"]);
|
|
186
|
+
var HTTP_STATIC_TOKEN_KEYS = /* @__PURE__ */ new Set(["active_env", "previous_env"]);
|
|
187
|
+
var HTTP_OAUTH_RESOURCE_KEYS = /* @__PURE__ */ new Set([
|
|
188
|
+
"resource",
|
|
189
|
+
"authorization_servers",
|
|
190
|
+
"scopes_supported",
|
|
191
|
+
"required_scopes",
|
|
192
|
+
"resource_name",
|
|
193
|
+
"resource_documentation"
|
|
194
|
+
]);
|
|
195
|
+
var HTTP_LIMIT_KEYS = /* @__PURE__ */ new Set([
|
|
196
|
+
"max_request_bytes",
|
|
197
|
+
"max_header_bytes",
|
|
198
|
+
"max_sessions",
|
|
199
|
+
"session_idle_timeout_seconds",
|
|
200
|
+
"request_timeout_ms",
|
|
201
|
+
"headers_timeout_ms",
|
|
202
|
+
"keep_alive_timeout_ms",
|
|
203
|
+
"max_connections"
|
|
204
|
+
]);
|
|
183
205
|
var RATE_LIMITS_KEYS = /* @__PURE__ */ new Set(["enabled", "default", "capabilities"]);
|
|
184
206
|
var RATE_LIMIT_RULE_KEYS = /* @__PURE__ */ new Set(["requests", "window_seconds"]);
|
|
185
207
|
var CLOUD_KEYS = /* @__PURE__ */ new Set(["base_url_env", "runner_token_env", "runner_id", "runner_version", "project_id", "adapter_id", "source_id", "engines", "capabilities", "session"]);
|
|
@@ -337,6 +359,7 @@ function validateRunnerCapabilityConfig(input) {
|
|
|
337
359
|
validatePolicies(input.policies, strict, errors);
|
|
338
360
|
validateOperatorIdentity(input.operator_identity, strict, errors);
|
|
339
361
|
validateSessionAuth(input.session_auth, input.trusted_context, input.contexts, strict, errors);
|
|
362
|
+
validateHttpSecurity(input.http_security, input.session_auth, input.trusted_context, input.contexts, strict, errors, warnings);
|
|
340
363
|
validateRateLimits(input.rate_limits, strict, errors);
|
|
341
364
|
validateMetrics(input.metrics, strict, errors);
|
|
342
365
|
validateGraduatedTrust(input.graduated_trust, strict, errors);
|
|
@@ -347,6 +370,169 @@ function validateRunnerCapabilityConfig(input) {
|
|
|
347
370
|
scanForForbiddenFields(input, "$", errors);
|
|
348
371
|
return { ok: errors.length === 0, errors, warnings };
|
|
349
372
|
}
|
|
373
|
+
function validateHttpSecurity(value, sessionAuth, trustedContext, contexts, strict, errors, warnings) {
|
|
374
|
+
if (value === void 0) return;
|
|
375
|
+
if (!isRecord(value)) {
|
|
376
|
+
errors.push({ path: "$.http_security", code: "HTTP_SECURITY_NOT_OBJECT", message: "http_security must be a Runner deployment configuration object." });
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (strict) checkUnknownKeys(value, HTTP_SECURITY_KEYS, "$.http_security", errors);
|
|
380
|
+
const deployment = value.deployment;
|
|
381
|
+
if (deployment !== void 0 && deployment !== "loopback" && deployment !== "single_tenant" && deployment !== "shared") {
|
|
382
|
+
errors.push({ path: "$.http_security.deployment", code: "INVALID_HTTP_DEPLOYMENT", message: "http_security.deployment must be loopback, single_tenant, or shared." });
|
|
383
|
+
}
|
|
384
|
+
const channel = value.channel;
|
|
385
|
+
if (channel !== void 0 && channel !== "direct_tls" && channel !== "trusted_tls_proxy" && channel !== "insecure_http_break_glass") {
|
|
386
|
+
errors.push({ path: "$.http_security.channel", code: "INVALID_HTTP_CHANNEL", message: "http_security.channel must be direct_tls, trusted_tls_proxy, or insecure_http_break_glass." });
|
|
387
|
+
}
|
|
388
|
+
validateHttpStaticToken(value.static_token, strict, errors);
|
|
389
|
+
validateHttpOauthResource(value.oauth_resource, strict, errors);
|
|
390
|
+
validateExactOrigins(value.allowed_origins, errors);
|
|
391
|
+
validateAllowedHosts(value.allowed_hosts, errors);
|
|
392
|
+
validateHttpLimits(value.limits, strict, errors);
|
|
393
|
+
const contextsUseClaims = isRecord(trustedContext) && trustedContext.provider === "http_claims" || isRecord(contexts) && Object.values(contexts).some((context) => isRecord(context) && context.provider === "http_claims");
|
|
394
|
+
if (deployment === "shared") {
|
|
395
|
+
if (!contextsUseClaims) {
|
|
396
|
+
errors.push({ path: "$.http_security.deployment", code: "SHARED_HTTP_CLAIMS_REQUIRED", message: "shared HTTP deployment requires http_claims trusted context so tenant and principal come from verified per-session identity." });
|
|
397
|
+
}
|
|
398
|
+
if (!isRecord(sessionAuth)) {
|
|
399
|
+
errors.push({ path: "$.session_auth", code: "SHARED_HTTP_SESSION_AUTH_REQUIRED", message: "shared HTTP deployment requires signed session_auth." });
|
|
400
|
+
} else {
|
|
401
|
+
if (!isNonEmptyString(sessionAuth.issuer)) errors.push({ path: "$.session_auth.issuer", code: "SHARED_HTTP_ISSUER_REQUIRED", message: "shared HTTP deployment requires an exact JWT issuer." });
|
|
402
|
+
if (!isNonEmptyString(sessionAuth.audience)) errors.push({ path: "$.session_auth.audience", code: "SHARED_HTTP_AUDIENCE_REQUIRED", message: "shared HTTP deployment requires an exact JWT audience/resource." });
|
|
403
|
+
if (sessionAuth.provider === "jwt_hs256") {
|
|
404
|
+
warnings.push({ path: "$.session_auth.provider", code: "SHARED_HTTP_HS256_WARNING", message: "Shared HTTP uses a symmetric JWT verification key. Prefer jwt_asymmetric with RS256/ES256 and JWKS for production." });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (!isRecord(value.oauth_resource)) {
|
|
408
|
+
errors.push({ path: "$.http_security.oauth_resource", code: "SHARED_HTTP_OAUTH_RESOURCE_REQUIRED", message: "shared HTTP deployment requires RFC 9728 protected-resource metadata for an external authorization server." });
|
|
409
|
+
} else if (isRecord(sessionAuth) && isNonEmptyString(sessionAuth.audience) && value.oauth_resource.resource !== sessionAuth.audience) {
|
|
410
|
+
errors.push({ path: "$.http_security.oauth_resource.resource", code: "HTTP_RESOURCE_AUDIENCE_MISMATCH", message: "oauth_resource.resource must exactly match session_auth.audience so tokens are bound to this Runner resource." });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (deployment !== "shared" && value.oauth_resource !== void 0 && !contextsUseClaims) {
|
|
414
|
+
errors.push({ path: "$.http_security.oauth_resource", code: "HTTP_OAUTH_CLAIMS_REQUIRED", message: "OAuth protected-resource metadata requires signed http_claims session identity." });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function validateHttpStaticToken(value, strict, errors) {
|
|
418
|
+
if (value === void 0) return;
|
|
419
|
+
if (!isRecord(value)) {
|
|
420
|
+
errors.push({ path: "$.http_security.static_token", code: "HTTP_STATIC_TOKEN_NOT_OBJECT", message: "http_security.static_token must contain environment-variable names only." });
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (strict) checkUnknownKeys(value, HTTP_STATIC_TOKEN_KEYS, "$.http_security.static_token", errors);
|
|
424
|
+
if (value.active_env !== void 0 && !isEnvName(value.active_env)) {
|
|
425
|
+
errors.push({ path: "$.http_security.static_token.active_env", code: "INVALID_HTTP_TOKEN_ENV", message: "active_env must name the environment variable containing the opaque endpoint token." });
|
|
426
|
+
}
|
|
427
|
+
if (value.previous_env !== void 0 && !isEnvName(value.previous_env)) {
|
|
428
|
+
errors.push({ path: "$.http_security.static_token.previous_env", code: "INVALID_HTTP_PREVIOUS_TOKEN_ENV", message: "previous_env must name the one previous token accepted during an operator-controlled rotation window." });
|
|
429
|
+
}
|
|
430
|
+
if (value.active_env !== void 0 && value.active_env === value.previous_env) {
|
|
431
|
+
errors.push({ path: "$.http_security.static_token.previous_env", code: "HTTP_TOKEN_ENV_REUSED", message: "active_env and previous_env must be different environment variables." });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function validateHttpOauthResource(value, strict, errors) {
|
|
435
|
+
if (value === void 0) return;
|
|
436
|
+
if (!isRecord(value)) {
|
|
437
|
+
errors.push({ path: "$.http_security.oauth_resource", code: "HTTP_OAUTH_RESOURCE_NOT_OBJECT", message: "oauth_resource must describe this Runner protected resource and its external authorization server." });
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (strict) checkUnknownKeys(value, HTTP_OAUTH_RESOURCE_KEYS, "$.http_security.oauth_resource", errors);
|
|
441
|
+
if (!isHttpsUrl(value.resource)) {
|
|
442
|
+
errors.push({ path: "$.http_security.oauth_resource.resource", code: "INVALID_HTTP_OAUTH_RESOURCE", message: "oauth_resource.resource must be an HTTPS URL for the exact public MCP endpoint." });
|
|
443
|
+
}
|
|
444
|
+
if (!Array.isArray(value.authorization_servers) || value.authorization_servers.length < 1 || value.authorization_servers.length > 8) {
|
|
445
|
+
errors.push({ path: "$.http_security.oauth_resource.authorization_servers", code: "HTTP_AUTHORIZATION_SERVERS_REQUIRED", message: "oauth_resource.authorization_servers must contain 1 through 8 external authorization-server HTTPS issuer URLs." });
|
|
446
|
+
} else {
|
|
447
|
+
value.authorization_servers.forEach((server, index) => {
|
|
448
|
+
if (!isHttpsUrl(server)) errors.push({ path: `$.http_security.oauth_resource.authorization_servers[${index}]`, code: "INVALID_HTTP_AUTHORIZATION_SERVER", message: "authorization server URLs must use HTTPS and contain no credentials, query, or fragment." });
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
for (const key of ["scopes_supported", "required_scopes"]) {
|
|
452
|
+
const scopes = value[key];
|
|
453
|
+
if (scopes !== void 0 && (!Array.isArray(scopes) || scopes.length < 1 || scopes.length > 64 || scopes.some((scope) => !isOAuthScope(scope)))) {
|
|
454
|
+
errors.push({ path: `$.http_security.oauth_resource.${key}`, code: "INVALID_HTTP_OAUTH_SCOPES", message: `${key} must contain 1 through 64 unique, visible OAuth scope strings.` });
|
|
455
|
+
} else if (Array.isArray(scopes) && new Set(scopes).size !== scopes.length) {
|
|
456
|
+
errors.push({ path: `$.http_security.oauth_resource.${key}`, code: "DUPLICATE_HTTP_OAUTH_SCOPE", message: `${key} must not contain duplicate scopes.` });
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (Array.isArray(value.required_scopes) && Array.isArray(value.scopes_supported)) {
|
|
460
|
+
for (const scope of value.required_scopes) {
|
|
461
|
+
if (!value.scopes_supported.includes(scope)) errors.push({ path: "$.http_security.oauth_resource.required_scopes", code: "UNSUPPORTED_HTTP_OAUTH_SCOPE", message: `Required scope ${String(scope)} is not listed in scopes_supported.` });
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (value.resource_name !== void 0 && (!isNonEmptyString(value.resource_name) || value.resource_name.trim().length > 128)) {
|
|
465
|
+
errors.push({ path: "$.http_security.oauth_resource.resource_name", code: "INVALID_HTTP_RESOURCE_NAME", message: "resource_name must be 1 through 128 characters." });
|
|
466
|
+
}
|
|
467
|
+
if (value.resource_documentation !== void 0 && !isHttpsUrl(value.resource_documentation)) {
|
|
468
|
+
errors.push({ path: "$.http_security.oauth_resource.resource_documentation", code: "INVALID_HTTP_RESOURCE_DOCUMENTATION", message: "resource_documentation must be an HTTPS URL without credentials, query, or fragment." });
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
function validateExactOrigins(value, errors) {
|
|
472
|
+
if (value === void 0) return;
|
|
473
|
+
if (!Array.isArray(value) || value.length > 32) {
|
|
474
|
+
errors.push({ path: "$.http_security.allowed_origins", code: "INVALID_HTTP_ALLOWED_ORIGINS", message: "allowed_origins must be an array of at most 32 exact origins." });
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
value.forEach((origin, index) => {
|
|
478
|
+
if (!isExactHttpOrigin(origin)) errors.push({ path: `$.http_security.allowed_origins[${index}]`, code: "INVALID_HTTP_ALLOWED_ORIGIN", message: "Each allowed origin must be an exact HTTP(S) origin; wildcards, credentials, paths, query strings, and fragments are forbidden." });
|
|
479
|
+
});
|
|
480
|
+
if (new Set(value).size !== value.length) errors.push({ path: "$.http_security.allowed_origins", code: "DUPLICATE_HTTP_ALLOWED_ORIGIN", message: "allowed_origins must not contain duplicates." });
|
|
481
|
+
}
|
|
482
|
+
function validateAllowedHosts(value, errors) {
|
|
483
|
+
if (value === void 0) return;
|
|
484
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 32 || value.some((host) => !isAllowedHost(host))) {
|
|
485
|
+
errors.push({ path: "$.http_security.allowed_hosts", code: "INVALID_HTTP_ALLOWED_HOSTS", message: "allowed_hosts must contain 1 through 32 exact host or host:port authorities; wildcards and forwarded-host syntax are forbidden." });
|
|
486
|
+
} else if (new Set(value.map((host) => String(host).toLowerCase())).size !== value.length) {
|
|
487
|
+
errors.push({ path: "$.http_security.allowed_hosts", code: "DUPLICATE_HTTP_ALLOWED_HOST", message: "allowed_hosts must not contain duplicates." });
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
function validateHttpLimits(value, strict, errors) {
|
|
491
|
+
if (value === void 0) return;
|
|
492
|
+
if (!isRecord(value)) {
|
|
493
|
+
errors.push({ path: "$.http_security.limits", code: "HTTP_LIMITS_NOT_OBJECT", message: "http_security.limits must be an object." });
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (strict) checkUnknownKeys(value, HTTP_LIMIT_KEYS, "$.http_security.limits", errors);
|
|
497
|
+
for (const [key, minimum, maximum] of [
|
|
498
|
+
["max_request_bytes", 1024, 16 * 1024 * 1024],
|
|
499
|
+
["max_header_bytes", 4096, 64 * 1024],
|
|
500
|
+
["max_sessions", 1, 1e5],
|
|
501
|
+
["session_idle_timeout_seconds", 10, 86400],
|
|
502
|
+
["request_timeout_ms", 1e3, 3e5],
|
|
503
|
+
["headers_timeout_ms", 1e3, 12e4],
|
|
504
|
+
["keep_alive_timeout_ms", 1e3, 12e4],
|
|
505
|
+
["max_connections", 1, 1e5]
|
|
506
|
+
]) {
|
|
507
|
+
if (value[key] !== void 0 && (!Number.isSafeInteger(value[key]) || Number(value[key]) < minimum || Number(value[key]) > maximum)) {
|
|
508
|
+
errors.push({ path: `$.http_security.limits.${key}`, code: "INVALID_HTTP_LIMIT", message: `${key} must be an integer from ${minimum} through ${maximum}.` });
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
function isHttpsUrl(value) {
|
|
513
|
+
if (typeof value !== "string") return false;
|
|
514
|
+
try {
|
|
515
|
+
const url = new URL(value);
|
|
516
|
+
return url.protocol === "https:" && !url.username && !url.password && !url.search && !url.hash;
|
|
517
|
+
} catch {
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
function isExactHttpOrigin(value) {
|
|
522
|
+
if (typeof value !== "string" || value === "*" || value === "null") return false;
|
|
523
|
+
try {
|
|
524
|
+
const url = new URL(value);
|
|
525
|
+
return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password && url.pathname === "/" && !url.search && !url.hash && url.origin === value;
|
|
526
|
+
} catch {
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
function isAllowedHost(value) {
|
|
531
|
+
return typeof value === "string" && value.length <= 255 && value === value.trim() && !/[\s,/*\\?#]/.test(value) && !value.includes("://") && /^(?:\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9.-]+)(?::\d{1,5})?$/.test(value);
|
|
532
|
+
}
|
|
533
|
+
function isOAuthScope(value) {
|
|
534
|
+
return typeof value === "string" && value.length >= 1 && value.length <= 128 && /^[\x21\x23-\x5b\x5d-\x7e]+$/.test(value);
|
|
535
|
+
}
|
|
350
536
|
function validateGovernance(value, strict, errors) {
|
|
351
537
|
if (value === void 0) return;
|
|
352
538
|
if (!isRecord(value)) {
|
|
@@ -4557,6 +4743,11 @@ var ProposalStore = class {
|
|
|
4557
4743
|
const rows = this.db.prepare(query.sql).all(...query.params);
|
|
4558
4744
|
return rows.map((row) => rowToProposal(row)).filter((proposal) => proposal !== void 0);
|
|
4559
4745
|
}
|
|
4746
|
+
countProposals(filters = {}) {
|
|
4747
|
+
const query = buildProposalCountQuery(filters);
|
|
4748
|
+
const row = this.db.prepare(query.sql).get(...query.params);
|
|
4749
|
+
return isRecord2(row) ? Number(row.count ?? 0) : 0;
|
|
4750
|
+
}
|
|
4560
4751
|
listEvidenceBundles(filters = {}) {
|
|
4561
4752
|
const query = buildEvidenceQuery(filters);
|
|
4562
4753
|
const rows = this.db.prepare(query.sql).all(...query.params);
|
|
@@ -4583,6 +4774,19 @@ var ProposalStore = class {
|
|
|
4583
4774
|
const proposalId = replayId.startsWith(prefix) ? replayId.slice(prefix.length) : replayId;
|
|
4584
4775
|
return this.replay(proposalId);
|
|
4585
4776
|
}
|
|
4777
|
+
getStoredReplay(replayId) {
|
|
4778
|
+
const row = this.db.prepare("SELECT * FROM replay_records WHERE replay_id = ?").get(replayId);
|
|
4779
|
+
return rowToStoredReplay(row);
|
|
4780
|
+
}
|
|
4781
|
+
getStoredReplayForProposal(proposalId) {
|
|
4782
|
+
const row = this.db.prepare(`
|
|
4783
|
+
SELECT * FROM replay_records
|
|
4784
|
+
WHERE proposal_id = ?
|
|
4785
|
+
ORDER BY created_at DESC, replay_id DESC
|
|
4786
|
+
LIMIT 1
|
|
4787
|
+
`).get(proposalId);
|
|
4788
|
+
return rowToStoredReplay(row);
|
|
4789
|
+
}
|
|
4586
4790
|
proposalIdForEvidence(evidenceBundleId) {
|
|
4587
4791
|
const evidence = this.getEvidenceBundle(evidenceBundleId);
|
|
4588
4792
|
if (evidence?.proposal_id) return evidence.proposal_id;
|
|
@@ -4885,6 +5089,19 @@ var ProposalStore = class {
|
|
|
4885
5089
|
});
|
|
4886
5090
|
return job;
|
|
4887
5091
|
}
|
|
5092
|
+
getWritebackJob(writebackJobId) {
|
|
5093
|
+
return rowToWritebackJob(this.db.prepare("SELECT * FROM writeback_jobs WHERE writeback_job_id = ?").get(writebackJobId));
|
|
5094
|
+
}
|
|
5095
|
+
listWritebackJobs(options = {}) {
|
|
5096
|
+
const clauses = [];
|
|
5097
|
+
const values = [];
|
|
5098
|
+
if (options.proposal_id) {
|
|
5099
|
+
clauses.push("proposal_id = ?");
|
|
5100
|
+
values.push(options.proposal_id);
|
|
5101
|
+
}
|
|
5102
|
+
const limit = Math.min(Math.max(options.limit ?? 100, 1), 1e3);
|
|
5103
|
+
return this.db.prepare(`SELECT * FROM writeback_jobs${clauses.length ? ` WHERE ${clauses.join(" AND ")}` : ""} ORDER BY created_at ASC, writeback_job_id ASC LIMIT ?`).all(...values, limit).map(rowToWritebackJob).filter((job) => Boolean(job));
|
|
5104
|
+
}
|
|
4888
5105
|
claimWritebackIntent(jobInput, runnerId) {
|
|
4889
5106
|
const job = parseWritebackJob(jobInput);
|
|
4890
5107
|
const proposal = this.requireProposal(job.proposal_id);
|
|
@@ -6708,6 +6925,19 @@ function inWhere(column, values) {
|
|
|
6708
6925
|
};
|
|
6709
6926
|
}
|
|
6710
6927
|
function buildProposalQuery(filters) {
|
|
6928
|
+
const { clauses, params } = proposalQueryParts(filters);
|
|
6929
|
+
const where = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "";
|
|
6930
|
+
return {
|
|
6931
|
+
sql: `SELECT * FROM proposals${where} ORDER BY created_at DESC, proposal_id DESC${filters.limit ? " LIMIT ?" : ""}`,
|
|
6932
|
+
params: filters.limit ? [...params, filters.limit] : params
|
|
6933
|
+
};
|
|
6934
|
+
}
|
|
6935
|
+
function buildProposalCountQuery(filters) {
|
|
6936
|
+
const { clauses, params } = proposalQueryParts(filters);
|
|
6937
|
+
const where = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "";
|
|
6938
|
+
return { sql: `SELECT COUNT(*) AS count FROM proposals${where}`, params };
|
|
6939
|
+
}
|
|
6940
|
+
function proposalQueryParts(filters) {
|
|
6711
6941
|
const clauses = [];
|
|
6712
6942
|
const params = [];
|
|
6713
6943
|
addEqual(clauses, params, "proposal_id", filters.proposal);
|
|
@@ -6719,7 +6949,7 @@ function buildProposalQuery(filters) {
|
|
|
6719
6949
|
addEqual(clauses, params, "action", filters.capability ?? filters.action);
|
|
6720
6950
|
addObjectFilter(clauses, params, "business_object", "source_table", "object_id", filters.objectType, filters.objectId);
|
|
6721
6951
|
addTimeRange(clauses, params, "created_at", filters.from, filters.to);
|
|
6722
|
-
return
|
|
6952
|
+
return { clauses, params };
|
|
6723
6953
|
}
|
|
6724
6954
|
function buildEvidenceQuery(filters) {
|
|
6725
6955
|
const clauses = [];
|
|
@@ -7301,6 +7531,62 @@ function rowToReceipt(row) {
|
|
|
7301
7531
|
source_table: row.source_table == null ? void 0 : String(row.source_table)
|
|
7302
7532
|
};
|
|
7303
7533
|
}
|
|
7534
|
+
function rowToWritebackJob(row) {
|
|
7535
|
+
if (!isRecord2(row)) return void 0;
|
|
7536
|
+
let payload;
|
|
7537
|
+
try {
|
|
7538
|
+
payload = JSON.parse(String(row.job_json));
|
|
7539
|
+
} catch {
|
|
7540
|
+
throw new ProposalStoreError("WRITEBACK_JOB_CORRUPT", `writeback job ${String(row.writeback_job_id)} payload is not valid JSON`);
|
|
7541
|
+
}
|
|
7542
|
+
if (!isRecord2(payload)) {
|
|
7543
|
+
throw new ProposalStoreError("WRITEBACK_JOB_CORRUPT", `writeback job ${String(row.writeback_job_id)} payload is not an object`);
|
|
7544
|
+
}
|
|
7545
|
+
const handler = payload.schema_version === "synapsor.handler-writeback.v1";
|
|
7546
|
+
let normalizedJob;
|
|
7547
|
+
if (!handler) {
|
|
7548
|
+
try {
|
|
7549
|
+
normalizedJob = parseWritebackJob(payload);
|
|
7550
|
+
} catch {
|
|
7551
|
+
throw new ProposalStoreError("WRITEBACK_JOB_CORRUPT", `writeback job ${String(row.writeback_job_id)} payload is not a supported writeback protocol`);
|
|
7552
|
+
}
|
|
7553
|
+
}
|
|
7554
|
+
const payloadJobId = handler ? payload.writeback_job_id : normalizedJob?.job_id;
|
|
7555
|
+
const payloadProposalId = handler ? payload.proposal_id : normalizedJob?.proposal_id;
|
|
7556
|
+
const payloadProposalHash = handler ? payload.proposal_hash : normalizedJob?.approval_id;
|
|
7557
|
+
if (payloadJobId !== String(row.writeback_job_id) || payloadProposalId !== String(row.proposal_id) || payloadProposalHash !== String(row.proposal_hash)) {
|
|
7558
|
+
throw new ProposalStoreError("WRITEBACK_JOB_CORRUPT", `writeback job ${String(row.writeback_job_id)} index fields do not match its payload`);
|
|
7559
|
+
}
|
|
7560
|
+
return {
|
|
7561
|
+
writeback_job_id: String(row.writeback_job_id),
|
|
7562
|
+
proposal_id: String(row.proposal_id),
|
|
7563
|
+
proposal_hash: String(row.proposal_hash),
|
|
7564
|
+
status: String(row.status),
|
|
7565
|
+
kind: handler ? "app_handler" : "direct_sql",
|
|
7566
|
+
payload,
|
|
7567
|
+
...normalizedJob ? { normalized_job: normalizedJob } : {},
|
|
7568
|
+
created_at: String(row.created_at),
|
|
7569
|
+
updated_at: String(row.updated_at)
|
|
7570
|
+
};
|
|
7571
|
+
}
|
|
7572
|
+
function rowToStoredReplay(row) {
|
|
7573
|
+
if (!isRecord2(row)) return void 0;
|
|
7574
|
+
let payload;
|
|
7575
|
+
try {
|
|
7576
|
+
payload = JSON.parse(String(row.payload_json));
|
|
7577
|
+
} catch {
|
|
7578
|
+
throw new ProposalStoreError("REPLAY_RECORD_CORRUPT", `replay ${String(row.replay_id)} payload is not valid JSON`);
|
|
7579
|
+
}
|
|
7580
|
+
if (!isRecord2(payload) || !isRecord2(payload.proposal)) {
|
|
7581
|
+
throw new ProposalStoreError("REPLAY_RECORD_CORRUPT", `replay ${String(row.replay_id)} payload is not a supported replay record`);
|
|
7582
|
+
}
|
|
7583
|
+
const replayId = String(row.replay_id);
|
|
7584
|
+
const proposalId = String(row.proposal_id);
|
|
7585
|
+
if (payload.replay_id !== replayId || payload.proposal.proposal_id !== proposalId || !Array.isArray(payload.approvals) || !Array.isArray(payload.events) || !Array.isArray(payload.receipts) || !Array.isArray(payload.query_audit) || !Array.isArray(payload.evidence) || typeof payload.generated_at !== "string") {
|
|
7586
|
+
throw new ProposalStoreError("REPLAY_RECORD_CORRUPT", `replay ${replayId} index fields do not match its payload`);
|
|
7587
|
+
}
|
|
7588
|
+
return payload;
|
|
7589
|
+
}
|
|
7304
7590
|
function rowToWritebackIntent(row) {
|
|
7305
7591
|
if (!isRecord2(row)) return void 0;
|
|
7306
7592
|
const status = String(row.status);
|
|
@@ -10839,8 +11125,30 @@ async function boundedJwksFetch(resource, init, maxBytes) {
|
|
|
10839
11125
|
body.set(chunk, offset);
|
|
10840
11126
|
offset += chunk.byteLength;
|
|
10841
11127
|
}
|
|
11128
|
+
if (response.ok) validatePublicJwks(body);
|
|
10842
11129
|
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
10843
11130
|
}
|
|
11131
|
+
function validatePublicJwks(body) {
|
|
11132
|
+
let parsed;
|
|
11133
|
+
try {
|
|
11134
|
+
parsed = JSON.parse(new TextDecoder().decode(body));
|
|
11135
|
+
} catch {
|
|
11136
|
+
throw new Error("JWKS response is not valid JSON");
|
|
11137
|
+
}
|
|
11138
|
+
if (!isRecord4(parsed) || !Array.isArray(parsed.keys) || parsed.keys.length > 128) {
|
|
11139
|
+
throw new Error("JWKS response must contain a bounded keys array");
|
|
11140
|
+
}
|
|
11141
|
+
const privateMembers = /* @__PURE__ */ new Set(["d", "p", "q", "dp", "dq", "qi", "oth", "k"]);
|
|
11142
|
+
for (const key of parsed.keys) {
|
|
11143
|
+
if (!isRecord4(key) || typeof key.kty !== "string") throw new Error("JWKS contains an invalid key");
|
|
11144
|
+
if (key.kty === "oct" || Object.keys(key).some((member) => privateMembers.has(member))) {
|
|
11145
|
+
throw new Error("JWKS must contain public verification keys only");
|
|
11146
|
+
}
|
|
11147
|
+
}
|
|
11148
|
+
}
|
|
11149
|
+
function isRecord4(value) {
|
|
11150
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
11151
|
+
}
|
|
10844
11152
|
function trimmedEnv(env, name) {
|
|
10845
11153
|
const value = env[name]?.trim();
|
|
10846
11154
|
return value || void 0;
|
|
@@ -11383,8 +11691,8 @@ function loadCloudLinkedConnection(config, env = process.env) {
|
|
|
11383
11691
|
} catch (error) {
|
|
11384
11692
|
throw new McpRuntimeError("CLOUD_CONNECTION_INVALID", `Unable to read the reviewed Cloud connection file: ${error instanceof Error ? error.message : String(error)}`);
|
|
11385
11693
|
}
|
|
11386
|
-
const root =
|
|
11387
|
-
const cloud =
|
|
11694
|
+
const root = isRecord5(parsed) ? parsed : {};
|
|
11695
|
+
const cloud = isRecord5(root.cloud) ? root.cloud : void 0;
|
|
11388
11696
|
if (!cloud) throw new McpRuntimeError("CLOUD_CONNECTION_INVALID", "Cloud connection file must contain a cloud object.");
|
|
11389
11697
|
const baseUrlEnv = nonEmptyString(cloud.base_url_env) ?? "SYNAPSOR_CLOUD_BASE_URL";
|
|
11390
11698
|
const runnerTokenEnv = nonEmptyString(cloud.runner_token_env) ?? "SYNAPSOR_RUNNER_TOKEN";
|
|
@@ -11575,13 +11883,13 @@ function cloudSafeChangeSet(changeSet) {
|
|
|
11575
11883
|
}
|
|
11576
11884
|
function stripCloudPrincipalColumn(changeSet, column) {
|
|
11577
11885
|
const strip = (value) => {
|
|
11578
|
-
if (
|
|
11886
|
+
if (isRecord5(value)) delete value[column];
|
|
11579
11887
|
};
|
|
11580
11888
|
strip(changeSet.before);
|
|
11581
11889
|
strip(changeSet.after);
|
|
11582
|
-
if ("frozen_set" in changeSet &&
|
|
11890
|
+
if ("frozen_set" in changeSet && isRecord5(changeSet.frozen_set) && Array.isArray(changeSet.frozen_set.members)) {
|
|
11583
11891
|
for (const member of changeSet.frozen_set.members) {
|
|
11584
|
-
if (!
|
|
11892
|
+
if (!isRecord5(member)) continue;
|
|
11585
11893
|
strip(member.before);
|
|
11586
11894
|
strip(member.after);
|
|
11587
11895
|
}
|
|
@@ -11768,7 +12076,7 @@ var CloudLinkedSynchronizer = class {
|
|
|
11768
12076
|
if (item.kind === "proposal") return this.client.submitProposal(item.payload);
|
|
11769
12077
|
if (item.kind === "activity") return this.client.submitActivity(item.payload);
|
|
11770
12078
|
if (item.kind === "result") {
|
|
11771
|
-
const result =
|
|
12079
|
+
const result = isRecord5(item.payload.result) ? item.payload.result : void 0;
|
|
11772
12080
|
const leaseId = nonEmptyString(item.payload.lease_id);
|
|
11773
12081
|
if (!result || !leaseId) throw new McpRuntimeError("CLOUD_RESULT_OUTBOX_INVALID", "Cloud result outbox entry is missing a result or lease identity.");
|
|
11774
12082
|
return this.client.result(result, leaseId);
|
|
@@ -11777,9 +12085,9 @@ var CloudLinkedSynchronizer = class {
|
|
|
11777
12085
|
}
|
|
11778
12086
|
};
|
|
11779
12087
|
function cloudGovernanceStatusPayload(response) {
|
|
11780
|
-
const decision =
|
|
11781
|
-
const job =
|
|
11782
|
-
const result =
|
|
12088
|
+
const decision = isRecord5(response.decision) ? response.decision : void 0;
|
|
12089
|
+
const job = isRecord5(response.job) ? response.job : void 0;
|
|
12090
|
+
const result = isRecord5(response.result) ? response.result : void 0;
|
|
11783
12091
|
const actor = nonEmptyString(decision?.actor);
|
|
11784
12092
|
return JSON.parse(JSON.stringify({
|
|
11785
12093
|
contract_id: nonEmptyString(response.contract_id),
|
|
@@ -11872,9 +12180,17 @@ function mergeContractIntoRuntimeConfig(config, contract, origin, seenCapabiliti
|
|
|
11872
12180
|
}
|
|
11873
12181
|
}
|
|
11874
12182
|
function runtimeContextFromSpec(context) {
|
|
12183
|
+
const unsupportedSessionBinding = context.bindings.find((binding) => binding.source === "session");
|
|
12184
|
+
if (unsupportedSessionBinding) {
|
|
12185
|
+
throw new Error(
|
|
12186
|
+
`SESSION_BINDING_UNSUPPORTED: context ${context.name} binding ${unsupportedSessionBinding.name} uses canonical SESSION source, but Synapsor Runner has no generic web-session trust provider. Use ENVIRONMENT for local stdio, HTTP_CLAIM for verified HTTP JWT claims, or CLOUD_SESSION for verified Cloud-linked identity.`
|
|
12187
|
+
);
|
|
12188
|
+
}
|
|
11875
12189
|
const tenantBinding = context.bindings.find((binding) => binding.name === context.tenant_binding) ?? context.bindings.find((binding) => binding.name === "tenant_id");
|
|
11876
12190
|
const principalBinding = context.bindings.find((binding) => binding.name === context.principal_binding) ?? context.bindings.find((binding) => binding.name === "principal");
|
|
11877
|
-
const provider = context.bindings.some((binding) => binding.source === "environment") ? "environment" : context.bindings.some((binding) => binding.source === "cloud_session") ? "cloud_session" : context.bindings.some((binding) => binding.source === "http_claim") ? "http_claims" : context.bindings.some((binding) => binding.source === "static_dev") ? "static_dev" :
|
|
12191
|
+
const provider = context.bindings.some((binding) => binding.source === "environment") ? "environment" : context.bindings.some((binding) => binding.source === "cloud_session") ? "cloud_session" : context.bindings.some((binding) => binding.source === "http_claim") ? "http_claims" : context.bindings.some((binding) => binding.source === "static_dev") ? "static_dev" : (() => {
|
|
12192
|
+
throw new Error(`TRUSTED_CONTEXT_BINDING_UNSUPPORTED: context ${context.name} has no binding source supported by Synapsor Runner.`);
|
|
12193
|
+
})();
|
|
11878
12194
|
return {
|
|
11879
12195
|
provider,
|
|
11880
12196
|
tenant_binding: context.tenant_binding,
|
|
@@ -12286,24 +12602,253 @@ async function serveStdio(options = {}) {
|
|
|
12286
12602
|
process.once("SIGTERM", close);
|
|
12287
12603
|
});
|
|
12288
12604
|
}
|
|
12605
|
+
function resolveHttpSecurity(config, options, host, env, usesSessionAuth) {
|
|
12606
|
+
const configured = config.http_security;
|
|
12607
|
+
const loopback = isLoopbackHost(host);
|
|
12608
|
+
if (options.devNoAuth && !loopback) {
|
|
12609
|
+
throw new McpRuntimeError("HTTP_DEV_NO_AUTH_UNSAFE_HOST", "--dev-no-auth is only allowed with localhost or 127.0.0.1.");
|
|
12610
|
+
}
|
|
12611
|
+
const deployment = configured?.deployment ?? (loopback ? "loopback" : void 0);
|
|
12612
|
+
if (!deployment) {
|
|
12613
|
+
throw new McpRuntimeError(
|
|
12614
|
+
"HTTP_REMOTE_DEPLOYMENT_REQUIRED",
|
|
12615
|
+
"A non-loopback listener requires http_security.deployment single_tenant or shared."
|
|
12616
|
+
);
|
|
12617
|
+
}
|
|
12618
|
+
if (!loopback && deployment === "loopback") {
|
|
12619
|
+
throw new McpRuntimeError("HTTP_LOOPBACK_PROFILE_REMOTE", "http_security.deployment loopback cannot bind a non-loopback listener.");
|
|
12620
|
+
}
|
|
12621
|
+
if (deployment === "shared" && !usesSessionAuth) {
|
|
12622
|
+
throw new McpRuntimeError("HTTP_SHARED_SESSION_AUTH_REQUIRED", "Shared HTTP deployment requires signed per-session http_claims identity.");
|
|
12623
|
+
}
|
|
12624
|
+
if (deployment !== "shared" && usesSessionAuth && !loopback) {
|
|
12625
|
+
throw new McpRuntimeError("HTTP_SHARED_DEPLOYMENT_REQUIRED", "Remote http_claims identity requires http_security.deployment shared.");
|
|
12626
|
+
}
|
|
12627
|
+
const optionChannels = [
|
|
12628
|
+
options.trustedTlsProxy ? "trusted_tls_proxy" : void 0,
|
|
12629
|
+
options.unsafeAllowCleartextHttp ? "insecure_http_break_glass" : void 0
|
|
12630
|
+
].filter((value) => Boolean(value));
|
|
12631
|
+
if (optionChannels.length > 1) {
|
|
12632
|
+
throw new McpRuntimeError("HTTP_CHANNEL_CONFLICT", "Choose only one of trusted TLS proxy or unsafe cleartext break-glass mode.");
|
|
12633
|
+
}
|
|
12634
|
+
const requestedChannel = optionChannels[0] ?? configured?.channel;
|
|
12635
|
+
if (options.tls && requestedChannel && requestedChannel !== "direct_tls") {
|
|
12636
|
+
throw new McpRuntimeError("HTTP_CHANNEL_CONFLICT", "Runner-owned TLS cannot be combined with trusted-proxy or insecure-cleartext channel declarations.");
|
|
12637
|
+
}
|
|
12638
|
+
let channel;
|
|
12639
|
+
if (options.tls) channel = "direct_tls";
|
|
12640
|
+
else if (requestedChannel === "direct_tls") {
|
|
12641
|
+
throw new McpRuntimeError("HTTP_TLS_MATERIAL_REQUIRED", "http_security.channel direct_tls requires Runner TLS certificate and key material.");
|
|
12642
|
+
} else if (requestedChannel === "trusted_tls_proxy") channel = "trusted_tls_proxy";
|
|
12643
|
+
else if (requestedChannel === "insecure_http_break_glass") channel = "insecure_http_break_glass";
|
|
12644
|
+
else if (loopback) channel = "loopback_cleartext";
|
|
12645
|
+
else {
|
|
12646
|
+
throw new McpRuntimeError(
|
|
12647
|
+
"HTTP_REMOTE_CLEARTEXT_REFUSED",
|
|
12648
|
+
"Refusing non-loopback cleartext HTTP. Configure Runner-owned TLS, an explicit trusted TLS proxy, or --unsafe-allow-cleartext-http break glass."
|
|
12649
|
+
);
|
|
12650
|
+
}
|
|
12651
|
+
if (options.devNoAuth && channel === "insecure_http_break_glass") {
|
|
12652
|
+
throw new McpRuntimeError("HTTP_BREAK_GLASS_AUTH_REQUIRED", "Unsafe cleartext break-glass mode never disables authentication.");
|
|
12653
|
+
}
|
|
12654
|
+
if (options.devNoAuth && deployment !== "loopback") {
|
|
12655
|
+
throw new McpRuntimeError("HTTP_DEV_NO_AUTH_PROFILE_INVALID", "--dev-no-auth is valid only for a loopback development deployment.");
|
|
12656
|
+
}
|
|
12657
|
+
const activeTokenEnv = options.authTokenEnv ?? configured?.static_token?.active_env ?? "SYNAPSOR_RUNNER_HTTP_TOKEN";
|
|
12658
|
+
const previousTokenEnv = options.previousAuthTokenEnv ?? configured?.static_token?.previous_env;
|
|
12659
|
+
if (previousTokenEnv && previousTokenEnv === activeTokenEnv) {
|
|
12660
|
+
throw new McpRuntimeError("HTTP_TOKEN_ENV_REUSED", "Active and previous HTTP token environment variables must be different.");
|
|
12661
|
+
}
|
|
12662
|
+
const activeToken = options.devNoAuth || usesSessionAuth ? void 0 : envValue(env, activeTokenEnv);
|
|
12663
|
+
const previousToken = options.devNoAuth || usesSessionAuth || !previousTokenEnv ? void 0 : envValue(env, previousTokenEnv);
|
|
12664
|
+
if (!options.devNoAuth && !usesSessionAuth && !activeToken) {
|
|
12665
|
+
throw new McpRuntimeError("HTTP_AUTH_TOKEN_MISSING", `${activeTokenEnv} is not set. HTTP MCP requires bearer auth by default.`);
|
|
12666
|
+
}
|
|
12667
|
+
if (previousTokenEnv && !usesSessionAuth && !options.devNoAuth && !previousToken) {
|
|
12668
|
+
throw new McpRuntimeError("HTTP_PREVIOUS_AUTH_TOKEN_MISSING", `${previousTokenEnv} is configured for rotation but is not set.`);
|
|
12669
|
+
}
|
|
12670
|
+
if (activeToken && previousToken && constantTimeTokenEquals(activeToken, previousToken)) {
|
|
12671
|
+
throw new McpRuntimeError("HTTP_TOKEN_ROTATION_DUPLICATE", "Active and previous HTTP endpoint tokens must differ.");
|
|
12672
|
+
}
|
|
12673
|
+
const weakStaticToken = Boolean(activeToken && !strongOpaqueToken(activeToken)) || Boolean(previousToken && !strongOpaqueToken(previousToken));
|
|
12674
|
+
if (!loopback && !usesSessionAuth && weakStaticToken) {
|
|
12675
|
+
throw new McpRuntimeError("HTTP_AUTH_TOKEN_WEAK", "Non-loopback static endpoint tokens must contain at least 32 bytes of high-entropy secret material.");
|
|
12676
|
+
}
|
|
12677
|
+
const configuredOrigins = configured?.allowed_origins ?? [];
|
|
12678
|
+
const allowedOrigins = new Set(configuredOrigins);
|
|
12679
|
+
if (options.corsOrigin) {
|
|
12680
|
+
if (!isExactHttpOrigin2(options.corsOrigin)) {
|
|
12681
|
+
throw new McpRuntimeError("HTTP_CORS_ORIGIN_INVALID", "--cors-origin must be one exact HTTP(S) origin; wildcards, paths, credentials, query, and fragments are forbidden.");
|
|
12682
|
+
}
|
|
12683
|
+
allowedOrigins.add(options.corsOrigin);
|
|
12684
|
+
}
|
|
12685
|
+
const allowedHosts = configured?.allowed_hosts?.map((value) => value.toLowerCase()) ?? defaultAllowedHosts(host);
|
|
12686
|
+
if (!loopback && allowedHosts.length === 0) {
|
|
12687
|
+
throw new McpRuntimeError("HTTP_ALLOWED_HOSTS_REQUIRED", "Non-loopback HTTP requires http_security.allowed_hosts with exact public/direct Host authorities.");
|
|
12688
|
+
}
|
|
12689
|
+
const rawLimits = configured?.limits;
|
|
12690
|
+
const limits = {
|
|
12691
|
+
maxRequestBytes: rawLimits?.max_request_bytes ?? 1048576,
|
|
12692
|
+
maxHeaderBytes: rawLimits?.max_header_bytes ?? 16384,
|
|
12693
|
+
maxSessions: rawLimits?.max_sessions ?? 1024,
|
|
12694
|
+
sessionIdleTimeoutMs: (rawLimits?.session_idle_timeout_seconds ?? 900) * 1e3,
|
|
12695
|
+
requestTimeoutMs: rawLimits?.request_timeout_ms ?? 3e4,
|
|
12696
|
+
headersTimeoutMs: rawLimits?.headers_timeout_ms ?? 1e4,
|
|
12697
|
+
keepAliveTimeoutMs: rawLimits?.keep_alive_timeout_ms ?? 5e3,
|
|
12698
|
+
maxConnections: rawLimits?.max_connections ?? 2048
|
|
12699
|
+
};
|
|
12700
|
+
const oauth = configured?.oauth_resource ? resolveOauthResource(configured.oauth_resource) : void 0;
|
|
12701
|
+
if (deployment === "shared") {
|
|
12702
|
+
if (!oauth) throw new McpRuntimeError("HTTP_OAUTH_RESOURCE_REQUIRED", "Shared HTTP deployment requires RFC 9728 protected-resource metadata.");
|
|
12703
|
+
const auth = config.session_auth;
|
|
12704
|
+
if (!auth?.issuer || !auth.audience) {
|
|
12705
|
+
throw new McpRuntimeError("HTTP_JWT_ISSUER_AUDIENCE_REQUIRED", "Shared HTTP deployment requires exact session_auth issuer and audience/resource.");
|
|
12706
|
+
}
|
|
12707
|
+
if (auth.audience !== configured?.oauth_resource?.resource) {
|
|
12708
|
+
throw new McpRuntimeError("HTTP_RESOURCE_AUDIENCE_MISMATCH", "session_auth.audience must exactly match http_security.oauth_resource.resource.");
|
|
12709
|
+
}
|
|
12710
|
+
}
|
|
12711
|
+
return {
|
|
12712
|
+
deployment,
|
|
12713
|
+
channel,
|
|
12714
|
+
activeToken,
|
|
12715
|
+
previousToken,
|
|
12716
|
+
activeTokenEnv,
|
|
12717
|
+
previousTokenEnv,
|
|
12718
|
+
weakStaticToken,
|
|
12719
|
+
allowedOrigins,
|
|
12720
|
+
allowedHosts,
|
|
12721
|
+
limits,
|
|
12722
|
+
oauth
|
|
12723
|
+
};
|
|
12724
|
+
}
|
|
12725
|
+
function resolveOauthResource(input) {
|
|
12726
|
+
const resource = new URL(input.resource);
|
|
12727
|
+
const pathname = resource.pathname === "/" ? "" : resource.pathname.replace(/\/$/, "");
|
|
12728
|
+
const metadataPath = `/.well-known/oauth-protected-resource${pathname}`;
|
|
12729
|
+
const metadataUrl = new URL(metadataPath || "/.well-known/oauth-protected-resource", resource.origin).toString();
|
|
12730
|
+
const metadata = OAuthProtectedResourceMetadataSchema.parse({
|
|
12731
|
+
resource: input.resource,
|
|
12732
|
+
authorization_servers: input.authorization_servers,
|
|
12733
|
+
...input.scopes_supported ? { scopes_supported: input.scopes_supported } : {},
|
|
12734
|
+
bearer_methods_supported: ["header"],
|
|
12735
|
+
...input.resource_name ? { resource_name: input.resource_name } : {},
|
|
12736
|
+
...input.resource_documentation ? { resource_documentation: input.resource_documentation } : {}
|
|
12737
|
+
});
|
|
12738
|
+
return { metadata, metadataUrl, metadataPath, requiredScopes: input.required_scopes ?? [] };
|
|
12739
|
+
}
|
|
12740
|
+
function defaultAllowedHosts(host) {
|
|
12741
|
+
if (!isLoopbackHost(host)) {
|
|
12742
|
+
return host === "0.0.0.0" || host === "::" ? [] : [host.toLowerCase()];
|
|
12743
|
+
}
|
|
12744
|
+
return ["localhost", "127.0.0.1", "[::1]", host.toLowerCase()];
|
|
12745
|
+
}
|
|
12746
|
+
function applyHttpServerLimits(server, limits) {
|
|
12747
|
+
server.requestTimeout = limits.requestTimeoutMs;
|
|
12748
|
+
server.headersTimeout = limits.headersTimeoutMs;
|
|
12749
|
+
server.keepAliveTimeout = limits.keepAliveTimeoutMs;
|
|
12750
|
+
server.maxConnections = limits.maxConnections;
|
|
12751
|
+
}
|
|
12752
|
+
function validateTlsMaterial(tls) {
|
|
12753
|
+
if (!tls) return;
|
|
12754
|
+
try {
|
|
12755
|
+
createSecureContext({ cert: tls.cert, key: tls.key, ca: tls.ca });
|
|
12756
|
+
} catch {
|
|
12757
|
+
throw new McpRuntimeError("HTTP_TLS_MATERIAL_INVALID", "HTTP TLS certificate, private key, or CA material is invalid.");
|
|
12758
|
+
}
|
|
12759
|
+
}
|
|
12760
|
+
function validateHttpRequestSecurity(request, response, security) {
|
|
12761
|
+
if ((request.url?.length ?? 0) > 8192) {
|
|
12762
|
+
writeJson(response, 414, { ok: false, error: "uri_too_long" });
|
|
12763
|
+
return false;
|
|
12764
|
+
}
|
|
12765
|
+
const host = headerValue(request.headers.host);
|
|
12766
|
+
if (!host || !hostAllowed(host, security.allowedHosts)) {
|
|
12767
|
+
writeJson(response, 403, { ok: false, error: "host_forbidden" });
|
|
12768
|
+
return false;
|
|
12769
|
+
}
|
|
12770
|
+
const origin = headerValue(request.headers.origin);
|
|
12771
|
+
if (origin && !security.allowedOrigins.has(origin)) {
|
|
12772
|
+
writeJson(response, 403, { ok: false, error: "origin_forbidden" });
|
|
12773
|
+
return false;
|
|
12774
|
+
}
|
|
12775
|
+
setHttpSecurityHeaders(response);
|
|
12776
|
+
if (origin) setCorsHeaders(response, origin);
|
|
12777
|
+
return true;
|
|
12778
|
+
}
|
|
12779
|
+
function hostAllowed(rawHost, allowedHosts) {
|
|
12780
|
+
const actual = parseHostAuthority(rawHost);
|
|
12781
|
+
if (!actual) return false;
|
|
12782
|
+
return allowedHosts.some((allowed) => {
|
|
12783
|
+
const expected = parseHostAuthority(allowed);
|
|
12784
|
+
if (!expected || expected.hostname !== actual.hostname) return false;
|
|
12785
|
+
return expected.port ? expected.port === actual.port : true;
|
|
12786
|
+
});
|
|
12787
|
+
}
|
|
12788
|
+
function parseHostAuthority(value) {
|
|
12789
|
+
if (!value || value !== value.trim() || /[\s,/?#\\]/.test(value)) return void 0;
|
|
12790
|
+
try {
|
|
12791
|
+
const parsed = new URL(`http://${value}`);
|
|
12792
|
+
if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) return void 0;
|
|
12793
|
+
return { hostname: parsed.hostname.toLowerCase(), port: parsed.port };
|
|
12794
|
+
} catch {
|
|
12795
|
+
return void 0;
|
|
12796
|
+
}
|
|
12797
|
+
}
|
|
12798
|
+
function isExactHttpOrigin2(value) {
|
|
12799
|
+
if (value === "*" || value === "null") return false;
|
|
12800
|
+
try {
|
|
12801
|
+
const origin = new URL(value);
|
|
12802
|
+
return (origin.protocol === "http:" || origin.protocol === "https:") && !origin.username && !origin.password && origin.pathname === "/" && !origin.search && !origin.hash && origin.origin === value;
|
|
12803
|
+
} catch {
|
|
12804
|
+
return false;
|
|
12805
|
+
}
|
|
12806
|
+
}
|
|
12807
|
+
function setHttpSecurityHeaders(response) {
|
|
12808
|
+
response.setHeader("cache-control", "no-store");
|
|
12809
|
+
response.setHeader("x-content-type-options", "nosniff");
|
|
12810
|
+
response.setHeader("referrer-policy", "no-referrer");
|
|
12811
|
+
}
|
|
12812
|
+
function strongOpaqueToken(token) {
|
|
12813
|
+
if (Buffer.byteLength(token, "utf8") < 32) return false;
|
|
12814
|
+
if (new Set(token).size < 12) return false;
|
|
12815
|
+
return !/^(.)\1+$/.test(token) && !/(?:password|secret|token|changeme|example|development)/i.test(token);
|
|
12816
|
+
}
|
|
12817
|
+
function httpAuthChallenge(security, insufficientScope = false) {
|
|
12818
|
+
const parts = ["Bearer"];
|
|
12819
|
+
if (insufficientScope) parts.push('error="insufficient_scope"');
|
|
12820
|
+
if (security.oauth) {
|
|
12821
|
+
parts.push(`resource_metadata="${security.oauth.metadataUrl}"`);
|
|
12822
|
+
if (security.oauth.requiredScopes.length) parts.push(`scope="${security.oauth.requiredScopes.join(" ")}"`);
|
|
12823
|
+
}
|
|
12824
|
+
return parts.join(" ");
|
|
12825
|
+
}
|
|
12826
|
+
function maybeServeOauthMetadata(request, response, security, pathname) {
|
|
12827
|
+
if (!security.oauth || request.method !== "GET") return false;
|
|
12828
|
+
if (pathname !== security.oauth.metadataPath && pathname !== "/.well-known/oauth-protected-resource") return false;
|
|
12829
|
+
writeJson(response, 200, security.oauth.metadata);
|
|
12830
|
+
return true;
|
|
12831
|
+
}
|
|
12832
|
+
function writeAuthenticationFailure(response, security, status, error) {
|
|
12833
|
+
response.setHeader("www-authenticate", httpAuthChallenge(security, status === 403));
|
|
12834
|
+
writeJson(response, status, { ok: false, error });
|
|
12835
|
+
}
|
|
12289
12836
|
async function startHttpMcpServer(options = {}) {
|
|
12290
12837
|
const host = options.host ?? "127.0.0.1";
|
|
12291
12838
|
const port = options.port ?? 8765;
|
|
12292
|
-
const authTokenEnv = options.authTokenEnv ?? "SYNAPSOR_RUNNER_HTTP_TOKEN";
|
|
12293
12839
|
const env = options.env ?? process.env;
|
|
12294
12840
|
const devNoAuth = options.devNoAuth === true;
|
|
12295
12841
|
const config = resolveRuntimeConfig(options.config ?? loadRuntimeConfigFromFile(options.configPath));
|
|
12296
|
-
|
|
12297
|
-
if (devNoAuth && !isLoopbackHost(host)) {
|
|
12298
|
-
throw new McpRuntimeError("HTTP_DEV_NO_AUTH_UNSAFE_HOST", "--dev-no-auth is only allowed with localhost or 127.0.0.1.");
|
|
12299
|
-
}
|
|
12842
|
+
assertValidRunnerCapabilityConfig(config);
|
|
12300
12843
|
if (configUsesHttpClaims(config)) {
|
|
12301
12844
|
throw new McpRuntimeError("HTTP_CLAIMS_REQUIRES_STREAMABLE", "http_claims trusted context requires spec MCP Streamable HTTP sessions; the legacy JSON-RPC bridge cannot bind per-session context.");
|
|
12302
12845
|
}
|
|
12303
|
-
const
|
|
12304
|
-
|
|
12305
|
-
|
|
12846
|
+
const security = resolveHttpSecurity(config, options, host, env, false);
|
|
12847
|
+
const metricsAccess = resolveMetricsEndpointAccess(config, env, host);
|
|
12848
|
+
if (options.tls?.requestClientCert && !options.tls.ca) {
|
|
12849
|
+
throw new McpRuntimeError("MTLS_CA_REQUIRED", "HTTP mTLS requires a CA bundle when client certificates are required.");
|
|
12306
12850
|
}
|
|
12851
|
+
validateTlsMaterial(options.tls);
|
|
12307
12852
|
const cloudTools = config.mode === "cloud" ? await fetchCloudToolMetadata(config, env) : void 0;
|
|
12308
12853
|
if (options.readRow && Object.values(config.sources ?? {}).some((source) => source.database_scope?.mode === "postgres_rls")) {
|
|
12309
12854
|
throw new McpRuntimeError("POSTGRES_RLS_CUSTOM_READER_UNVERIFIED", "Hardened postgres_rls mode requires Runner's verified PostgreSQL reader; a custom readRow cannot be attested by the stock server.");
|
|
@@ -12318,19 +12863,27 @@ async function startHttpMcpServer(options = {}) {
|
|
|
12318
12863
|
cloudTools
|
|
12319
12864
|
});
|
|
12320
12865
|
const readinessCheck = options.readinessCheck ?? (() => checkRunnerReadiness(config, env));
|
|
12321
|
-
const
|
|
12866
|
+
const requestHandler = (request, response) => {
|
|
12322
12867
|
void handleHttpMcpRequest({
|
|
12323
12868
|
request,
|
|
12324
12869
|
response,
|
|
12325
12870
|
runtime,
|
|
12326
|
-
authToken,
|
|
12327
12871
|
devNoAuth,
|
|
12328
|
-
|
|
12872
|
+
security,
|
|
12329
12873
|
readinessCheck,
|
|
12330
12874
|
metricsAccess,
|
|
12331
12875
|
metricsProvider: () => renderRuntimeMetrics(runtime.store, runtime.poolMetrics(), runtime.rateLimitMetrics(), readinessCheck)
|
|
12332
12876
|
});
|
|
12333
|
-
}
|
|
12877
|
+
};
|
|
12878
|
+
const server = options.tls ? createHttpsServer({
|
|
12879
|
+
cert: options.tls.cert,
|
|
12880
|
+
key: options.tls.key,
|
|
12881
|
+
ca: options.tls.ca,
|
|
12882
|
+
requestCert: options.tls.requestClientCert === true,
|
|
12883
|
+
rejectUnauthorized: options.tls.requestClientCert === true,
|
|
12884
|
+
maxHeaderSize: security.limits.maxHeaderBytes
|
|
12885
|
+
}, requestHandler) : createServer({ maxHeaderSize: security.limits.maxHeaderBytes }, requestHandler);
|
|
12886
|
+
applyHttpServerLimits(server, security.limits);
|
|
12334
12887
|
try {
|
|
12335
12888
|
await new Promise((resolve2, reject) => {
|
|
12336
12889
|
server.once("error", reject);
|
|
@@ -12346,13 +12899,19 @@ async function startHttpMcpServer(options = {}) {
|
|
|
12346
12899
|
const address = server.address();
|
|
12347
12900
|
const actualHost = address.address === "::" ? host : address.address;
|
|
12348
12901
|
const actualPort = address.port;
|
|
12349
|
-
const
|
|
12902
|
+
const scheme = options.tls ? "https" : "http";
|
|
12903
|
+
const url = `${scheme}://${actualHost}:${actualPort}/mcp`;
|
|
12350
12904
|
if (options.log !== false) {
|
|
12351
12905
|
const log = options.log ?? process.stderr;
|
|
12352
12906
|
log.write(`Synapsor Runner HTTP MCP listening on ${url}
|
|
12353
12907
|
`);
|
|
12354
|
-
log.write(
|
|
12908
|
+
log.write(`Channel: ${security.channel}; deployment: ${security.deployment}
|
|
12909
|
+
`);
|
|
12910
|
+
if (options.tls) log.write(options.tls.requestClientCert ? "TLS: enabled, client certificates required in addition to Bearer auth\n" : "TLS: enabled\n");
|
|
12911
|
+
log.write(devNoAuth ? "Auth: disabled for loopback development only\n" : `Auth: opaque Bearer endpoint token from ${security.activeTokenEnv}${security.previousTokenEnv ? `; previous rotation token from ${security.previousTokenEnv}` : ""}
|
|
12355
12912
|
`);
|
|
12913
|
+
if (security.weakStaticToken) log.write("Auth warning: loopback endpoint token is shorter or more predictable than the production requirement; generate at least 32 random bytes.\n");
|
|
12914
|
+
if (security.channel === "insecure_http_break_glass") log.write("SECURITY WARNING: remote Bearer traffic is using explicit insecure cleartext break glass. Credentials and data can be intercepted.\n");
|
|
12356
12915
|
log.write(`Config: ${options.configPath ?? "synapsor.runner.json"}
|
|
12357
12916
|
`);
|
|
12358
12917
|
log.write(`Store: ${options.storePath ?? config.storage?.sqlite_path ?? "./.synapsor/local.db"}
|
|
@@ -12368,29 +12927,23 @@ async function startHttpMcpServer(options = {}) {
|
|
|
12368
12927
|
async function startStreamableHttpMcpServer(options = {}) {
|
|
12369
12928
|
const host = options.host ?? "127.0.0.1";
|
|
12370
12929
|
const port = options.port ?? 8766;
|
|
12371
|
-
const authTokenEnv = options.authTokenEnv ?? "SYNAPSOR_RUNNER_HTTP_TOKEN";
|
|
12372
12930
|
const env = options.env ?? process.env;
|
|
12373
12931
|
const devNoAuth = options.devNoAuth === true;
|
|
12374
12932
|
const config = resolveRuntimeConfig(options.config ?? loadRuntimeConfigFromFile(options.configPath));
|
|
12375
12933
|
assertValidRunnerCapabilityConfig(config);
|
|
12376
12934
|
const usesSessionAuth = configUsesHttpClaims(config);
|
|
12935
|
+
const security = resolveHttpSecurity(config, options, host, env, usesSessionAuth);
|
|
12377
12936
|
const metricsAccess = resolveMetricsEndpointAccess(config, env, host);
|
|
12378
|
-
if (devNoAuth && !isLoopbackHost(host)) {
|
|
12379
|
-
throw new McpRuntimeError("HTTP_DEV_NO_AUTH_UNSAFE_HOST", "--dev-no-auth is only allowed with localhost or 127.0.0.1.");
|
|
12380
|
-
}
|
|
12381
12937
|
if (devNoAuth && usesSessionAuth) {
|
|
12382
12938
|
throw new McpRuntimeError("HTTP_CLAIMS_AUTH_REQUIRED", "http_claims trusted context cannot run with --dev-no-auth.");
|
|
12383
12939
|
}
|
|
12384
|
-
const authToken = devNoAuth || usesSessionAuth ? void 0 : envValue(env, authTokenEnv);
|
|
12385
|
-
if (!devNoAuth && !usesSessionAuth && !authToken) {
|
|
12386
|
-
throw new McpRuntimeError("HTTP_AUTH_TOKEN_MISSING", `${authTokenEnv} is not set. Streamable HTTP MCP requires bearer auth by default.`);
|
|
12387
|
-
}
|
|
12388
12940
|
assertRuntimeStoreStartupReady(config, env);
|
|
12389
12941
|
const sessionVerifier = usesSessionAuth ? sessionAuthVerifier(config, env, options.configPath ? path2.dirname(path2.resolve(options.configPath)) : process.cwd()) : void 0;
|
|
12390
12942
|
const readinessCheck = options.readinessCheck ?? (() => checkRunnerReadiness(config, env));
|
|
12391
12943
|
if (options.tls?.requestClientCert && !options.tls.ca) {
|
|
12392
12944
|
throw new McpRuntimeError("MTLS_CA_REQUIRED", "Streamable HTTP mTLS requires a CA bundle when client certificates are required.");
|
|
12393
12945
|
}
|
|
12946
|
+
validateTlsMaterial(options.tls);
|
|
12394
12947
|
const cloudTools = config.mode === "cloud" ? await fetchCloudToolMetadata(config, env) : void 0;
|
|
12395
12948
|
if (options.readRow && Object.values(config.sources ?? {}).some((source) => source.database_scope?.mode === "postgres_rls")) {
|
|
12396
12949
|
throw new McpRuntimeError("POSTGRES_RLS_CUSTOM_READER_UNVERIFIED", "Hardened postgres_rls mode requires Runner's verified PostgreSQL reader; a custom readRow cannot be attested by the stock server.");
|
|
@@ -12403,6 +12956,7 @@ async function startStreamableHttpMcpServer(options = {}) {
|
|
|
12403
12956
|
const sharedResources = createMcpRuntimeSharedResources(config, env, options.readRow, Date.now, options.credentialResolver);
|
|
12404
12957
|
const sessions = /* @__PURE__ */ new Map();
|
|
12405
12958
|
const openSessions = /* @__PURE__ */ new Set();
|
|
12959
|
+
const initializingSessions = { count: 0 };
|
|
12406
12960
|
const requestHandler = (request, response) => {
|
|
12407
12961
|
void handleStreamableHttpMcpRequest({
|
|
12408
12962
|
request,
|
|
@@ -12415,12 +12969,12 @@ async function startStreamableHttpMcpServer(options = {}) {
|
|
|
12415
12969
|
env,
|
|
12416
12970
|
toolNameStyle: options.toolNameStyle,
|
|
12417
12971
|
resultFormat: options.resultFormat,
|
|
12418
|
-
authToken,
|
|
12419
12972
|
sessionVerifier,
|
|
12420
12973
|
devNoAuth,
|
|
12421
|
-
|
|
12974
|
+
security,
|
|
12422
12975
|
sessions,
|
|
12423
12976
|
openSessions,
|
|
12977
|
+
initializingSessions,
|
|
12424
12978
|
readinessCheck,
|
|
12425
12979
|
metricsAccess,
|
|
12426
12980
|
metricsProvider: () => renderRuntimeMetrics(sharedStore, sharedResources.poolMetrics(), sharedResources.rateLimitMetrics(), readinessCheck)
|
|
@@ -12431,8 +12985,14 @@ async function startStreamableHttpMcpServer(options = {}) {
|
|
|
12431
12985
|
key: options.tls.key,
|
|
12432
12986
|
ca: options.tls.ca,
|
|
12433
12987
|
requestCert: options.tls.requestClientCert === true,
|
|
12434
|
-
rejectUnauthorized: options.tls.requestClientCert === true
|
|
12435
|
-
|
|
12988
|
+
rejectUnauthorized: options.tls.requestClientCert === true,
|
|
12989
|
+
maxHeaderSize: security.limits.maxHeaderBytes
|
|
12990
|
+
}, requestHandler) : createServer({ maxHeaderSize: security.limits.maxHeaderBytes }, requestHandler);
|
|
12991
|
+
applyHttpServerLimits(server, security.limits);
|
|
12992
|
+
const sessionReaper = setInterval(() => {
|
|
12993
|
+
void pruneExpiredStreamableSessions(sessions, openSessions, security.limits.sessionIdleTimeoutMs, Date.now());
|
|
12994
|
+
}, Math.min(3e4, Math.max(1e3, Math.floor(security.limits.sessionIdleTimeoutMs / 2))));
|
|
12995
|
+
sessionReaper.unref?.();
|
|
12436
12996
|
try {
|
|
12437
12997
|
await new Promise((resolve2, reject) => {
|
|
12438
12998
|
server.once("error", reject);
|
|
@@ -12442,6 +13002,7 @@ async function startStreamableHttpMcpServer(options = {}) {
|
|
|
12442
13002
|
});
|
|
12443
13003
|
});
|
|
12444
13004
|
} catch (error) {
|
|
13005
|
+
clearInterval(sessionReaper);
|
|
12445
13006
|
await cloudSynchronizer?.stop();
|
|
12446
13007
|
await closeStreamableSessions(openSessions);
|
|
12447
13008
|
await sharedResources.close();
|
|
@@ -12457,10 +13018,16 @@ async function startStreamableHttpMcpServer(options = {}) {
|
|
|
12457
13018
|
const log = options.log ?? process.stderr;
|
|
12458
13019
|
log.write(`Synapsor Runner Streamable HTTP MCP listening on ${url}
|
|
12459
13020
|
`);
|
|
12460
|
-
|
|
12461
|
-
|
|
12462
|
-
|
|
13021
|
+
log.write(`Channel: ${security.channel}; deployment: ${security.deployment}
|
|
13022
|
+
`);
|
|
13023
|
+
if (options.tls) log.write(options.tls.requestClientCert ? "TLS: enabled, client certificates required in addition to Bearer auth\n" : "TLS: enabled\n");
|
|
13024
|
+
log.write(devNoAuth ? "Auth: disabled for localhost development only\n" : usesSessionAuth ? `Auth: signed per-session JWT (${config.session_auth?.provider}); issuer and resource checked on every request
|
|
13025
|
+
` : `Auth: opaque Bearer endpoint token from ${security.activeTokenEnv}${security.previousTokenEnv ? `; previous rotation token from ${security.previousTokenEnv}` : ""}
|
|
13026
|
+
`);
|
|
13027
|
+
if (security.oauth) log.write(`OAuth resource metadata: ${security.oauth.metadataUrl}
|
|
12463
13028
|
`);
|
|
13029
|
+
if (security.weakStaticToken) log.write("Auth warning: loopback endpoint token is shorter or more predictable than the production requirement; generate at least 32 random bytes.\n");
|
|
13030
|
+
if (security.channel === "insecure_http_break_glass") log.write("SECURITY WARNING: remote Bearer traffic is using explicit insecure cleartext break glass. Credentials and data can be intercepted.\n");
|
|
12464
13031
|
for (const assurance of describeIsolationAssurance(config)) {
|
|
12465
13032
|
log.write(`Isolation ${assurance.source}: ${assurance.mode}; trusted context: ${assurance.trusted_context.request_binding}
|
|
12466
13033
|
`);
|
|
@@ -12476,19 +13043,23 @@ async function startStreamableHttpMcpServer(options = {}) {
|
|
|
12476
13043
|
host: actualHost,
|
|
12477
13044
|
port: actualPort,
|
|
12478
13045
|
url,
|
|
12479
|
-
close: () =>
|
|
13046
|
+
close: () => {
|
|
13047
|
+
clearInterval(sessionReaper);
|
|
13048
|
+
return closeStreamableHttpServer(server, openSessions, sharedResources, sharedStore, cloudSynchronizer);
|
|
13049
|
+
}
|
|
12480
13050
|
};
|
|
12481
13051
|
}
|
|
12482
13052
|
async function handleStreamableHttpMcpRequest(input) {
|
|
12483
|
-
const { request, response, config, storePath, sharedStore, sharedResources, cloudTools, env, toolNameStyle, resultFormat,
|
|
13053
|
+
const { request, response, config, storePath, sharedStore, sharedResources, cloudTools, env, toolNameStyle, resultFormat, sessionVerifier, devNoAuth, security, sessions, openSessions, initializingSessions, readinessCheck, metricsAccess, metricsProvider } = input;
|
|
12484
13054
|
try {
|
|
12485
|
-
|
|
12486
|
-
if (request.method === "OPTIONS" &&
|
|
13055
|
+
if (!validateHttpRequestSecurity(request, response, security)) return;
|
|
13056
|
+
if (request.method === "OPTIONS" && request.headers.origin) {
|
|
12487
13057
|
response.statusCode = 204;
|
|
12488
13058
|
response.end();
|
|
12489
13059
|
return;
|
|
12490
13060
|
}
|
|
12491
13061
|
const url = new URL(request.url ?? "/", "http://localhost");
|
|
13062
|
+
if (maybeServeOauthMetadata(request, response, security, url.pathname)) return;
|
|
12492
13063
|
if (request.method === "GET" && url.pathname === "/healthz") {
|
|
12493
13064
|
writeJson(response, 200, {
|
|
12494
13065
|
ok: true,
|
|
@@ -12510,11 +13081,13 @@ async function handleStreamableHttpMcpRequest(input) {
|
|
|
12510
13081
|
writeJson(response, 404, { ok: false, error: "not_found" });
|
|
12511
13082
|
return;
|
|
12512
13083
|
}
|
|
12513
|
-
const
|
|
12514
|
-
if (!
|
|
12515
|
-
|
|
13084
|
+
const authResult = await authenticateStreamableRequest(config, request.headers.authorization, sessionVerifier, security, devNoAuth);
|
|
13085
|
+
if (!authResult.ok) {
|
|
13086
|
+
writeAuthenticationFailure(response, security, authResult.status, authResult.error);
|
|
12516
13087
|
return;
|
|
12517
13088
|
}
|
|
13089
|
+
const authentication = authResult.authentication;
|
|
13090
|
+
await pruneExpiredStreamableSessions(sessions, openSessions, security.limits.sessionIdleTimeoutMs, Date.now());
|
|
12518
13091
|
const sessionId = headerValue(request.headers["mcp-session-id"]);
|
|
12519
13092
|
if (sessionId) {
|
|
12520
13093
|
const existing = sessions.get(sessionId);
|
|
@@ -12523,9 +13096,10 @@ async function handleStreamableHttpMcpRequest(input) {
|
|
|
12523
13096
|
return;
|
|
12524
13097
|
}
|
|
12525
13098
|
if (existing.authFingerprint !== authentication.fingerprint) {
|
|
12526
|
-
|
|
13099
|
+
writeAuthenticationFailure(response, security, 401, "unauthorized");
|
|
12527
13100
|
return;
|
|
12528
13101
|
}
|
|
13102
|
+
existing.lastSeenAt = Date.now();
|
|
12529
13103
|
await existing.transport.handleRequest(request, response);
|
|
12530
13104
|
return;
|
|
12531
13105
|
}
|
|
@@ -12533,54 +13107,73 @@ async function handleStreamableHttpMcpRequest(input) {
|
|
|
12533
13107
|
writeJson(response, 400, jsonRpcError(null, -32e3, "MCP initialize request is required before using this Streamable HTTP session."));
|
|
12534
13108
|
return;
|
|
12535
13109
|
}
|
|
12536
|
-
|
|
12537
|
-
|
|
12538
|
-
writeJson(response,
|
|
13110
|
+
if (openSessions.size + initializingSessions.count >= security.limits.maxSessions) {
|
|
13111
|
+
response.setHeader("retry-after", "1");
|
|
13112
|
+
writeJson(response, 503, { ok: false, error: "session_capacity_exhausted", retryable: true, retry_after_ms: 1e3 });
|
|
12539
13113
|
return;
|
|
12540
13114
|
}
|
|
12541
|
-
|
|
12542
|
-
|
|
12543
|
-
|
|
12544
|
-
|
|
12545
|
-
|
|
12546
|
-
|
|
12547
|
-
|
|
12548
|
-
|
|
12549
|
-
|
|
12550
|
-
|
|
12551
|
-
|
|
12552
|
-
|
|
12553
|
-
|
|
13115
|
+
initializingSessions.count += 1;
|
|
13116
|
+
let initializingSession;
|
|
13117
|
+
try {
|
|
13118
|
+
const parsedBody = JSON.parse(await readRequestBody(request, security.limits.maxRequestBytes));
|
|
13119
|
+
if (!containsInitializeRequest(parsedBody)) {
|
|
13120
|
+
writeJson(response, 400, jsonRpcError(requestIdFromPayload(parsedBody), -32e3, "First Streamable HTTP MCP request must be initialize."));
|
|
13121
|
+
return;
|
|
13122
|
+
}
|
|
13123
|
+
const transport = new StreamableHTTPServerTransport({
|
|
13124
|
+
sessionIdGenerator: () => crypto3.randomUUID(),
|
|
13125
|
+
onsessioninitialized: (newSessionId) => {
|
|
13126
|
+
if (initializingSession) {
|
|
13127
|
+
initializingSession.sessionId = newSessionId;
|
|
13128
|
+
sessions.set(newSessionId, initializingSession);
|
|
13129
|
+
}
|
|
13130
|
+
},
|
|
13131
|
+
onsessionclosed: (closedSessionId) => {
|
|
13132
|
+
const closed = sessions.get(closedSessionId);
|
|
13133
|
+
if (closed) {
|
|
13134
|
+
disposeStreamableSession(closed, sessions, openSessions);
|
|
13135
|
+
}
|
|
12554
13136
|
}
|
|
13137
|
+
});
|
|
13138
|
+
const runtime = createMcpRuntime(config, {
|
|
13139
|
+
env,
|
|
13140
|
+
storePath,
|
|
13141
|
+
store: sharedStore,
|
|
13142
|
+
sharedResources,
|
|
13143
|
+
resultFormat,
|
|
13144
|
+
cloudTools,
|
|
13145
|
+
trustedContext: authentication.context
|
|
13146
|
+
});
|
|
13147
|
+
initializingSession = { transport, runtime, authFingerprint: authentication.fingerprint, lastSeenAt: Date.now() };
|
|
13148
|
+
openSessions.add(initializingSession);
|
|
13149
|
+
transport.onclose = () => {
|
|
13150
|
+
if (initializingSession) disposeStreamableSession(initializingSession, sessions, openSessions);
|
|
13151
|
+
};
|
|
13152
|
+
await createSynapsorMcpServer(runtime, { toolNameStyle }).connect(transport);
|
|
13153
|
+
await transport.handleRequest(request, response, parsedBody);
|
|
13154
|
+
} catch (error) {
|
|
13155
|
+
if (initializingSession) {
|
|
13156
|
+
disposeStreamableSession(initializingSession, sessions, openSessions);
|
|
13157
|
+
await initializingSession.transport.close().catch(() => void 0);
|
|
12555
13158
|
}
|
|
12556
|
-
|
|
12557
|
-
|
|
12558
|
-
|
|
12559
|
-
|
|
12560
|
-
store: sharedStore,
|
|
12561
|
-
sharedResources,
|
|
12562
|
-
resultFormat,
|
|
12563
|
-
cloudTools,
|
|
12564
|
-
trustedContext: authentication.context
|
|
12565
|
-
});
|
|
12566
|
-
session = { transport, runtime, authFingerprint: authentication.fingerprint };
|
|
12567
|
-
openSessions.add(session);
|
|
12568
|
-
transport.onclose = () => {
|
|
12569
|
-
if (session) disposeStreamableSession(session, sessions, openSessions);
|
|
12570
|
-
};
|
|
12571
|
-
await createSynapsorMcpServer(runtime, { toolNameStyle }).connect(transport);
|
|
12572
|
-
await transport.handleRequest(request, response, parsedBody);
|
|
13159
|
+
throw error;
|
|
13160
|
+
} finally {
|
|
13161
|
+
initializingSessions.count -= 1;
|
|
13162
|
+
}
|
|
12573
13163
|
} catch (error) {
|
|
12574
|
-
const message2 = sanitizeHttpError(error,
|
|
12575
|
-
if (!response.headersSent
|
|
13164
|
+
const message2 = sanitizeHttpError(error, security.activeToken, security.previousToken);
|
|
13165
|
+
if (!response.headersSent && error instanceof McpRuntimeError && error.code === "HTTP_BODY_TOO_LARGE") {
|
|
13166
|
+
writeJson(response, 413, { ok: false, error: "request_too_large" });
|
|
13167
|
+
} else if (!response.headersSent) writeJson(response, 200, jsonRpcError(null, -32e3, message2));
|
|
12576
13168
|
else response.end();
|
|
12577
13169
|
}
|
|
12578
13170
|
}
|
|
12579
13171
|
async function handleHttpMcpRequest(input) {
|
|
12580
|
-
const { request, response, runtime,
|
|
13172
|
+
const { request, response, runtime, devNoAuth, security, readinessCheck, metricsAccess, metricsProvider } = input;
|
|
12581
13173
|
try {
|
|
12582
|
-
setCommonHttpHeaders(response
|
|
12583
|
-
if (request
|
|
13174
|
+
setCommonHttpHeaders(response);
|
|
13175
|
+
if (!validateHttpRequestSecurity(request, response, security)) return;
|
|
13176
|
+
if (request.method === "OPTIONS" && request.headers.origin) {
|
|
12584
13177
|
response.statusCode = 204;
|
|
12585
13178
|
response.end();
|
|
12586
13179
|
return;
|
|
@@ -12611,13 +13204,13 @@ async function handleHttpMcpRequest(input) {
|
|
|
12611
13204
|
writeJson(response, 405, { ok: false, error: "method_not_allowed" });
|
|
12612
13205
|
return;
|
|
12613
13206
|
}
|
|
12614
|
-
if (!devNoAuth && !
|
|
12615
|
-
|
|
13207
|
+
if (!devNoAuth && !validBearerTokens(request.headers.authorization, [security.activeToken, security.previousToken])) {
|
|
13208
|
+
writeAuthenticationFailure(response, security, 401, "unauthorized");
|
|
12616
13209
|
return;
|
|
12617
13210
|
}
|
|
12618
|
-
const body = await readRequestBody(request);
|
|
13211
|
+
const body = await readRequestBody(request, security.limits.maxRequestBytes);
|
|
12619
13212
|
const payload = JSON.parse(body);
|
|
12620
|
-
if (!
|
|
13213
|
+
if (!isRecord5(payload)) {
|
|
12621
13214
|
writeJson(response, 400, jsonRpcError(null, -32600, "JSON-RPC request must be an object."));
|
|
12622
13215
|
return;
|
|
12623
13216
|
}
|
|
@@ -12627,15 +13220,18 @@ async function handleHttpMcpRequest(input) {
|
|
|
12627
13220
|
writeJson(response, 400, jsonRpcError(id, -32600, "JSON-RPC method is required."));
|
|
12628
13221
|
return;
|
|
12629
13222
|
}
|
|
12630
|
-
const result = await handleHttpJsonRpcMethod(runtime, method,
|
|
13223
|
+
const result = await handleHttpJsonRpcMethod(runtime, method, isRecord5(payload.params) ? payload.params : {});
|
|
12631
13224
|
writeJson(response, 200, {
|
|
12632
13225
|
jsonrpc: "2.0",
|
|
12633
13226
|
id,
|
|
12634
|
-
result: sanitizeHttpPayload(result,
|
|
13227
|
+
result: sanitizeHttpPayload(result, security.activeToken, security.previousToken)
|
|
12635
13228
|
});
|
|
12636
13229
|
} catch (error) {
|
|
12637
|
-
const message2 = sanitizeHttpError(error,
|
|
12638
|
-
|
|
13230
|
+
const message2 = sanitizeHttpError(error, security.activeToken, security.previousToken);
|
|
13231
|
+
if (!response.headersSent && error instanceof McpRuntimeError && error.code === "HTTP_BODY_TOO_LARGE") {
|
|
13232
|
+
writeJson(response, 413, { ok: false, error: "request_too_large" });
|
|
13233
|
+
} else if (!response.headersSent) writeJson(response, 200, jsonRpcError(null, -32e3, message2));
|
|
13234
|
+
else response.end();
|
|
12639
13235
|
}
|
|
12640
13236
|
}
|
|
12641
13237
|
async function handleHttpJsonRpcMethod(runtime, method, params) {
|
|
@@ -12647,7 +13243,7 @@ async function handleHttpJsonRpcMethod(runtime, method, params) {
|
|
|
12647
13243
|
if (method === "tools/call") {
|
|
12648
13244
|
const name = typeof params.name === "string" ? params.name : void 0;
|
|
12649
13245
|
if (!name) throw new McpRuntimeError("HTTP_TOOL_NAME_REQUIRED", "tools/call requires params.name.");
|
|
12650
|
-
const args =
|
|
13246
|
+
const args = isRecord5(params.arguments) ? params.arguments : isRecord5(params.args) ? params.args : {};
|
|
12651
13247
|
return await toolCallResult(runtime, name, args);
|
|
12652
13248
|
}
|
|
12653
13249
|
if (method === "resources/read") {
|
|
@@ -12677,11 +13273,21 @@ function httpToolMetadata(tool) {
|
|
|
12677
13273
|
};
|
|
12678
13274
|
}
|
|
12679
13275
|
function validBearerToken(header, expected) {
|
|
12680
|
-
|
|
12681
|
-
|
|
12682
|
-
|
|
12683
|
-
const
|
|
12684
|
-
|
|
13276
|
+
return validBearerTokens(header, [expected]);
|
|
13277
|
+
}
|
|
13278
|
+
function validBearerTokens(header, expected) {
|
|
13279
|
+
const actual = bearerToken(header);
|
|
13280
|
+
if (!actual) return false;
|
|
13281
|
+
let matched = 0;
|
|
13282
|
+
for (const candidate of expected.filter((value) => Boolean(value))) {
|
|
13283
|
+
matched |= Number(constantTimeTokenEquals(actual, candidate));
|
|
13284
|
+
}
|
|
13285
|
+
return matched === 1;
|
|
13286
|
+
}
|
|
13287
|
+
function constantTimeTokenEquals(actual, expected) {
|
|
13288
|
+
const actualDigest = crypto3.createHash("sha256").update(actual, "utf8").digest();
|
|
13289
|
+
const expectedDigest = crypto3.createHash("sha256").update(expected, "utf8").digest();
|
|
13290
|
+
return crypto3.timingSafeEqual(actualDigest, expectedDigest);
|
|
12685
13291
|
}
|
|
12686
13292
|
function resolveMetricsEndpointAccess(config, env, host) {
|
|
12687
13293
|
if (config.metrics?.enabled !== true) return { enabled: false };
|
|
@@ -12788,12 +13394,12 @@ async function checkRunnerReadiness(config, env = process.env, timeoutMs = 3e3)
|
|
|
12788
13394
|
if (!executorName || checkedExecutors.has(executorName)) continue;
|
|
12789
13395
|
checkedExecutors.add(executorName);
|
|
12790
13396
|
components.push(await readinessComponent(`executor:${executorName}`, "EXECUTOR_READY", "EXECUTOR_UNAVAILABLE", timeoutMs, async () => {
|
|
12791
|
-
const executor =
|
|
13397
|
+
const executor = isRecord5(config.executors?.[executorName]) ? config.executors?.[executorName] : void 0;
|
|
12792
13398
|
if (!executor) throw new Error("executor missing");
|
|
12793
13399
|
if (executor.type === "http_handler") {
|
|
12794
13400
|
if (typeof executor.url_env !== "string" || !envValue(env, executor.url_env)) throw new Error("handler URL unavailable");
|
|
12795
13401
|
const handlerUrl = envValue(env, executor.url_env);
|
|
12796
|
-
const auth =
|
|
13402
|
+
const auth = isRecord5(executor.auth) ? executor.auth : void 0;
|
|
12797
13403
|
if (auth?.type === "bearer_env" && (typeof auth.token_env !== "string" || !envValue(env, auth.token_env))) throw new Error("handler token unavailable");
|
|
12798
13404
|
const response = await fetch(handlerUrl, {
|
|
12799
13405
|
method: "HEAD",
|
|
@@ -12886,20 +13492,25 @@ function sessionAuthVerifier(config, env, baseDir) {
|
|
|
12886
13492
|
throw new McpRuntimeError("SESSION_AUTH_INVALID", message2);
|
|
12887
13493
|
}
|
|
12888
13494
|
}
|
|
12889
|
-
async function authenticateStreamableRequest(config, authorization, sessionVerifier,
|
|
12890
|
-
if (devNoAuth) return { fingerprint: "dev-no-auth" };
|
|
13495
|
+
async function authenticateStreamableRequest(config, authorization, sessionVerifier, security, devNoAuth) {
|
|
13496
|
+
if (devNoAuth) return { ok: true, authentication: { fingerprint: "dev-no-auth" } };
|
|
12891
13497
|
const token = bearerToken(authorization);
|
|
12892
|
-
if (!token) return
|
|
13498
|
+
if (!token) return { ok: false, status: 401, error: "unauthorized" };
|
|
12893
13499
|
if (!configUsesHttpClaims(config)) {
|
|
12894
|
-
if (!
|
|
12895
|
-
|
|
13500
|
+
if (!validBearerTokens(authorization, [security.activeToken, security.previousToken])) {
|
|
13501
|
+
return { ok: false, status: 401, error: "unauthorized" };
|
|
13502
|
+
}
|
|
13503
|
+
return { ok: true, authentication: { fingerprint: tokenFingerprint(token) } };
|
|
12896
13504
|
}
|
|
12897
13505
|
try {
|
|
12898
|
-
if (!sessionVerifier) return
|
|
13506
|
+
if (!sessionVerifier) return { ok: false, status: 401, error: "unauthorized" };
|
|
12899
13507
|
const context = await verifySessionJwt(config, token, sessionVerifier);
|
|
12900
|
-
return { fingerprint: tokenFingerprint(token), context };
|
|
12901
|
-
} catch {
|
|
12902
|
-
|
|
13508
|
+
return { ok: true, authentication: { fingerprint: tokenFingerprint(token), context } };
|
|
13509
|
+
} catch (error) {
|
|
13510
|
+
if (error instanceof McpRuntimeError && error.code === "HTTP_INSUFFICIENT_SCOPE") {
|
|
13511
|
+
return { ok: false, status: 403, error: "insufficient_scope" };
|
|
13512
|
+
}
|
|
13513
|
+
return { ok: false, status: 401, error: "unauthorized" };
|
|
12903
13514
|
}
|
|
12904
13515
|
}
|
|
12905
13516
|
async function verifySessionJwt(config, token, verifier) {
|
|
@@ -12909,12 +13520,34 @@ async function verifySessionJwt(config, token, verifier) {
|
|
|
12909
13520
|
const tenant = safeSessionClaim(claims[auth.tenant_claim ?? "tenant_id"]);
|
|
12910
13521
|
const principal = safeSessionClaim(claims[auth.principal_claim ?? "sub"]);
|
|
12911
13522
|
if (!tenant || !principal) throw new Error("JWT trusted context claims are missing or unsafe");
|
|
13523
|
+
const requiredScopes = config.http_security?.oauth_resource?.required_scopes ?? [];
|
|
13524
|
+
if (requiredScopes.length > 0) {
|
|
13525
|
+
const granted = safeJwtScopes(claims.scope, claims.scp);
|
|
13526
|
+
if (!requiredScopes.every((scope) => granted.has(scope))) {
|
|
13527
|
+
throw new McpRuntimeError("HTTP_INSUFFICIENT_SCOPE", "JWT does not grant the required MCP resource scope.");
|
|
13528
|
+
}
|
|
13529
|
+
}
|
|
12912
13530
|
return { tenant_id: tenant, principal, provenance: "http_claims" };
|
|
12913
13531
|
}
|
|
12914
13532
|
function bearerToken(header) {
|
|
12915
|
-
|
|
12916
|
-
const token =
|
|
12917
|
-
return token
|
|
13533
|
+
const match = /^Bearer[ \t]+([^\s,]+)$/i.exec(header ?? "");
|
|
13534
|
+
const token = match?.[1];
|
|
13535
|
+
return token && token.length <= 16384 ? token : void 0;
|
|
13536
|
+
}
|
|
13537
|
+
function safeJwtScopes(scope, scp) {
|
|
13538
|
+
const values = [];
|
|
13539
|
+
if (typeof scope === "string" && scope.length <= 8192 && !/[\u0000-\u001f\u007f]/.test(scope)) {
|
|
13540
|
+
values.push(...scope.split(/\s+/).filter(Boolean));
|
|
13541
|
+
} else if (scope !== void 0) {
|
|
13542
|
+
throw new Error("JWT scope claim is unsafe");
|
|
13543
|
+
}
|
|
13544
|
+
if (Array.isArray(scp) && scp.length <= 64 && scp.every((value) => typeof value === "string" && value.length <= 128 && !/[\s\u0000-\u001f\u007f]/.test(value))) {
|
|
13545
|
+
values.push(...scp);
|
|
13546
|
+
} else if (scp !== void 0) {
|
|
13547
|
+
throw new Error("JWT scp claim is unsafe");
|
|
13548
|
+
}
|
|
13549
|
+
if (values.length > 128 || values.some((value) => value.length > 128)) throw new Error("JWT scope claim is unsafe");
|
|
13550
|
+
return new Set(values);
|
|
12918
13551
|
}
|
|
12919
13552
|
function tokenFingerprint(token) {
|
|
12920
13553
|
return `sha256:${crypto3.createHash("sha256").update(token).digest("hex")}`;
|
|
@@ -12934,10 +13567,10 @@ function containsInitializeRequest(payload) {
|
|
|
12934
13567
|
}
|
|
12935
13568
|
function requestIdFromPayload(payload) {
|
|
12936
13569
|
if (Array.isArray(payload)) {
|
|
12937
|
-
const request = payload.find((message2) =>
|
|
12938
|
-
return
|
|
13570
|
+
const request = payload.find((message2) => isRecord5(message2) && "id" in message2);
|
|
13571
|
+
return isRecord5(request) ? request.id ?? null : null;
|
|
12939
13572
|
}
|
|
12940
|
-
return
|
|
13573
|
+
return isRecord5(payload) ? payload.id ?? null : null;
|
|
12941
13574
|
}
|
|
12942
13575
|
function openaiToolNameAlias(canonicalName) {
|
|
12943
13576
|
const sanitized = canonicalName.replace(/[^A-Za-z0-9_-]+/g, "__").replace(/_{3,}/g, "__").replace(/^_+|_+$/g, "");
|
|
@@ -12992,14 +13625,14 @@ function writeJson(response, statusCode, payload) {
|
|
|
12992
13625
|
response.end(`${JSON.stringify(payload, null, 2)}
|
|
12993
13626
|
`);
|
|
12994
13627
|
}
|
|
12995
|
-
async function readRequestBody(request) {
|
|
13628
|
+
async function readRequestBody(request, maxBytes = 1048576) {
|
|
12996
13629
|
const chunks = [];
|
|
12997
13630
|
let bytes = 0;
|
|
12998
13631
|
for await (const chunk of request) {
|
|
12999
13632
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
13000
13633
|
bytes += buffer.length;
|
|
13001
|
-
if (bytes >
|
|
13002
|
-
throw new McpRuntimeError("HTTP_BODY_TOO_LARGE",
|
|
13634
|
+
if (bytes > maxBytes) {
|
|
13635
|
+
throw new McpRuntimeError("HTTP_BODY_TOO_LARGE", `HTTP MCP request body exceeds the configured ${maxBytes}-byte limit.`);
|
|
13003
13636
|
}
|
|
13004
13637
|
chunks.push(buffer);
|
|
13005
13638
|
}
|
|
@@ -13012,21 +13645,21 @@ function jsonRpcError(id, code, message2) {
|
|
|
13012
13645
|
error: { code, message: message2 }
|
|
13013
13646
|
};
|
|
13014
13647
|
}
|
|
13015
|
-
function sanitizeHttpError(error,
|
|
13648
|
+
function sanitizeHttpError(error, ...authTokens) {
|
|
13016
13649
|
const raw = error instanceof Error ? error.message : String(error);
|
|
13017
|
-
return sanitizeHttpString(raw,
|
|
13650
|
+
return sanitizeHttpString(raw, ...authTokens);
|
|
13018
13651
|
}
|
|
13019
|
-
function sanitizeHttpPayload(value,
|
|
13020
|
-
if (typeof value === "string") return sanitizeHttpString(value,
|
|
13021
|
-
if (Array.isArray(value)) return value.map((item) => sanitizeHttpPayload(item,
|
|
13022
|
-
if (
|
|
13023
|
-
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitizeHttpPayload(item,
|
|
13652
|
+
function sanitizeHttpPayload(value, ...authTokens) {
|
|
13653
|
+
if (typeof value === "string") return sanitizeHttpString(value, ...authTokens);
|
|
13654
|
+
if (Array.isArray(value)) return value.map((item) => sanitizeHttpPayload(item, ...authTokens));
|
|
13655
|
+
if (isRecord5(value)) {
|
|
13656
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitizeHttpPayload(item, ...authTokens)]));
|
|
13024
13657
|
}
|
|
13025
13658
|
return value;
|
|
13026
13659
|
}
|
|
13027
|
-
function sanitizeHttpString(value,
|
|
13660
|
+
function sanitizeHttpString(value, ...authTokens) {
|
|
13028
13661
|
let redacted = value.replace(/(?:postgres(?:ql)?|mysql):\/\/[^\s"']+/gi, "[redacted-database-url]");
|
|
13029
|
-
if (authToken) redacted = redacted.split(authToken).join("[redacted-token]");
|
|
13662
|
+
for (const authToken of authTokens) if (authToken) redacted = redacted.split(authToken).join("[redacted-token]");
|
|
13030
13663
|
return redacted;
|
|
13031
13664
|
}
|
|
13032
13665
|
function isLoopbackHost(host) {
|
|
@@ -13060,6 +13693,13 @@ async function closeStreamableSessions(sessions) {
|
|
|
13060
13693
|
disposeStreamableSession(session);
|
|
13061
13694
|
}
|
|
13062
13695
|
}
|
|
13696
|
+
async function pruneExpiredStreamableSessions(sessions, openSessions, idleTimeoutMs, now) {
|
|
13697
|
+
const expired = [...openSessions].filter((session) => now - session.lastSeenAt >= idleTimeoutMs);
|
|
13698
|
+
for (const session of expired) {
|
|
13699
|
+
disposeStreamableSession(session, sessions, openSessions);
|
|
13700
|
+
await session.transport.close().catch(() => void 0);
|
|
13701
|
+
}
|
|
13702
|
+
}
|
|
13063
13703
|
function disposeStreamableSession(session, sessionMap, openSessions) {
|
|
13064
13704
|
if (session.closed) return;
|
|
13065
13705
|
session.closed = true;
|
|
@@ -13086,7 +13726,7 @@ async function toolCallResult(runtime, toolName, args) {
|
|
|
13086
13726
|
}
|
|
13087
13727
|
async function withProposalReviewPresentation(runtime, result) {
|
|
13088
13728
|
const directId = typeof result.proposal_id === "string" ? result.proposal_id : void 0;
|
|
13089
|
-
const nested =
|
|
13729
|
+
const nested = isRecord5(result.proposal) ? result.proposal : void 0;
|
|
13090
13730
|
const proposalId = directId ?? (typeof nested?.id === "string" ? nested.id : void 0);
|
|
13091
13731
|
if (!proposalId || proposalId === "wrp_unknown") return result;
|
|
13092
13732
|
try {
|
|
@@ -13167,11 +13807,11 @@ function toolDescriptionWithCanonical(description, canonicalName, exposedName) {
|
|
|
13167
13807
|
${description}`;
|
|
13168
13808
|
}
|
|
13169
13809
|
function zodInputShapeFromJsonSchema(schema) {
|
|
13170
|
-
const properties =
|
|
13810
|
+
const properties = isRecord5(schema.properties) ? schema.properties : {};
|
|
13171
13811
|
const required = Array.isArray(schema.required) ? new Set(schema.required.map(String)) : /* @__PURE__ */ new Set();
|
|
13172
13812
|
const shape = {};
|
|
13173
13813
|
for (const [name, rawProperty] of Object.entries(properties)) {
|
|
13174
|
-
const property =
|
|
13814
|
+
const property = isRecord5(rawProperty) ? rawProperty : {};
|
|
13175
13815
|
let valueSchema;
|
|
13176
13816
|
if (Array.isArray(property.enum)) {
|
|
13177
13817
|
const allowed = property.enum.map((item) => scalar2(item));
|
|
@@ -13574,13 +14214,13 @@ function resultEnvelopeFromLegacy(legacy, capability, canonicalName) {
|
|
|
13574
14214
|
const kind = capability?.kind ?? (typeof legacy.proposal_id === "string" ? "proposal" : "read");
|
|
13575
14215
|
const evidenceBundleId = typeof legacy.evidence_bundle_id === "string" ? legacy.evidence_bundle_id : void 0;
|
|
13576
14216
|
const sourceChanged = Boolean(legacy.source_database_changed ?? legacy.source_database_mutated ?? false);
|
|
13577
|
-
const context =
|
|
13578
|
-
const target =
|
|
14217
|
+
const context = isRecord5(legacy.trusted_context) ? legacy.trusted_context : void 0;
|
|
14218
|
+
const target = isRecord5(legacy.target) ? legacy.target : void 0;
|
|
13579
14219
|
if (kind === "proposal") {
|
|
13580
14220
|
const proposalId = typeof legacy.proposal_id === "string" ? legacy.proposal_id : "wrp_unknown";
|
|
13581
14221
|
const targetType = typeof target?.type === "string" ? target.type : capability?.target.table ?? "object";
|
|
13582
14222
|
const targetId = target?.id !== void 0 ? String(target.id) : "unknown";
|
|
13583
|
-
const approval =
|
|
14223
|
+
const approval = isRecord5(legacy.approval) ? legacy.approval : void 0;
|
|
13584
14224
|
const state = typeof legacy.status === "string" ? legacy.status : "review_required";
|
|
13585
14225
|
const approvalRequired = legacy.approval_required !== false;
|
|
13586
14226
|
const executor = writebackExecutorName(legacy.writeback);
|
|
@@ -13596,7 +14236,7 @@ function resultEnvelopeFromLegacy(legacy, capability, canonicalName) {
|
|
|
13596
14236
|
id: proposalId,
|
|
13597
14237
|
state,
|
|
13598
14238
|
target: `${targetType}:${targetId}`,
|
|
13599
|
-
diff:
|
|
14239
|
+
diff: isRecord5(legacy.diff) ? legacy.diff : {},
|
|
13600
14240
|
approval_required: approvalRequired,
|
|
13601
14241
|
...approval ? { approval } : {},
|
|
13602
14242
|
writeback: {
|
|
@@ -13617,7 +14257,7 @@ function resultEnvelopeFromLegacy(legacy, capability, canonicalName) {
|
|
|
13617
14257
|
}
|
|
13618
14258
|
};
|
|
13619
14259
|
}
|
|
13620
|
-
const businessObject =
|
|
14260
|
+
const businessObject = isRecord5(legacy.business_object) ? legacy.business_object : void 0;
|
|
13621
14261
|
const objectType = typeof businessObject?.type === "string" ? businessObject.type : capability?.target.table ?? "record";
|
|
13622
14262
|
const objectId = businessObject?.id !== void 0 ? String(businessObject.id) : String(legacy.action ?? action);
|
|
13623
14263
|
return {
|
|
@@ -13625,7 +14265,7 @@ function resultEnvelopeFromLegacy(legacy, capability, canonicalName) {
|
|
|
13625
14265
|
summary: kind === "aggregate_read" ? `Read one reviewed aggregate through ${action}. Source member rows exposed: no. Source database changed: no.` : `Read ${objectType} ${objectId} through ${action}. Source database changed: no.`,
|
|
13626
14266
|
action,
|
|
13627
14267
|
kind,
|
|
13628
|
-
data:
|
|
14268
|
+
data: isRecord5(legacy.data) ? legacy.data : {},
|
|
13629
14269
|
proposal: null,
|
|
13630
14270
|
error: null,
|
|
13631
14271
|
evidence: evidenceBundleId ? evidenceHandle(evidenceBundleId) : null,
|
|
@@ -13639,7 +14279,7 @@ function resultEnvelopeFromLegacy(legacy, capability, canonicalName) {
|
|
|
13639
14279
|
};
|
|
13640
14280
|
}
|
|
13641
14281
|
function writebackExecutorName(value) {
|
|
13642
|
-
if (!
|
|
14282
|
+
if (!isRecord5(value)) return void 0;
|
|
13643
14283
|
return typeof value.executor === "string" ? value.executor : typeof value.mode === "string" ? value.mode : void 0;
|
|
13644
14284
|
}
|
|
13645
14285
|
function capabilityWritebackMode2(capability) {
|
|
@@ -13673,7 +14313,7 @@ function assertProposalWritebackResolvable(config, capability) {
|
|
|
13673
14313
|
throw new McpRuntimeError("WRITEBACK_UNRESOLVED", `capability ${capability.name} declares HANDLER writeback but has no executor name.`);
|
|
13674
14314
|
}
|
|
13675
14315
|
const executor = config.executors?.[executorName];
|
|
13676
|
-
if (!
|
|
14316
|
+
if (!isRecord5(executor)) {
|
|
13677
14317
|
throw new McpRuntimeError("WRITEBACK_UNRESOLVED", `capability ${capability.name} declares HANDLER writeback but executor ${executorName} is not configured.`);
|
|
13678
14318
|
}
|
|
13679
14319
|
if (executor.type !== "http_handler" && executor.type !== "command_handler") {
|
|
@@ -13940,26 +14580,26 @@ function errorChain(error) {
|
|
|
13940
14580
|
while (current !== void 0 && current !== null && chain.length < 6 && !seen.has(current)) {
|
|
13941
14581
|
chain.push(current);
|
|
13942
14582
|
seen.add(current);
|
|
13943
|
-
current =
|
|
14583
|
+
current = isRecord5(current) ? current.cause : void 0;
|
|
13944
14584
|
}
|
|
13945
14585
|
return chain;
|
|
13946
14586
|
}
|
|
13947
14587
|
function errorStringProperty(error, property) {
|
|
13948
|
-
if (!
|
|
14588
|
+
if (!isRecord5(error)) return void 0;
|
|
13949
14589
|
const value = error[property];
|
|
13950
14590
|
if (typeof value !== "string" && typeof value !== "number") return void 0;
|
|
13951
14591
|
const normalized = String(value).trim().toUpperCase();
|
|
13952
14592
|
return normalized || void 0;
|
|
13953
14593
|
}
|
|
13954
14594
|
function errorNumberProperty(error, property) {
|
|
13955
|
-
if (!
|
|
14595
|
+
if (!isRecord5(error)) return void 0;
|
|
13956
14596
|
const value = error[property];
|
|
13957
14597
|
const normalized = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
|
|
13958
14598
|
return Number.isInteger(normalized) ? normalized : void 0;
|
|
13959
14599
|
}
|
|
13960
14600
|
function errorMessage(error) {
|
|
13961
14601
|
if (error instanceof Error) return error.message;
|
|
13962
|
-
if (
|
|
14602
|
+
if (isRecord5(error) && typeof error.message === "string") return error.message;
|
|
13963
14603
|
return typeof error === "string" ? error : "";
|
|
13964
14604
|
}
|
|
13965
14605
|
function effectivePrincipalScope(config, capability, context) {
|
|
@@ -14708,7 +15348,7 @@ function validateToolArgs(capability, args) {
|
|
|
14708
15348
|
if (!Array.isArray(value)) throw new McpRuntimeError("ARGUMENT_TYPE_INVALID", `${name} must be an array of reviewed objects.`);
|
|
14709
15349
|
if (value.length < 1 || value.length > spec.max_items) throw new McpRuntimeError("ARGUMENT_ITEM_COUNT_INVALID", `${name} must contain 1 through ${spec.max_items} items.`);
|
|
14710
15350
|
for (const [index, item] of value.entries()) {
|
|
14711
|
-
if (!
|
|
15351
|
+
if (!isRecord5(item)) throw new McpRuntimeError("ARGUMENT_ITEM_TYPE_INVALID", `${name}[${index}] must be an object.`);
|
|
14712
15352
|
for (const key of Object.keys(item)) if (!Object.prototype.hasOwnProperty.call(spec.fields, key)) throw new McpRuntimeError("ARGUMENT_ITEM_FIELD_NOT_ALLOWED", `${name}[${index}].${key} is not a reviewed item field.`);
|
|
14713
15353
|
for (const [fieldName, fieldSpec] of Object.entries(spec.fields)) validateScalarArg(`${name}[${index}].${fieldName}`, fieldSpec, item[fieldName]);
|
|
14714
15354
|
}
|
|
@@ -14810,7 +15450,7 @@ function buildPatch(capability, args) {
|
|
|
14810
15450
|
function batchItemsFromArgs(capability, args) {
|
|
14811
15451
|
const argumentName = capability.operation?.batch?.items_from_arg;
|
|
14812
15452
|
const value = argumentName ? args[argumentName] : void 0;
|
|
14813
|
-
if (!argumentName || !Array.isArray(value) || value.some((item) => !
|
|
15453
|
+
if (!argumentName || !Array.isArray(value) || value.some((item) => !isRecord5(item))) {
|
|
14814
15454
|
throw new McpRuntimeError("BATCH_ITEMS_REQUIRED", `Bounded INSERT capability ${capability.name} requires its reviewed object-array argument.`);
|
|
14815
15455
|
}
|
|
14816
15456
|
return value;
|
|
@@ -15048,7 +15688,7 @@ function stableId(prefix, input) {
|
|
|
15048
15688
|
function hashJson(input) {
|
|
15049
15689
|
return `sha256:${crypto3.createHash("sha256").update(JSON.stringify(input)).digest("hex")}`;
|
|
15050
15690
|
}
|
|
15051
|
-
function
|
|
15691
|
+
function isRecord5(value) {
|
|
15052
15692
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
15053
15693
|
}
|
|
15054
15694
|
function toolErrorPayload(error) {
|