feat(foundry): productionize Cesium Map Page and platform runtime
This commit is contained in:
@@ -0,0 +1,425 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
const MCP_PROTOCOL_VERSION = "2025-06-18";
|
||||
const MAX_BODY_BYTES = 1024 * 1024;
|
||||
|
||||
function sendJson(response, statusCode, payload) {
|
||||
response.writeHead(statusCode, {
|
||||
"cache-control": "no-store",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
});
|
||||
response.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
async function readJsonBody(request, maxBytes = MAX_BODY_BYTES) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length;
|
||||
if (size > maxBytes) throw new Error("payload_too_large");
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
}
|
||||
|
||||
function timingSafeTokenEqual(left, right) {
|
||||
const leftBuffer = Buffer.from(String(left || ""));
|
||||
const rightBuffer = Buffer.from(String(right || ""));
|
||||
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
|
||||
}
|
||||
|
||||
function bearerToken(request) {
|
||||
const value = String(request.headers.authorization || "");
|
||||
return value.startsWith("Bearer ") ? value.slice("Bearer ".length).trim() : "";
|
||||
}
|
||||
|
||||
function originAllowed(request, allowedOrigins) {
|
||||
const origin = String(request.headers.origin || "").trim();
|
||||
if (!origin) return true;
|
||||
return allowedOrigins.has(origin);
|
||||
}
|
||||
|
||||
function base64UrlJson(value) {
|
||||
return Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
function parseBase64UrlJson(value) {
|
||||
try {
|
||||
return JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function signCapability(encodedPayload, secret) {
|
||||
return createHmac("sha256", secret)
|
||||
.update(`nodedc.module-foundry.mcp-capability.v1.${encodedPayload}`)
|
||||
.digest("base64url");
|
||||
}
|
||||
|
||||
function mintMcpCapability(identity, config) {
|
||||
const now = Date.now();
|
||||
const payload = {
|
||||
schemaVersion: "nodedc.module-foundry.mcp-capability.v1",
|
||||
service: "module-foundry",
|
||||
actorId: identity.actorId,
|
||||
ownerKey: identity.ownerKey,
|
||||
issuedAt: now,
|
||||
expiresAt: now + config.mcpCapabilityTtlMs,
|
||||
};
|
||||
const encodedPayload = base64UrlJson(payload);
|
||||
return `fnd1.${encodedPayload}.${signCapability(encodedPayload, config.capabilitySecret)}`;
|
||||
}
|
||||
|
||||
function actorFromMcpCapability(token, config) {
|
||||
const [prefix, encodedPayload, signature, ...rest] = String(token || "").split(".");
|
||||
if (prefix !== "fnd1" || !encodedPayload || !signature || rest.length || !config.capabilitySecret) return null;
|
||||
const expectedSignature = signCapability(encodedPayload, config.capabilitySecret);
|
||||
if (!timingSafeTokenEqual(signature, expectedSignature)) return null;
|
||||
const payload = parseBase64UrlJson(encodedPayload);
|
||||
if (!payload || payload.schemaVersion !== "nodedc.module-foundry.mcp-capability.v1" || payload.service !== "module-foundry") return null;
|
||||
const actorId = String(payload.actorId || "").trim();
|
||||
const ownerKey = String(payload.ownerKey || "").trim();
|
||||
const expiresAt = Number(payload.expiresAt);
|
||||
const issuedAt = Number(payload.issuedAt);
|
||||
if (!actorId || !ownerKey || !Number.isFinite(expiresAt) || !Number.isFinite(issuedAt)) return null;
|
||||
if (issuedAt > Date.now() + 60_000 || expiresAt <= Date.now() || expiresAt - issuedAt > config.mcpCapabilityTtlMs + 60_000) return null;
|
||||
return { actorId, ownerKey };
|
||||
}
|
||||
|
||||
function mcpError(id, code, message, data) {
|
||||
return {
|
||||
jsonrpc: "2.0",
|
||||
id: id ?? null,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
...(data === undefined ? {} : { data }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mcpResult(id, result) {
|
||||
return { jsonrpc: "2.0", id: id ?? null, result };
|
||||
}
|
||||
|
||||
function toolText(payload, isError = false) {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
||||
structuredContent: payload,
|
||||
...(isError ? { isError: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMcpError(error) {
|
||||
const code = String(error?.message || "foundry_operation_failed");
|
||||
if (code === "application_not_found") return { code, status: 404 };
|
||||
if (code.startsWith("invalid_") || code.startsWith("unknown_") || code.startsWith("map_") || code.endsWith("_required")) return { code, status: 400 };
|
||||
if (code.startsWith("duplicate_") || code.includes("conflict") || code.includes("mismatch")) return { code, status: 409 };
|
||||
return { code: "foundry_operation_failed", status: 500 };
|
||||
}
|
||||
|
||||
const tools = [
|
||||
{
|
||||
name: "foundry_status",
|
||||
title: "NDC Module Foundry status",
|
||||
description: "Read the availability and baseline scope of NDC Module Foundry. Canonical Page Library templates are read-only.",
|
||||
inputSchema: { type: "object", additionalProperties: false, properties: {} },
|
||||
},
|
||||
{
|
||||
name: "foundry_list_applications",
|
||||
title: "List application instances",
|
||||
description: "List editable application instances in NDC Module Foundry. This does not mutate Page Library.",
|
||||
inputSchema: { type: "object", additionalProperties: false, properties: {} },
|
||||
},
|
||||
{
|
||||
name: "foundry_get_application",
|
||||
title: "Get application instance",
|
||||
description: "Read one editable application instance, its page instances, features and map pin bindings.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["applicationId"],
|
||||
properties: { applicationId: { type: "string", description: "UUID of an application instance." } },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "foundry_create_application",
|
||||
title: "Create application instance",
|
||||
description: "Create a draft application instance from a registered page template. The canonical template itself remains unchanged. Application deletion is deliberately unavailable.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["name", "idempotencyKey"],
|
||||
properties: {
|
||||
name: { type: "string", description: "Human-readable application name." },
|
||||
idempotencyKey: { type: "string", description: "Stable key for replay-safe writes." },
|
||||
slug: { type: "string" },
|
||||
description: { type: "string" },
|
||||
templateId: { type: "string", description: "Registered Page Library template id. Defaults to map." },
|
||||
templateVersion: { type: "string" },
|
||||
theme: { type: "string", enum: ["dark", "light"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "foundry_update_application_metadata",
|
||||
title: "Update application metadata",
|
||||
description: "Rename or describe an application instance. It cannot change canonical templates or delete an application.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["applicationId", "idempotencyKey"],
|
||||
properties: {
|
||||
applicationId: { type: "string" },
|
||||
idempotencyKey: { type: "string" },
|
||||
name: { type: "string" },
|
||||
slug: { type: "string" },
|
||||
description: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "foundry_add_page_instance",
|
||||
title: "Add page instance to application",
|
||||
description: "Create one more instance of a registered Page Library template inside an application. A template may be used repeatedly; Page Library remains read-only.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["applicationId", "templateId", "idempotencyKey"],
|
||||
properties: {
|
||||
applicationId: { type: "string" },
|
||||
templateId: { type: "string", description: "Registered Page Library template id." },
|
||||
templateVersion: { type: "string" },
|
||||
idempotencyKey: { type: "string" },
|
||||
title: { type: "string" },
|
||||
navigationLabel: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "foundry_upsert_map_pin_binding",
|
||||
title: "Upsert map pin binding",
|
||||
description: "Create or update a visual map-pin binding on a Map Page instance. This stores a stable visual binding; it does not call a provider, Engine or an external data source.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["applicationId", "pageId", "idempotencyKey", "binding"],
|
||||
properties: {
|
||||
applicationId: { type: "string" },
|
||||
pageId: { type: "string", description: "Map Page instance id inside the application." },
|
||||
idempotencyKey: { type: "string" },
|
||||
binding: {
|
||||
type: "object",
|
||||
description: "Provider-neutral map-pin binding. Coordinates and source entity are mandatory.",
|
||||
required: ["id", "subjectId", "coordinates", "source"],
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
subjectId: { type: "string" },
|
||||
label: { type: "string" },
|
||||
status: { type: "string" },
|
||||
coordinates: {
|
||||
type: "object",
|
||||
required: ["longitude", "latitude"],
|
||||
properties: {
|
||||
longitude: { type: "number" },
|
||||
latitude: { type: "number" },
|
||||
heightMeters: { type: "number" },
|
||||
},
|
||||
},
|
||||
source: {
|
||||
type: "object",
|
||||
required: ["entityId"],
|
||||
properties: {
|
||||
entityId: { type: "string" },
|
||||
streamId: { type: "string" },
|
||||
displayFields: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
attributes: { type: "object" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "foundry_upsert_map_data_product_binding",
|
||||
title: "Upsert Map data product binding",
|
||||
description: "Bind one approved provider-neutral data product to an entity-stream slot of a Map Page instance. The binding stores no provider transport, endpoint, tenant, connection or credential.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["applicationId", "pageId", "idempotencyKey", "binding"],
|
||||
properties: {
|
||||
applicationId: { type: "string" },
|
||||
pageId: { type: "string", description: "Map Page instance id inside the application." },
|
||||
idempotencyKey: { type: "string" },
|
||||
binding: {
|
||||
type: "object",
|
||||
required: ["id", "dataProductId", "slotId", "semanticTypes"],
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
dataProductId: { type: "string", description: "Versioned provider-neutral product, for example fleet.positions.current.v1." },
|
||||
slotId: { type: "string", description: "Approved Map Page entity-stream slot." },
|
||||
semanticTypes: { type: "array", items: { type: "string" } },
|
||||
fieldProjection: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function toolMap(operations) {
|
||||
return {
|
||||
foundry_status: () => operations.status(),
|
||||
foundry_list_applications: () => operations.listApplications(),
|
||||
foundry_get_application: (input) => operations.getApplication(input.applicationId),
|
||||
foundry_create_application: (input, actor) => operations.createApplication(input, actor),
|
||||
foundry_update_application_metadata: (input, actor) => operations.updateApplicationMetadata(input, actor),
|
||||
foundry_add_page_instance: (input, actor) => operations.addPageInstance(input, actor),
|
||||
foundry_upsert_map_pin_binding: (input, actor) => operations.upsertMapPinBinding(input, actor),
|
||||
foundry_upsert_map_data_product_binding: (input, actor) => operations.upsertMapDataProductBinding(input, actor),
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleFoundryMcpRequest(request, response, options) {
|
||||
const config = options.getConfig();
|
||||
const allowedOrigins = new Set(config.mcpAllowedOrigins || []);
|
||||
if (request.method !== "POST") {
|
||||
response.writeHead(405, { Allow: "POST" });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
if (!originAllowed(request, allowedOrigins)) return sendJson(response, 403, { error: "mcp_origin_not_allowed" });
|
||||
if (!config.capabilitySecret) return sendJson(response, 503, { error: "foundry_mcp_not_configured" });
|
||||
const actor = actorFromMcpCapability(bearerToken(request), config);
|
||||
if (!actor) return sendJson(response, 401, { error: "mcp_unauthorized" });
|
||||
|
||||
let message;
|
||||
try {
|
||||
message = await readJsonBody(request);
|
||||
} catch (error) {
|
||||
return sendJson(response, error?.message === "payload_too_large" ? 413 : 400, { error: error?.message === "payload_too_large" ? "payload_too_large" : "invalid_json" });
|
||||
}
|
||||
if (!message || message.jsonrpc !== "2.0" || typeof message.method !== "string") {
|
||||
return sendJson(response, 400, mcpError(message?.id, -32600, "Invalid Request"));
|
||||
}
|
||||
const id = message.id;
|
||||
if (message.method === "initialize") {
|
||||
return sendJson(response, 200, mcpResult(id, {
|
||||
protocolVersion: MCP_PROTOCOL_VERSION,
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
serverInfo: { name: "nodedc_module_foundry", version: "0.1.0" },
|
||||
instructions: "NDC Module Foundry edits application instances only. Page Library is read-only. Application deletion is unavailable.",
|
||||
}));
|
||||
}
|
||||
if (message.method === "notifications/initialized") return sendJson(response, 202, {});
|
||||
if (message.method === "ping") return sendJson(response, 200, mcpResult(id, {}));
|
||||
if (message.method === "tools/list") return sendJson(response, 200, mcpResult(id, { tools }));
|
||||
if (message.method !== "tools/call") return sendJson(response, 200, mcpError(id, -32601, "Method not found"));
|
||||
|
||||
const params = message.params && typeof message.params === "object" ? message.params : {};
|
||||
const name = String(params.name || "");
|
||||
const handler = toolMap(options.operations)[name];
|
||||
if (!handler) return sendJson(response, 200, mcpError(id, -32602, "Unknown tool"));
|
||||
try {
|
||||
const result = await handler(params.arguments && typeof params.arguments === "object" ? params.arguments : {}, actor);
|
||||
return sendJson(response, 200, mcpResult(id, toolText(result)));
|
||||
} catch (error) {
|
||||
const normalized = normalizeMcpError(error);
|
||||
return sendJson(response, 200, mcpResult(id, toolText({ error: normalized.code }, true)));
|
||||
}
|
||||
}
|
||||
|
||||
function ownerIdentity(owner) {
|
||||
const source = owner && typeof owner === "object" ? owner : {};
|
||||
const actorId = String(source.userId || source.user_id || source.id || "").trim();
|
||||
const ownerKey = String(source.key || source.ownerKey || source.owner_key || actorId).trim();
|
||||
const groups = [source.groups, source.roles, source.roleKeys]
|
||||
.flatMap((value) => Array.isArray(value) ? value : [])
|
||||
.map((value) => String(value || "").trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
return { actorId, ownerKey, groups };
|
||||
}
|
||||
|
||||
function hasFoundryAccess(identity, config) {
|
||||
if (!identity.actorId || !identity.ownerKey) return false;
|
||||
// A Launcher/Authentik block revokes every Foundry surface, including a
|
||||
// previously discoverable AI Workspace entitlement.
|
||||
if (identity.groups.includes("nodedc:module-foundry:blocked")) return false;
|
||||
if (config.allowAllAuthenticated) return true;
|
||||
if (config.allowedOwnerIds.includes(identity.actorId) || config.allowedOwnerIds.includes(identity.ownerKey)) return true;
|
||||
return identity.groups.some((group) => config.allowedOwnerGroups.includes(group));
|
||||
}
|
||||
|
||||
export async function handleFoundryEntitlementRequest(request, response, options) {
|
||||
const config = options.getConfig();
|
||||
if (request.method !== "POST") {
|
||||
response.writeHead(405, { Allow: "POST" });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
if (!config.internalAccessToken || !config.capabilitySecret) return sendJson(response, 503, { ok: false, error: "foundry_entitlement_not_configured" });
|
||||
if (!timingSafeTokenEqual(bearerToken(request), config.internalAccessToken)) return sendJson(response, 401, { ok: false, error: "entitlement_unauthorized" });
|
||||
let body;
|
||||
try {
|
||||
body = await readJsonBody(request);
|
||||
} catch (error) {
|
||||
return sendJson(response, error?.message === "payload_too_large" ? 413 : 400, { ok: false, error: "invalid_json" });
|
||||
}
|
||||
if (body?.schemaVersion !== "ai-workspace.entitlement-request.v1") return sendJson(response, 400, { ok: false, error: "unsupported_entitlement_schema" });
|
||||
if (body?.appId !== "module-foundry") return sendJson(response, 400, { ok: false, error: "unsupported_entitlement_app" });
|
||||
const identity = ownerIdentity(body.owner);
|
||||
const accessAllowed = hasFoundryAccess(identity, config);
|
||||
const grantBase = {
|
||||
appId: "module-foundry",
|
||||
appTitle: "NDC Module Foundry",
|
||||
surface: "module-foundry",
|
||||
scopes: [
|
||||
"foundry.application.read",
|
||||
"foundry.application.create",
|
||||
"foundry.application.update",
|
||||
"foundry.page-instance.create",
|
||||
"foundry.map-pin.upsert",
|
||||
"foundry.map-data-product.upsert",
|
||||
],
|
||||
};
|
||||
if (!accessAllowed) {
|
||||
return sendJson(response, 200, {
|
||||
appGrants: {
|
||||
"module-foundry": {
|
||||
...grantBase,
|
||||
enabled: false,
|
||||
denied: true,
|
||||
reason: "foundry_access_not_granted",
|
||||
deniedText: "NDC Module Foundry недоступен в текущем контексте.",
|
||||
mcpServers: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!config.mcpUrl) return sendJson(response, 503, { ok: false, error: "foundry_mcp_not_configured" });
|
||||
const capability = mintMcpCapability(identity, config);
|
||||
return sendJson(response, 200, {
|
||||
appGrants: {
|
||||
"module-foundry": {
|
||||
...grantBase,
|
||||
enabled: true,
|
||||
context: { actorId: identity.actorId },
|
||||
mcpServers: [{
|
||||
serverName: "nodedc_module_foundry",
|
||||
url: config.mcpUrl,
|
||||
enabled: true,
|
||||
required: false,
|
||||
startupTimeoutSec: 20,
|
||||
toolTimeoutSec: 60,
|
||||
httpHeaders: {
|
||||
Authorization: `Bearer ${capability}`,
|
||||
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user