UNPKG

@fusedashlabs/ui9000-workspace

Version:

UI9000 workspace MCP. One tool, show_workspace, draws a chart from your table. Set TYPESAFE_API_KEY to let Jev choose the chart.

112 lines (111 loc) • 4.82 kB
/** * Public data-link API: `signDataLink` / `readDataLink`. * * Assembled from mcp-ui `createDataLink` / `getDataLink` * (`src/utils/dataLinkHandlers.ts`) and the TTL resolution in * `chartDataLinkHelper.ts`. The signing and verification steps are unchanged — * see `signature.ts`. * * These two functions satisfy the `SignDataLink` / `ReadDataLink` contract in * `src/tool/data-channel.ts`: the payload never reaches the spec, only an * opaque `/v1/data-links/:id?sig=&exp=` handle does. */ import { randomUUID } from 'node:crypto'; import { DEFAULT_HOSTED_MCP_BASE_URL, isRemoteDataLinkBase, persistHostedDataLink, shouldRemotePersistDataLink, } from './hosted.js'; import { requireDataLinkSecret } from './secret.js'; import { getDataLinkStore } from './store.js'; import { createSignedDataUrl, isExpired, generateExpiration, parseSignedUrlParams, verifyDataLinkSignature, } from './signature.js'; export const DEFAULT_TTL_HOURS = 24; export const DEFAULT_MAX_PAYLOAD_SIZE_MB = 1.5; /** Same hosted origin mcp-ui npx uses. Override with loopback `MCP_BASE_URL` for local `mcp:serve`. */ export const DEFAULT_BASE_URL = DEFAULT_HOSTED_MCP_BASE_URL; /** mcp-ui `getDataLinkTtlHours`: DATA_LINK_TTL_HOURS, floored, min 1, default 24. */ export function getDataLinkTtlHours(env = process.env) { const raw = env.DATA_LINK_TTL_HOURS; if (raw == null || raw === '') return DEFAULT_TTL_HOURS; const parsed = Number(raw); return Number.isFinite(parsed) && parsed >= 1 ? Math.floor(parsed) : DEFAULT_TTL_HOURS; } /** MAX_PAYLOAD_SIZE_MB, default 1.5. */ export function getMaxPayloadSizeMb(env = process.env) { const parsed = Number(env.MAX_PAYLOAD_SIZE_MB || String(DEFAULT_MAX_PAYLOAD_SIZE_MB)); return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_PAYLOAD_SIZE_MB; } /** * Strip a trailing `/mcp` so `https://host/mcp` and `https://host` share the * same `/v1/data-links` API root (mcp-ui `normalizeDataLinkBaseUrl`). */ export function normalizeDataLinkBaseUrl(raw) { const trimmed = raw.trim().replace(/\/+$/, ''); if (!trimmed) return trimmed; try { const url = new URL(trimmed); if (url.pathname.replace(/\/+$/, '') === '/mcp') return url.origin; return trimmed; } catch { return trimmed; } } function resolveBaseUrl(options, env) { const raw = options.baseUrl || env.MCP_BASE_URL || DEFAULT_BASE_URL; return normalizeDataLinkBaseUrl(raw); } /** * Persist the payload and return an opaque signed handle. * * Refuses payloads over MAX_PAYLOAD_SIZE_MB (1.5 MB) — the cap is on the * serialized bytes, matching mcp-ui, so a link can never be larger than the * host is willing to serve back. * * Hosted stdio (Cursor / Claude) POSTs to `MCP_BASE_URL` when * `MCP_DATA_LINK_REMOTE_PERSIST` is set — same path as mcp-ui npx. */ export function signDataLink(payload, options = {}) { const env = options.env ?? process.env; const maxPayloadSizeMb = getMaxPayloadSizeMb(env); const maxPayloadSizeBytes = maxPayloadSizeMb * 1024 * 1024; const payloadSize = JSON.stringify(payload).length; if (payloadSize > maxPayloadSizeBytes) { throw new Error(`Payload too large. Maximum size is ${maxPayloadSizeMb}MB.`); } const ttlHours = getDataLinkTtlHours(env); const store = options.store ?? getDataLinkStore(); const baseUrl = resolveBaseUrl(options, env); if (shouldRemotePersistDataLink(env) && isRemoteDataLinkBase(baseUrl)) { return persistHostedDataLink(payload, baseUrl, options.fetchImpl).then((hosted) => { store.set(hosted.id, payload, ttlHours * 60 * 60 * 1000); return { dataUrl: hosted.dataUrl }; }); } const secret = options.secret ?? requireDataLinkSecret(env); const id = randomUUID(); const exp = generateExpiration(ttlHours); store.set(id, payload, ttlHours * 60 * 60 * 1000); return { dataUrl: createSignedDataUrl(baseUrl, id, exp, secret) }; } /** * Resolve a signed handle back to its payload. * * Fails closed to `null` — an unparseable URL, a bad signature, an expired * `exp`, or a missing/expired file all read the same way to the caller, so a * forged handle learns nothing from the response. */ export function readDataLink(link, options = {}) { const env = options.env ?? process.env; const params = parseSignedUrlParams(link?.dataUrl ?? ''); if (!params) return null; const secret = options.secret ?? requireDataLinkSecret(env); if (!verifyDataLinkSignature(params.id, params.exp, params.sig, secret)) { return null; } if (isExpired(params.exp)) { return null; } const store = options.store ?? getDataLinkStore(); return store.get(params.id)?.payload ?? null; }