@objectstack/runtime 12.6.0 → 14.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +255 -33
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +46 -7
- package/dist/index.d.ts +46 -7
- package/dist/index.js +252 -30
- package/dist/index.js.map +1 -1
- package/package.json +20 -21
package/dist/index.d.cts
CHANGED
|
@@ -122,15 +122,15 @@ interface StandaloneStackResult {
|
|
|
122
122
|
* App-declared RBAC metadata, surfaced so the CLI (`serve`/`dev`/`start`)
|
|
123
123
|
* can wire it without a host `objectstack.config.ts`. In particular the
|
|
124
124
|
* `serve` command reads `permissions[]` to honour an app-declared default
|
|
125
|
-
* profile (ADR-0056 D7 — `
|
|
126
|
-
* `fallbackPermissionSet`) and reads both `
|
|
125
|
+
* profile (ADR-0056 D7 — `appDefaultPermissionSetName` → SecurityPlugin
|
|
126
|
+
* `fallbackPermissionSet`) and reads both `positions[]` and `permissions[]` to
|
|
127
127
|
* register application org roles with Better-Auth. Without these the
|
|
128
128
|
* artifact-serve path silently fell back to the built-in `member_default`
|
|
129
129
|
* (owner-only), so an `isDefault` profile declared purely in app metadata
|
|
130
130
|
* was ignored under `objectstack dev`.
|
|
131
131
|
*/
|
|
132
132
|
permissions?: any[];
|
|
133
|
-
|
|
133
|
+
positions?: any[];
|
|
134
134
|
}
|
|
135
135
|
declare function createStandaloneStack(config?: StandaloneStackConfig): Promise<StandaloneStackResult>;
|
|
136
136
|
|
|
@@ -1038,9 +1038,10 @@ declare class HttpDispatcher {
|
|
|
1038
1038
|
* Handle an MCP request over the Streamable HTTP transport (`/mcp`).
|
|
1039
1039
|
*
|
|
1040
1040
|
* Gating + auth (fail-closed):
|
|
1041
|
-
* - **
|
|
1042
|
-
* runtime
|
|
1043
|
-
* return 404 so the
|
|
1041
|
+
* - **default-on**: served unless `OS_MCP_SERVER_ENABLED=false` (single-env
|
|
1042
|
+
* runtime; MCP is a core platform capability). Multi-tenant cloud
|
|
1043
|
+
* overrides this gate per env. When opted out we return 404 so the
|
|
1044
|
+
* surface isn't advertised.
|
|
1044
1045
|
* - **auth**: requires a principal already resolved by
|
|
1045
1046
|
* `resolveExecutionContext` (the `sys_api_key` Bearer/header path or a
|
|
1046
1047
|
* session). Anonymous → 401.
|
|
@@ -1051,8 +1052,31 @@ declare class HttpDispatcher {
|
|
|
1051
1052
|
* uses. An external agent can never exceed the key's authority.
|
|
1052
1053
|
*/
|
|
1053
1054
|
handleMcp(body: any, context: HttpProtocolContext): Promise<HttpDispatcherResult>;
|
|
1054
|
-
/**
|
|
1055
|
+
/**
|
|
1056
|
+
* Whether the MCP HTTP surface is on for this single-env runtime.
|
|
1057
|
+
* Default-on core capability; `OS_MCP_SERVER_ENABLED=false` opts out
|
|
1058
|
+
* (single decision point: `isMcpServerEnabled` in `@objectstack/types`).
|
|
1059
|
+
*/
|
|
1055
1060
|
private static isMcpEnabled;
|
|
1061
|
+
/**
|
|
1062
|
+
* Absolute URL of the RFC 9728 protected-resource metadata for the MCP
|
|
1063
|
+
* endpoint, advertised via `WWW-Authenticate` (#2698). `null` when the
|
|
1064
|
+
* OAuth track is off — the auth service owns the decision (AS enabled +
|
|
1065
|
+
* OAuth 2.1 TLS rule), the dispatcher only relays it. Never throws.
|
|
1066
|
+
*/
|
|
1067
|
+
private getMcpResourceMetadataUrl;
|
|
1068
|
+
/**
|
|
1069
|
+
* `GET /mcp/skill` — the environment-customized portable Agent Skill
|
|
1070
|
+
* (`SKILL.md`), rendered by the MCP service (ADR-0036 Amendment C: ONE
|
|
1071
|
+
* generic skill; only the connection URL is environment-specific).
|
|
1072
|
+
*
|
|
1073
|
+
* Served PUBLIC like `/discovery`: the content is generic agent
|
|
1074
|
+
* instructions plus a URL the caller already knows — no schema, no
|
|
1075
|
+
* tenant data. Gated on the same default-on switch as the `/mcp` route
|
|
1076
|
+
* (404 when opted out, so the surface isn't advertised) and 501 when the
|
|
1077
|
+
* MCP plugin isn't loaded, mirroring `handleMcp`.
|
|
1078
|
+
*/
|
|
1079
|
+
handleMcpSkill(method: string, context: HttpProtocolContext): Promise<HttpDispatcherResult>;
|
|
1056
1080
|
/**
|
|
1057
1081
|
* Normalise the inbound request into a Web-standard `Request` for the MCP
|
|
1058
1082
|
* transport. Accepts an already-Web `Request`, or a node/Hono-style req
|
|
@@ -1489,6 +1513,21 @@ declare class HttpDispatcher {
|
|
|
1489
1513
|
* POST /read/all → markAllRead
|
|
1490
1514
|
*/
|
|
1491
1515
|
handleNotification(path: string, method: string, body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult>;
|
|
1516
|
+
/**
|
|
1517
|
+
* Handle the security admin surface (`/security/...`) — ADR-0090 D5/D9
|
|
1518
|
+
* suggested audience bindings. A package's `isDefault: true` permission
|
|
1519
|
+
* set is an install-time SUGGESTION to bind it to the `everyone` position;
|
|
1520
|
+
* these routes let an admin see and resolve those suggestions. The
|
|
1521
|
+
* `security` service does the real gating (tenant-admin pre-check, and the
|
|
1522
|
+
* confirm write runs under the audience-anchor + delegated-admin gates
|
|
1523
|
+
* with the caller's execution context — never auto-bound, never system).
|
|
1524
|
+
*
|
|
1525
|
+
* Routes:
|
|
1526
|
+
* GET /security/suggested-bindings?status=&packageId= → list (reconciles first)
|
|
1527
|
+
* POST /security/suggested-bindings/:id/confirm → create the anchor binding
|
|
1528
|
+
* POST /security/suggested-bindings/:id/dismiss → decline the suggestion
|
|
1529
|
+
*/
|
|
1530
|
+
handleSecurity(path: string, method: string, _body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult>;
|
|
1492
1531
|
/**
|
|
1493
1532
|
* Handles i18n requests
|
|
1494
1533
|
* path: sub-path after /i18n/
|
package/dist/index.d.ts
CHANGED
|
@@ -122,15 +122,15 @@ interface StandaloneStackResult {
|
|
|
122
122
|
* App-declared RBAC metadata, surfaced so the CLI (`serve`/`dev`/`start`)
|
|
123
123
|
* can wire it without a host `objectstack.config.ts`. In particular the
|
|
124
124
|
* `serve` command reads `permissions[]` to honour an app-declared default
|
|
125
|
-
* profile (ADR-0056 D7 — `
|
|
126
|
-
* `fallbackPermissionSet`) and reads both `
|
|
125
|
+
* profile (ADR-0056 D7 — `appDefaultPermissionSetName` → SecurityPlugin
|
|
126
|
+
* `fallbackPermissionSet`) and reads both `positions[]` and `permissions[]` to
|
|
127
127
|
* register application org roles with Better-Auth. Without these the
|
|
128
128
|
* artifact-serve path silently fell back to the built-in `member_default`
|
|
129
129
|
* (owner-only), so an `isDefault` profile declared purely in app metadata
|
|
130
130
|
* was ignored under `objectstack dev`.
|
|
131
131
|
*/
|
|
132
132
|
permissions?: any[];
|
|
133
|
-
|
|
133
|
+
positions?: any[];
|
|
134
134
|
}
|
|
135
135
|
declare function createStandaloneStack(config?: StandaloneStackConfig): Promise<StandaloneStackResult>;
|
|
136
136
|
|
|
@@ -1038,9 +1038,10 @@ declare class HttpDispatcher {
|
|
|
1038
1038
|
* Handle an MCP request over the Streamable HTTP transport (`/mcp`).
|
|
1039
1039
|
*
|
|
1040
1040
|
* Gating + auth (fail-closed):
|
|
1041
|
-
* - **
|
|
1042
|
-
* runtime
|
|
1043
|
-
* return 404 so the
|
|
1041
|
+
* - **default-on**: served unless `OS_MCP_SERVER_ENABLED=false` (single-env
|
|
1042
|
+
* runtime; MCP is a core platform capability). Multi-tenant cloud
|
|
1043
|
+
* overrides this gate per env. When opted out we return 404 so the
|
|
1044
|
+
* surface isn't advertised.
|
|
1044
1045
|
* - **auth**: requires a principal already resolved by
|
|
1045
1046
|
* `resolveExecutionContext` (the `sys_api_key` Bearer/header path or a
|
|
1046
1047
|
* session). Anonymous → 401.
|
|
@@ -1051,8 +1052,31 @@ declare class HttpDispatcher {
|
|
|
1051
1052
|
* uses. An external agent can never exceed the key's authority.
|
|
1052
1053
|
*/
|
|
1053
1054
|
handleMcp(body: any, context: HttpProtocolContext): Promise<HttpDispatcherResult>;
|
|
1054
|
-
/**
|
|
1055
|
+
/**
|
|
1056
|
+
* Whether the MCP HTTP surface is on for this single-env runtime.
|
|
1057
|
+
* Default-on core capability; `OS_MCP_SERVER_ENABLED=false` opts out
|
|
1058
|
+
* (single decision point: `isMcpServerEnabled` in `@objectstack/types`).
|
|
1059
|
+
*/
|
|
1055
1060
|
private static isMcpEnabled;
|
|
1061
|
+
/**
|
|
1062
|
+
* Absolute URL of the RFC 9728 protected-resource metadata for the MCP
|
|
1063
|
+
* endpoint, advertised via `WWW-Authenticate` (#2698). `null` when the
|
|
1064
|
+
* OAuth track is off — the auth service owns the decision (AS enabled +
|
|
1065
|
+
* OAuth 2.1 TLS rule), the dispatcher only relays it. Never throws.
|
|
1066
|
+
*/
|
|
1067
|
+
private getMcpResourceMetadataUrl;
|
|
1068
|
+
/**
|
|
1069
|
+
* `GET /mcp/skill` — the environment-customized portable Agent Skill
|
|
1070
|
+
* (`SKILL.md`), rendered by the MCP service (ADR-0036 Amendment C: ONE
|
|
1071
|
+
* generic skill; only the connection URL is environment-specific).
|
|
1072
|
+
*
|
|
1073
|
+
* Served PUBLIC like `/discovery`: the content is generic agent
|
|
1074
|
+
* instructions plus a URL the caller already knows — no schema, no
|
|
1075
|
+
* tenant data. Gated on the same default-on switch as the `/mcp` route
|
|
1076
|
+
* (404 when opted out, so the surface isn't advertised) and 501 when the
|
|
1077
|
+
* MCP plugin isn't loaded, mirroring `handleMcp`.
|
|
1078
|
+
*/
|
|
1079
|
+
handleMcpSkill(method: string, context: HttpProtocolContext): Promise<HttpDispatcherResult>;
|
|
1056
1080
|
/**
|
|
1057
1081
|
* Normalise the inbound request into a Web-standard `Request` for the MCP
|
|
1058
1082
|
* transport. Accepts an already-Web `Request`, or a node/Hono-style req
|
|
@@ -1489,6 +1513,21 @@ declare class HttpDispatcher {
|
|
|
1489
1513
|
* POST /read/all → markAllRead
|
|
1490
1514
|
*/
|
|
1491
1515
|
handleNotification(path: string, method: string, body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult>;
|
|
1516
|
+
/**
|
|
1517
|
+
* Handle the security admin surface (`/security/...`) — ADR-0090 D5/D9
|
|
1518
|
+
* suggested audience bindings. A package's `isDefault: true` permission
|
|
1519
|
+
* set is an install-time SUGGESTION to bind it to the `everyone` position;
|
|
1520
|
+
* these routes let an admin see and resolve those suggestions. The
|
|
1521
|
+
* `security` service does the real gating (tenant-admin pre-check, and the
|
|
1522
|
+
* confirm write runs under the audience-anchor + delegated-admin gates
|
|
1523
|
+
* with the caller's execution context — never auto-bound, never system).
|
|
1524
|
+
*
|
|
1525
|
+
* Routes:
|
|
1526
|
+
* GET /security/suggested-bindings?status=&packageId= → list (reconciles first)
|
|
1527
|
+
* POST /security/suggested-bindings/:id/confirm → create the anchor binding
|
|
1528
|
+
* POST /security/suggested-bindings/:id/dismiss → decline the suggestion
|
|
1529
|
+
*/
|
|
1530
|
+
handleSecurity(path: string, method: string, _body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult>;
|
|
1492
1531
|
/**
|
|
1493
1532
|
* Handles i18n requests
|
|
1494
1533
|
* path: sub-path after /i18n/
|
package/dist/index.js
CHANGED
|
@@ -1030,7 +1030,7 @@ var init_app_plugin = __esm({
|
|
|
1030
1030
|
if (typeof metadata?.registerInMemory === "function") {
|
|
1031
1031
|
const securityBundle = this.bundle.manifest ? { ...this.bundle.manifest, ...this.bundle } : this.bundle;
|
|
1032
1032
|
const SECURITY_FIELDS = [
|
|
1033
|
-
["
|
|
1033
|
+
["positions", "position"],
|
|
1034
1034
|
["permissions", "permission"],
|
|
1035
1035
|
["sharingRules", "sharing_rule"],
|
|
1036
1036
|
["policies", "policy"]
|
|
@@ -1396,8 +1396,7 @@ var init_app_plugin = __esm({
|
|
|
1396
1396
|
"skills",
|
|
1397
1397
|
"actions",
|
|
1398
1398
|
"permissions",
|
|
1399
|
-
"
|
|
1400
|
-
"profiles",
|
|
1399
|
+
"positions",
|
|
1401
1400
|
"translations",
|
|
1402
1401
|
"sharingRules",
|
|
1403
1402
|
"ragPipelines",
|
|
@@ -1757,7 +1756,7 @@ async function createStandaloneStack(config) {
|
|
|
1757
1756
|
const objects = Array.isArray(artifactBundle?.objects) ? artifactBundle.objects : void 0;
|
|
1758
1757
|
const manifest = artifactBundle?.manifest;
|
|
1759
1758
|
const permissions = Array.isArray(artifactBundle?.permissions) ? artifactBundle.permissions : void 0;
|
|
1760
|
-
const
|
|
1759
|
+
const positions = Array.isArray(artifactBundle?.positions) ? artifactBundle.positions : void 0;
|
|
1761
1760
|
return {
|
|
1762
1761
|
plugins,
|
|
1763
1762
|
api: {
|
|
@@ -1768,7 +1767,7 @@ async function createStandaloneStack(config) {
|
|
|
1768
1767
|
...objects ? { objects } : {},
|
|
1769
1768
|
...manifest ? { manifest } : {},
|
|
1770
1769
|
...permissions ? { permissions } : {},
|
|
1771
|
-
...
|
|
1770
|
+
...positions ? { positions } : {}
|
|
1772
1771
|
};
|
|
1773
1772
|
}
|
|
1774
1773
|
var StandaloneStackConfigSchema;
|
|
@@ -2085,7 +2084,9 @@ function safeGet(ctx, name) {
|
|
|
2085
2084
|
// src/http-dispatcher.ts
|
|
2086
2085
|
init_package_state_store();
|
|
2087
2086
|
import { getEnv, resolveLocale, evaluateAuthGate, isAuthGateAllowlisted } from "@objectstack/core";
|
|
2087
|
+
import { isMcpServerEnabled } from "@objectstack/types";
|
|
2088
2088
|
import { CoreServiceName } from "@objectstack/spec/system";
|
|
2089
|
+
import { MCP_OAUTH_SCOPES } from "@objectstack/spec/ai";
|
|
2089
2090
|
import { pluralToSingular, PLURAL_TO_SINGULAR } from "@objectstack/spec/shared";
|
|
2090
2091
|
|
|
2091
2092
|
// src/api-exposure.ts
|
|
@@ -2122,6 +2123,12 @@ import {
|
|
|
2122
2123
|
resolveAuthzContext,
|
|
2123
2124
|
resolveLocalizationContext
|
|
2124
2125
|
} from "@objectstack/core";
|
|
2126
|
+
function extractJwtBearer(headers) {
|
|
2127
|
+
const auth = headers.get("authorization");
|
|
2128
|
+
const bearer = auth?.match(/^Bearer\s+(\S+)$/i)?.[1];
|
|
2129
|
+
if (!bearer || bearer.startsWith("osk_")) return void 0;
|
|
2130
|
+
return bearer.split(".").length === 3 ? bearer : void 0;
|
|
2131
|
+
}
|
|
2125
2132
|
function toHeaders(input) {
|
|
2126
2133
|
if (!input) return new Headers();
|
|
2127
2134
|
if (typeof Headers !== "undefined" && input instanceof Headers) return input;
|
|
@@ -2140,6 +2147,22 @@ function toHeaders(input) {
|
|
|
2140
2147
|
async function resolveExecutionContext(opts) {
|
|
2141
2148
|
const headers = toHeaders(opts.request?.headers);
|
|
2142
2149
|
const ql = await opts.getQl();
|
|
2150
|
+
let oauthPrincipal;
|
|
2151
|
+
let oauthBearerPresented = false;
|
|
2152
|
+
if (opts.acceptOAuthAccessToken) {
|
|
2153
|
+
const jwtBearer = extractJwtBearer(headers);
|
|
2154
|
+
if (jwtBearer) {
|
|
2155
|
+
oauthBearerPresented = true;
|
|
2156
|
+
try {
|
|
2157
|
+
const authService = await opts.getService("auth");
|
|
2158
|
+
const verified = await authService?.verifyMcpAccessToken?.(jwtBearer);
|
|
2159
|
+
if (verified?.userId && Array.isArray(verified.scopes)) {
|
|
2160
|
+
oauthPrincipal = verified;
|
|
2161
|
+
}
|
|
2162
|
+
} catch {
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2143
2166
|
const getSession = async (h) => {
|
|
2144
2167
|
try {
|
|
2145
2168
|
const authService = await opts.getService("auth");
|
|
@@ -2150,19 +2173,29 @@ async function resolveExecutionContext(opts) {
|
|
|
2150
2173
|
return void 0;
|
|
2151
2174
|
}
|
|
2152
2175
|
};
|
|
2153
|
-
const
|
|
2176
|
+
const getSessionForProvenance = oauthPrincipal ? async () => ({ user: { id: oauthPrincipal.userId } }) : oauthBearerPresented ? async () => void 0 : getSession;
|
|
2177
|
+
const authz = await resolveAuthzContext({ ql, headers, getSession: getSessionForProvenance });
|
|
2154
2178
|
const ctx = {
|
|
2155
|
-
|
|
2179
|
+
positions: authz.positions,
|
|
2156
2180
|
permissions: authz.permissions,
|
|
2157
2181
|
systemPermissions: authz.systemPermissions,
|
|
2158
2182
|
isSystem: false
|
|
2159
2183
|
};
|
|
2184
|
+
if (authz.userId) {
|
|
2185
|
+
ctx.principalKind = "human";
|
|
2186
|
+
} else {
|
|
2187
|
+
ctx.principalKind = "guest";
|
|
2188
|
+
ctx.positions = ["guest"];
|
|
2189
|
+
}
|
|
2160
2190
|
if (authz.userId) ctx.userId = authz.userId;
|
|
2161
2191
|
if (authz.tenantId) ctx.tenantId = authz.tenantId;
|
|
2162
2192
|
if (authz.email) ctx.email = authz.email;
|
|
2163
2193
|
if (authz.accessToken) ctx.accessToken = authz.accessToken;
|
|
2164
2194
|
if (authz.tabPermissions) ctx.tabPermissions = authz.tabPermissions;
|
|
2165
2195
|
ctx.org_user_ids = authz.org_user_ids;
|
|
2196
|
+
if (oauthPrincipal && ctx.userId === oauthPrincipal.userId) {
|
|
2197
|
+
ctx.oauthScopes = oauthPrincipal.scopes;
|
|
2198
|
+
}
|
|
2166
2199
|
if (authz.userId) {
|
|
2167
2200
|
const settings = await Promise.resolve(opts.getService("settings")).catch(() => void 0);
|
|
2168
2201
|
const localization = await resolveLocalizationContext({
|
|
@@ -2414,9 +2447,10 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2414
2447
|
* Handle an MCP request over the Streamable HTTP transport (`/mcp`).
|
|
2415
2448
|
*
|
|
2416
2449
|
* Gating + auth (fail-closed):
|
|
2417
|
-
* - **
|
|
2418
|
-
* runtime
|
|
2419
|
-
* return 404 so the
|
|
2450
|
+
* - **default-on**: served unless `OS_MCP_SERVER_ENABLED=false` (single-env
|
|
2451
|
+
* runtime; MCP is a core platform capability). Multi-tenant cloud
|
|
2452
|
+
* overrides this gate per env. When opted out we return 404 so the
|
|
2453
|
+
* surface isn't advertised.
|
|
2420
2454
|
* - **auth**: requires a principal already resolved by
|
|
2421
2455
|
* `resolveExecutionContext` (the `sys_api_key` Bearer/header path or a
|
|
2422
2456
|
* session). Anonymous → 401.
|
|
@@ -2436,7 +2470,29 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2436
2470
|
}
|
|
2437
2471
|
const ec = context.executionContext;
|
|
2438
2472
|
if (!ec || !ec.userId && !ec.isSystem) {
|
|
2439
|
-
|
|
2473
|
+
const resourceMetadataUrl = await this.getMcpResourceMetadataUrl(context);
|
|
2474
|
+
const response = this.error(
|
|
2475
|
+
resourceMetadataUrl ? "Unauthorized: a valid OAuth access token or API key is required" : "Unauthorized: a valid API key is required",
|
|
2476
|
+
401
|
|
2477
|
+
);
|
|
2478
|
+
if (resourceMetadataUrl) {
|
|
2479
|
+
response.headers = {
|
|
2480
|
+
"WWW-Authenticate": `Bearer realm="ObjectStack MCP", resource_metadata="${resourceMetadataUrl}"`
|
|
2481
|
+
};
|
|
2482
|
+
}
|
|
2483
|
+
return { handled: true, response };
|
|
2484
|
+
}
|
|
2485
|
+
const grantedScopes = Array.isArray(ec.oauthScopes) ? ec.oauthScopes : void 0;
|
|
2486
|
+
if (grantedScopes && !grantedScopes.some((s) => MCP_OAUTH_SCOPES.includes(s))) {
|
|
2487
|
+
const resourceMetadataUrl = await this.getMcpResourceMetadataUrl(context);
|
|
2488
|
+
const response = this.error(
|
|
2489
|
+
`Forbidden: the access token grants none of the MCP scopes (${MCP_OAUTH_SCOPES.join(", ")})`,
|
|
2490
|
+
403
|
|
2491
|
+
);
|
|
2492
|
+
response.headers = {
|
|
2493
|
+
"WWW-Authenticate": `Bearer error="insufficient_scope", scope="${MCP_OAUTH_SCOPES.join(" ")}"` + (resourceMetadataUrl ? `, resource_metadata="${resourceMetadataUrl}"` : "")
|
|
2494
|
+
};
|
|
2495
|
+
return { handled: true, response };
|
|
2440
2496
|
}
|
|
2441
2497
|
const webRequest = this.toMcpWebRequest(context.request, body);
|
|
2442
2498
|
if (!webRequest) {
|
|
@@ -2445,7 +2501,13 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2445
2501
|
const bridge = this.buildMcpBridge(context);
|
|
2446
2502
|
let webRes;
|
|
2447
2503
|
try {
|
|
2448
|
-
webRes = await mcp.handleHttpRequest(webRequest, {
|
|
2504
|
+
webRes = await mcp.handleHttpRequest(webRequest, {
|
|
2505
|
+
bridge,
|
|
2506
|
+
parsedBody: body,
|
|
2507
|
+
// undefined = not scope-limited (API key / session); an array
|
|
2508
|
+
// narrows the registered tool families inside the MCP runtime.
|
|
2509
|
+
...grantedScopes ? { toolOptions: { grantedScopes } } : {}
|
|
2510
|
+
});
|
|
2449
2511
|
} catch (err) {
|
|
2450
2512
|
return { handled: true, response: this.error(err?.message ?? "MCP request failed", 500) };
|
|
2451
2513
|
}
|
|
@@ -2472,9 +2534,95 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2472
2534
|
}
|
|
2473
2535
|
return { handled: true, response: { status: webRes.status, headers, body: responseBody } };
|
|
2474
2536
|
}
|
|
2475
|
-
/**
|
|
2537
|
+
/**
|
|
2538
|
+
* Whether the MCP HTTP surface is on for this single-env runtime.
|
|
2539
|
+
* Default-on core capability; `OS_MCP_SERVER_ENABLED=false` opts out
|
|
2540
|
+
* (single decision point: `isMcpServerEnabled` in `@objectstack/types`).
|
|
2541
|
+
*/
|
|
2476
2542
|
static isMcpEnabled() {
|
|
2477
|
-
return
|
|
2543
|
+
return isMcpServerEnabled();
|
|
2544
|
+
}
|
|
2545
|
+
/**
|
|
2546
|
+
* Absolute URL of the RFC 9728 protected-resource metadata for the MCP
|
|
2547
|
+
* endpoint, advertised via `WWW-Authenticate` (#2698). `null` when the
|
|
2548
|
+
* OAuth track is off — the auth service owns the decision (AS enabled +
|
|
2549
|
+
* OAuth 2.1 TLS rule), the dispatcher only relays it. Never throws.
|
|
2550
|
+
*/
|
|
2551
|
+
async getMcpResourceMetadataUrl(context) {
|
|
2552
|
+
try {
|
|
2553
|
+
const authService = await this.resolveService("auth", context.environmentId);
|
|
2554
|
+
const url = authService?.getMcpResourceMetadataUrl?.();
|
|
2555
|
+
return typeof url === "string" && url ? url : null;
|
|
2556
|
+
} catch {
|
|
2557
|
+
return null;
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
/**
|
|
2561
|
+
* `GET /mcp/skill` — the environment-customized portable Agent Skill
|
|
2562
|
+
* (`SKILL.md`), rendered by the MCP service (ADR-0036 Amendment C: ONE
|
|
2563
|
+
* generic skill; only the connection URL is environment-specific).
|
|
2564
|
+
*
|
|
2565
|
+
* Served PUBLIC like `/discovery`: the content is generic agent
|
|
2566
|
+
* instructions plus a URL the caller already knows — no schema, no
|
|
2567
|
+
* tenant data. Gated on the same default-on switch as the `/mcp` route
|
|
2568
|
+
* (404 when opted out, so the surface isn't advertised) and 501 when the
|
|
2569
|
+
* MCP plugin isn't loaded, mirroring `handleMcp`.
|
|
2570
|
+
*/
|
|
2571
|
+
async handleMcpSkill(method, context) {
|
|
2572
|
+
if (!_HttpDispatcher.isMcpEnabled()) {
|
|
2573
|
+
return { handled: true, response: this.error("MCP server is not enabled for this environment", 404) };
|
|
2574
|
+
}
|
|
2575
|
+
if (method !== "GET") {
|
|
2576
|
+
return {
|
|
2577
|
+
handled: true,
|
|
2578
|
+
response: {
|
|
2579
|
+
status: 405,
|
|
2580
|
+
headers: { Allow: "GET" },
|
|
2581
|
+
body: { success: false, error: { message: "Method not allowed \u2014 use GET", code: 405 } }
|
|
2582
|
+
}
|
|
2583
|
+
};
|
|
2584
|
+
}
|
|
2585
|
+
const mcp = await this.resolveService("mcp", context.environmentId);
|
|
2586
|
+
if (!mcp || typeof mcp.renderSkill !== "function") {
|
|
2587
|
+
return { handled: true, response: this.error("MCP server is not available", 501) };
|
|
2588
|
+
}
|
|
2589
|
+
let mcpUrl;
|
|
2590
|
+
try {
|
|
2591
|
+
const authService = await this.resolveService("auth", context.environmentId);
|
|
2592
|
+
const url = authService?.getMcpResourceUrl?.();
|
|
2593
|
+
if (typeof url === "string" && url) mcpUrl = url;
|
|
2594
|
+
} catch {
|
|
2595
|
+
}
|
|
2596
|
+
if (!mcpUrl) {
|
|
2597
|
+
try {
|
|
2598
|
+
const webReq = this.toMcpWebRequest(context.request, void 0);
|
|
2599
|
+
const host = webReq?.headers.get("host");
|
|
2600
|
+
if (host) {
|
|
2601
|
+
const proto = webReq?.headers.get("x-forwarded-proto") || "http";
|
|
2602
|
+
mcpUrl = `${proto}://${host}/api/v1/mcp`;
|
|
2603
|
+
}
|
|
2604
|
+
} catch {
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
const markdown = mcp.renderSkill({ mcpUrl });
|
|
2608
|
+
return {
|
|
2609
|
+
handled: true,
|
|
2610
|
+
result: {
|
|
2611
|
+
type: "stream",
|
|
2612
|
+
status: 200,
|
|
2613
|
+
contentType: "text/markdown; charset=utf-8",
|
|
2614
|
+
headers: {
|
|
2615
|
+
"content-type": "text/markdown; charset=utf-8",
|
|
2616
|
+
"content-disposition": 'inline; filename="SKILL.md"',
|
|
2617
|
+
// Same reasoning as /discovery (cloud#152): reflects mutable
|
|
2618
|
+
// runtime config (base URL), must never be edge-cached stale.
|
|
2619
|
+
"cache-control": "no-store"
|
|
2620
|
+
},
|
|
2621
|
+
events: (async function* () {
|
|
2622
|
+
yield markdown;
|
|
2623
|
+
})()
|
|
2624
|
+
}
|
|
2625
|
+
};
|
|
2478
2626
|
}
|
|
2479
2627
|
/**
|
|
2480
2628
|
* Normalise the inbound request into a Web-standard `Request` for the MCP
|
|
@@ -3132,9 +3280,9 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3132
3280
|
notifications: hasNotification ? `${prefix}/notifications` : void 0,
|
|
3133
3281
|
ai: hasAi ? `${prefix}/ai` : void 0,
|
|
3134
3282
|
i18n: hasI18n ? `${prefix}/i18n` : void 0,
|
|
3135
|
-
// MCP (Streamable HTTP) is
|
|
3136
|
-
//
|
|
3137
|
-
//
|
|
3283
|
+
// MCP (Streamable HTTP) is a default-on core capability —
|
|
3284
|
+
// advertised unless OS_MCP_SERVER_ENABLED=false opts the env
|
|
3285
|
+
// out. The objectui Integrations page reads this.
|
|
3138
3286
|
mcp: _HttpDispatcher.isMcpEnabled() ? `${prefix}/mcp` : void 0
|
|
3139
3287
|
};
|
|
3140
3288
|
const svcAvailable = (route, provider) => ({
|
|
@@ -3641,6 +3789,56 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3641
3789
|
}
|
|
3642
3790
|
return { handled: false };
|
|
3643
3791
|
}
|
|
3792
|
+
/**
|
|
3793
|
+
* Handle the security admin surface (`/security/...`) — ADR-0090 D5/D9
|
|
3794
|
+
* suggested audience bindings. A package's `isDefault: true` permission
|
|
3795
|
+
* set is an install-time SUGGESTION to bind it to the `everyone` position;
|
|
3796
|
+
* these routes let an admin see and resolve those suggestions. The
|
|
3797
|
+
* `security` service does the real gating (tenant-admin pre-check, and the
|
|
3798
|
+
* confirm write runs under the audience-anchor + delegated-admin gates
|
|
3799
|
+
* with the caller's execution context — never auto-bound, never system).
|
|
3800
|
+
*
|
|
3801
|
+
* Routes:
|
|
3802
|
+
* GET /security/suggested-bindings?status=&packageId= → list (reconciles first)
|
|
3803
|
+
* POST /security/suggested-bindings/:id/confirm → create the anchor binding
|
|
3804
|
+
* POST /security/suggested-bindings/:id/dismiss → decline the suggestion
|
|
3805
|
+
*/
|
|
3806
|
+
async handleSecurity(path, method, _body, query, context) {
|
|
3807
|
+
const service = await this.resolveService("security", context.environmentId);
|
|
3808
|
+
if (!service || typeof service.listAudienceBindingSuggestions !== "function") {
|
|
3809
|
+
return { handled: true, response: this.error("Security service not available", 503) };
|
|
3810
|
+
}
|
|
3811
|
+
const ec = context.executionContext;
|
|
3812
|
+
if (!ec?.userId && !ec?.isSystem) {
|
|
3813
|
+
return { handled: true, response: this.error("Authentication required", 401) };
|
|
3814
|
+
}
|
|
3815
|
+
const m = method.toUpperCase();
|
|
3816
|
+
const parts = path.split("/").filter(Boolean);
|
|
3817
|
+
if (parts[0] !== "suggested-bindings") return { handled: false };
|
|
3818
|
+
try {
|
|
3819
|
+
if (parts.length === 1 && m === "GET") {
|
|
3820
|
+
const status = query?.status ? String(query.status) : void 0;
|
|
3821
|
+
const packageId = query?.packageId ? String(query.packageId) : void 0;
|
|
3822
|
+
const result = await service.listAudienceBindingSuggestions(ec, { status, packageId });
|
|
3823
|
+
return { handled: true, response: this.success(result) };
|
|
3824
|
+
}
|
|
3825
|
+
if (parts.length === 3 && m === "POST") {
|
|
3826
|
+
const id = decodeURIComponent(parts[1]);
|
|
3827
|
+
if (parts[2] === "confirm") {
|
|
3828
|
+
const result = await service.confirmAudienceBindingSuggestion(ec, id);
|
|
3829
|
+
return { handled: true, response: this.success(result) };
|
|
3830
|
+
}
|
|
3831
|
+
if (parts[2] === "dismiss") {
|
|
3832
|
+
const result = await service.dismissAudienceBindingSuggestion(ec, id);
|
|
3833
|
+
return { handled: true, response: this.success(result) };
|
|
3834
|
+
}
|
|
3835
|
+
}
|
|
3836
|
+
return { handled: false };
|
|
3837
|
+
} catch (err) {
|
|
3838
|
+
const status = typeof err?.statusCode === "number" ? err.statusCode : 500;
|
|
3839
|
+
return { handled: true, response: this.error(err?.message ?? "Security operation failed", status) };
|
|
3840
|
+
}
|
|
3841
|
+
}
|
|
3644
3842
|
/**
|
|
3645
3843
|
* Handles i18n requests
|
|
3646
3844
|
* path: sub-path after /i18n/
|
|
@@ -4424,7 +4622,7 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
4424
4622
|
const ec = context?.executionContext;
|
|
4425
4623
|
const userIdFromAuth = context?.user?.id ?? context?.userId ?? ec?.userId;
|
|
4426
4624
|
if (userIdFromAuth) automationContext.userId = userIdFromAuth;
|
|
4427
|
-
if (Array.isArray(ec?.
|
|
4625
|
+
if (Array.isArray(ec?.positions) && ec.positions.length) automationContext.positions = ec.positions;
|
|
4428
4626
|
if (Array.isArray(ec?.permissions) && ec.permissions.length) automationContext.permissions = ec.permissions;
|
|
4429
4627
|
if (ec?.tenantId) automationContext.tenantId = ec.tenantId;
|
|
4430
4628
|
const result = await automationService.execute(name, automationContext);
|
|
@@ -4646,8 +4844,16 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
4646
4844
|
return Array.isArray(rows) ? rows : rows?.value ?? [];
|
|
4647
4845
|
}
|
|
4648
4846
|
};
|
|
4649
|
-
const
|
|
4650
|
-
const userFromAuth =
|
|
4847
|
+
const ec = _context?.executionContext;
|
|
4848
|
+
const userFromAuth = ec?.userId ? {
|
|
4849
|
+
id: ec.userId,
|
|
4850
|
+
name: ec.userId,
|
|
4851
|
+
email: ec.email,
|
|
4852
|
+
roles: Array.isArray(ec.positions) ? ec.positions : [],
|
|
4853
|
+
positions: Array.isArray(ec.positions) ? ec.positions : [],
|
|
4854
|
+
permissions: Array.isArray(ec.permissions) ? ec.permissions : [],
|
|
4855
|
+
tenantId: ec.tenantId
|
|
4856
|
+
} : { id: "system", name: "system", roles: [], positions: [], permissions: [] };
|
|
4651
4857
|
const actionContext = {
|
|
4652
4858
|
record,
|
|
4653
4859
|
user: userFromAuth,
|
|
@@ -4732,7 +4938,7 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
4732
4938
|
id: ec.userId,
|
|
4733
4939
|
displayName: ec.userDisplayName ?? ec.userName ?? ec.userId,
|
|
4734
4940
|
email: ec.userEmail,
|
|
4735
|
-
roles: Array.isArray(ec.
|
|
4941
|
+
roles: Array.isArray(ec.positions) ? ec.positions : [],
|
|
4736
4942
|
permissions: Array.isArray(ec.permissions) ? ec.permissions : [],
|
|
4737
4943
|
organizationId: ec.tenantId
|
|
4738
4944
|
} : void 0;
|
|
@@ -4796,7 +5002,7 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
4796
5002
|
if (!svc) {
|
|
4797
5003
|
return { handled: true, response: this.error("Sharing is not configured for this environment", 501) };
|
|
4798
5004
|
}
|
|
4799
|
-
const SYSTEM_CTX = { isSystem: true,
|
|
5005
|
+
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] };
|
|
4800
5006
|
const m = method.toUpperCase();
|
|
4801
5007
|
const parts = subPath.replace(/^\/+/, "").split("/").filter(Boolean);
|
|
4802
5008
|
const ec = context.executionContext;
|
|
@@ -4975,7 +5181,13 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
4975
5181
|
}
|
|
4976
5182
|
return this.getObjectQLService(context.environmentId);
|
|
4977
5183
|
},
|
|
4978
|
-
request: context.request
|
|
5184
|
+
request: context.request,
|
|
5185
|
+
// OAuth 2.1 access tokens are honoured ONLY on the MCP
|
|
5186
|
+
// surface (#2698): their coarse tool-family scopes are
|
|
5187
|
+
// enforced at MCP tool dispatch, which other routes don't do.
|
|
5188
|
+
// Matches the plain and `/projects/:id`-scoped route forms
|
|
5189
|
+
// (the scoped prefix is stripped only later, below).
|
|
5190
|
+
acceptOAuthAccessToken: /^(?:\/projects\/[^/]+)?\/mcp(?:[/?]|$)/.test(cleanPath)
|
|
4979
5191
|
});
|
|
4980
5192
|
} catch {
|
|
4981
5193
|
}
|
|
@@ -5023,6 +5235,9 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
5023
5235
|
if (cleanPath.startsWith("/data")) {
|
|
5024
5236
|
return this.handleData(cleanPath.substring(5), method, body, query, context);
|
|
5025
5237
|
}
|
|
5238
|
+
if (cleanPath === "/mcp/skill" || cleanPath.startsWith("/mcp/skill?")) {
|
|
5239
|
+
return this.handleMcpSkill(method, context);
|
|
5240
|
+
}
|
|
5026
5241
|
if (cleanPath === "/mcp" || cleanPath.startsWith("/mcp/") || cleanPath.startsWith("/mcp?")) {
|
|
5027
5242
|
return this.handleMcp(body, context);
|
|
5028
5243
|
}
|
|
@@ -5050,6 +5265,9 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
5050
5265
|
if (cleanPath.startsWith("/notifications")) {
|
|
5051
5266
|
return this.handleNotification(cleanPath.substring(14), method, body, query, context);
|
|
5052
5267
|
}
|
|
5268
|
+
if (cleanPath === "/security" || cleanPath.startsWith("/security/")) {
|
|
5269
|
+
return this.handleSecurity(cleanPath.substring(9), method, body, query, context);
|
|
5270
|
+
}
|
|
5053
5271
|
if (cleanPath.startsWith("/packages")) {
|
|
5054
5272
|
return this.handlePackages(cleanPath.substring(9), method, body, query, context);
|
|
5055
5273
|
}
|
|
@@ -5768,6 +5986,14 @@ function createDispatcherPlugin(config = {}) {
|
|
|
5768
5986
|
mountMcp("POST");
|
|
5769
5987
|
mountMcp("GET");
|
|
5770
5988
|
mountMcp("DELETE");
|
|
5989
|
+
server.get(`${prefix}/mcp/skill`, async (req, res) => {
|
|
5990
|
+
try {
|
|
5991
|
+
const result = await dispatcher.dispatch("GET", "/mcp/skill", req.body, req.query, { request: req });
|
|
5992
|
+
sendResult(result, res);
|
|
5993
|
+
} catch (err) {
|
|
5994
|
+
errorResponse(err, res);
|
|
5995
|
+
}
|
|
5996
|
+
});
|
|
5771
5997
|
server.post(`${prefix}/keys`, async (req, res) => {
|
|
5772
5998
|
try {
|
|
5773
5999
|
const result = await dispatcher.dispatch("POST", "/keys", req.body, req.query, { request: req });
|
|
@@ -6072,9 +6298,7 @@ function createDispatcherPlugin(config = {}) {
|
|
|
6072
6298
|
const registerActionRoutes = (base) => {
|
|
6073
6299
|
server.post(`${base}/actions/:object/:action`, async (req, res) => {
|
|
6074
6300
|
try {
|
|
6075
|
-
const
|
|
6076
|
-
if (req.params?.environmentId) ctx2.environmentId = req.params.environmentId;
|
|
6077
|
-
const result = await dispatcher.handleActions(`/${req.params.object}/${req.params.action}`, "POST", req.body, ctx2);
|
|
6301
|
+
const result = await dispatcher.dispatch("POST", `/actions/${req.params.object}/${req.params.action}`, req.body, req.query, { request: req });
|
|
6078
6302
|
sendResult(result, res);
|
|
6079
6303
|
} catch (err) {
|
|
6080
6304
|
errorResponse(err, res);
|
|
@@ -6082,9 +6306,7 @@ function createDispatcherPlugin(config = {}) {
|
|
|
6082
6306
|
});
|
|
6083
6307
|
server.post(`${base}/actions/:object/:action/:recordId`, async (req, res) => {
|
|
6084
6308
|
try {
|
|
6085
|
-
const
|
|
6086
|
-
if (req.params?.environmentId) ctx2.environmentId = req.params.environmentId;
|
|
6087
|
-
const result = await dispatcher.handleActions(`/${req.params.object}/${req.params.action}/${req.params.recordId}`, "POST", req.body, ctx2);
|
|
6309
|
+
const result = await dispatcher.dispatch("POST", `/actions/${req.params.object}/${req.params.action}/${req.params.recordId}`, req.body, req.query, { request: req });
|
|
6088
6310
|
sendResult(result, res);
|
|
6089
6311
|
} catch (err) {
|
|
6090
6312
|
errorResponse(err, res);
|
|
@@ -6126,7 +6348,7 @@ function createDispatcherPlugin(config = {}) {
|
|
|
6126
6348
|
id: userId,
|
|
6127
6349
|
displayName: sessionData?.user?.name ?? sessionData?.user?.email ?? userId,
|
|
6128
6350
|
email: sessionData?.user?.email,
|
|
6129
|
-
|
|
6351
|
+
positions: [],
|
|
6130
6352
|
permissions: [],
|
|
6131
6353
|
organizationId: sessionData?.session?.activeOrganizationId
|
|
6132
6354
|
};
|