@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.
240 lines (239 loc) • 9.02 kB
JavaScript
/**
* Streamable HTTP for Claude.ai / ChatGPT connectors. Stdio stays the Cursor path.
* Stateless: one SDK server + transport per request (same as mcp-ui POST /mcp).
*/
import { timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { applyWorkspaceHostDefaults, readDotEnv, resolveWorkspaceDataPath, wireWorkspace, workspaceDemoCsvPath, WORKSPACE_DATA_PATH_ENV, } from '../workspace-server.js';
import { createSdkWorkspaceServer } from './sdk-workspace.js';
export const WORKSPACE_HTTP_PATH = '/mcp';
export const DEFAULT_WORKSPACE_HTTP_PORT = 8090;
const CORS_METHODS = 'GET, POST, DELETE, OPTIONS';
const CORS_ALLOW_HEADERS = 'content-type, mcp-session-id, mcp-protocol-version, authorization';
const HTTP_TOKEN_ENV = 'MCP_HTTP_TOKEN';
const MAX_HTTP_BODY_BYTES = 2 * 1024 * 1024;
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
export function wireWorkspaceHttp(options = {}) {
const env = applyWorkspaceHostDefaults(options.env ?? process.env);
const dataPath = options.dataPath ??
resolveWorkspaceDataPath(env[WORKSPACE_DATA_PATH_ENV], options.root) ??
workspaceDemoCsvPath();
return wireWorkspace({ ...options, env, dataPath, allowRemoteSources: false });
}
export async function handleWorkspaceMcpHttpRequest(req, res, parsedBody, options = {}) {
applyWorkspaceHttpCors(res, headerOrigin(req));
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
const env = options.env ?? process.env;
if (refuseMissingBearer(req, res, env))
return;
await dispatchWorkspaceMcp(req, res, wireWorkspaceHttp(options), parsedBody);
}
async function dispatchWorkspaceMcp(req, res, wired, parsedBody) {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
const server = createSdkWorkspaceServer(wired.handler, wired.info);
let closed = false;
const close = () => {
if (closed)
return;
closed = true;
try {
void transport.close();
}
catch {
/* ignore */
}
try {
void server.close();
}
catch {
/* ignore */
}
};
res.on('close', close);
try {
await server.connect(transport);
const body = parsedBody !== undefined ? parsedBody : await readJsonBody(req);
await transport.handleRequest(req, res, body);
if (res.writableEnded || res.destroyed)
close();
}
catch (error) {
if (!res.headersSent) {
const tooLarge = error instanceof HttpBodyError;
const status = tooLarge ? 413 : 400;
const code = tooLarge ? -32600 : -32700;
const message = error instanceof Error ? error.message : 'MCP request failed';
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code, message } }));
}
try {
void transport.close();
}
catch {
/* ignore */
}
try {
await server.close();
}
catch {
/* ignore */
}
}
}
export async function startWorkspaceHttpServer(options = {}) {
const env = applyWorkspaceHostDefaults(options.env ?? readDotEnv(process.env));
const mcpPath = options.path ?? WORKSPACE_HTTP_PATH;
const host = options.host ?? '127.0.0.1';
const port = options.port ?? Number(env.PORT || DEFAULT_WORKSPACE_HTTP_PORT);
const extraHosts = (env.MCP_HTTP_ALLOWED_HOSTS ?? '')
.split(',')
.map((item) => item.trim().toLowerCase())
.filter(Boolean);
const httpServer = createServer((req, res) => {
void (async () => {
const origin = headerOrigin(req);
applyWorkspaceHttpCors(res, origin, extraHosts);
if (!requestAllowed(req, extraHosts)) {
res.writeHead(403, { 'content-type': 'text/plain' });
res.end('forbidden host');
return;
}
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
if (url.pathname === '/health' || url.pathname === '/health/') {
res.writeHead(200, { 'content-type': 'text/plain' });
res.end('ok');
return;
}
if (refuseMissingBearer(req, res, env))
return;
if (url.pathname !== mcpPath && url.pathname !== `${mcpPath}/`) {
res.writeHead(404, { 'content-type': 'text/plain' });
res.end('not found');
return;
}
await dispatchWorkspaceMcp(req, res, wireWorkspaceHttp({ ...options, env }));
})().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
if (!res.headersSent) {
res.writeHead(500, { 'content-type': 'text/plain' });
res.end('MCP request failed');
}
});
});
await new Promise((resolve, reject) => {
httpServer.once('error', reject);
httpServer.listen(port, host, () => resolve());
});
const address = httpServer.address();
const boundHost = address.address === '0.0.0.0' ? '127.0.0.1' : address.address;
const url = `http://${boundHost}:${address.port}`;
return {
url,
port: address.port,
path: mcpPath,
close: () => new Promise((resolve, reject) => {
httpServer.close((err) => (err ? reject(err) : resolve()));
}),
};
}
class HttpBodyError extends Error {
}
async function readJsonBody(req) {
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'DELETE') {
return undefined;
}
const chunks = [];
let size = 0;
for await (const chunk of req) {
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += buf.length;
if (size > MAX_HTTP_BODY_BYTES) {
throw new HttpBodyError(`HTTP body exceeds ${MAX_HTTP_BODY_BYTES} bytes.`);
}
chunks.push(buf);
}
const raw = Buffer.concat(chunks).toString('utf8').trim();
if (!raw)
return undefined;
return JSON.parse(raw);
}
export function applyWorkspaceHttpCors(res, origin, extraHosts = []) {
res.setHeader('Access-Control-Allow-Methods', CORS_METHODS);
res.setHeader('Access-Control-Allow-Headers', CORS_ALLOW_HEADERS);
res.setHeader('Access-Control-Expose-Headers', 'mcp-session-id');
res.setHeader('Vary', 'Origin');
if (origin && isAllowedHttpHost(hostnameOf(origin), extraHosts)) {
res.setHeader('Access-Control-Allow-Origin', origin);
}
}
function requestAllowed(req, extraHosts) {
const host = requestHostname(req);
if (host && !isAllowedHttpHost(host, extraHosts))
return false;
const origin = headerOrigin(req);
if (origin && !isAllowedHttpHost(hostnameOf(origin), extraHosts))
return false;
return true;
}
export function isAllowedHttpHost(host, extraHosts = []) {
const name = host.trim().toLowerCase();
if (!name)
return true;
return LOOPBACK_HOSTS.has(name) || extraHosts.includes(name);
}
/** Unset token leaves local loopback as it is. A set token is required on /mcp. */
function refuseMissingBearer(req, res, env) {
if (bearerMatches(req, env[HTTP_TOKEN_ENV]))
return false;
res.writeHead(401, { 'content-type': 'text/plain' });
res.end('unauthorized');
return true;
}
function bearerMatches(req, expected) {
const token = expected?.trim() ?? '';
if (!token)
return true;
const header = req.headers.authorization;
if (typeof header !== 'string')
return false;
const scheme = /^Bearer\s+/i.exec(header);
if (!scheme)
return false;
const given = Buffer.from(header.slice(scheme[0].length));
const want = Buffer.from(token);
if (given.length !== want.length)
return false;
return timingSafeEqual(given, want);
}
function headerOrigin(req) {
const origin = req.headers.origin;
return typeof origin === 'string' && origin.trim() ? origin.trim() : undefined;
}
function requestHostname(req) {
const raw = req.headers.host ?? '';
if (raw.startsWith('[')) {
const end = raw.indexOf(']');
return end > 1 ? raw.slice(1, end).toLowerCase() : '';
}
return raw.split(':')[0]?.toLowerCase() ?? '';
}
function hostnameOf(origin) {
try {
return new URL(origin).hostname.toLowerCase();
}
catch {
return origin.toLowerCase();
}
}