@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.
188 lines (187 loc) • 7.94 kB
JavaScript
/**
* S3-26 wiring: the stdio MCP server with the real `handleShowWorkspace`.
*
* Junior modules stay untouched — this file imports `createServer`,
* `loadWorkspaceCatalog`, `profileColumns`, and `signDataLink`.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadWorkspaceCatalog } from './catalog/load-workspace.js';
import { askJev } from './jev/ask.js';
import { DEFAULT_BASE_URL, enableRemotePersist, isProductionLikeEnv, isRemoteDataLinkBase, resolveDataLinkStorageDir, signDataLink, TtlStore, } from './migrate/datalink/index.js';
import { profileColumns } from './profiler/profile-columns.js';
import { classifyColumns } from './profiler/roles.js';
import { parseCsvTable, tableToRows } from './profiler/table.js';
import { createServer, } from './server/index.js';
import { connectSdkWorkspace } from './server/sdk-workspace.js';
import { workspaceChartAppResource } from './server/mcp-app.js';
import { SHOW_WORKSPACE_DESCRIPTION, SHOW_WORKSPACE_INPUT_SCHEMA, handleShowWorkspace, } from './tool/show-workspace.js';
import { datasetFromPayload } from './tool/ingest-table.js';
export const WORKSPACE_DATA_PATH_ENV = 'WORKSPACE_DATA_PATH';
/**
* Same public FuseDash charts key mcp-ui bakes as `MCP_MAPBOX_TOKEN`.
* Maps always have a token on this server — do not require `WORKSPACE_HAS_MAP_TOKEN`.
*/
export const WORKSPACE_MAPBOX_TOKEN = 'pk.eyJ1IjoiYW5keWsxOTg3IiwiYSI6ImNqeHJtdGJjNTA5bWwzbW1mcXA0cTZuMmkifQ.qAhmQzT0m6-KuvZt7-C83A';
/**
* Fill hosted defaults so Cursor/Claude stdio starts with no mcp.json env.
* Does not mutate the input object.
*/
export function applyWorkspaceHostDefaults(env) {
const next = { ...env };
if (!next.MCP_BASE_URL?.trim()) {
next.MCP_BASE_URL = DEFAULT_BASE_URL;
}
const base = next.MCP_BASE_URL.trim();
if (isRemoteDataLinkBase(base)) {
enableRemotePersist(next);
}
if (!(next.MAPBOX_ACCESS_TOKEN || next.MAPBOX_TOKEN || '').trim()) {
next.MAPBOX_ACCESS_TOKEN = WORKSPACE_MAPBOX_TOKEN;
}
return next;
}
/** Local `.env` only, and only for a real process start. Does not override a key already set. */
export function readDotEnv(env) {
const file = fileURLToPath(new URL('../.env', import.meta.url));
let text;
try {
text = fs.readFileSync(file, 'utf8');
}
catch {
return env;
}
const next = { ...env };
for (const line of text.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#'))
continue;
const eq = trimmed.indexOf('=');
if (eq <= 0)
continue;
const name = trimmed.slice(0, eq).trim();
if (next[name])
continue;
let value = trimmed.slice(eq + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
next[name] = value;
}
return next;
}
/** `packages/core` — relative `WORKSPACE_DATA_PATH` resolves here, not cwd. */
function packageRoot() {
return fileURLToPath(new URL('../', import.meta.url));
}
/** JSON-RPC tool schema. Cloned so the `as const` contract object stays frozen. */
const SHOW_WORKSPACE_LIST_SCHEMA = JSON.parse(JSON.stringify(SHOW_WORKSPACE_INPUT_SCHEMA));
/** Shipped demo CSV so HTTP/Claude connectors have rows without WORKSPACE_DATA_PATH. */
export function workspaceDemoCsvPath() {
return fileURLToPath(new URL('./demo/regional-incidents.csv', import.meta.url));
}
export function wireWorkspace(options = {}) {
const env = options.env ?? process.env;
let table = options.table ??
loadWorkspaceTable(options.dataPath ?? env[WORKSPACE_DATA_PATH_ENV], options.root);
let selectedColumns;
const catalog = loadWorkspaceCatalog();
const store = resolveBoundStore(env, options);
const sign = (rows) => signDataLink(rows, store !== undefined ? { env, store } : { env });
const apiKey = env.TYPESAFE_API_KEY?.trim() ?? '';
const makeContext = () => {
const profile = profileColumns(table, { hasMapToken: true });
const payload = tableToRows(table);
const fields = table.columns.map((column) => column.name);
const classified = classifyColumns(table).columns;
return {
catalog,
profile,
payload,
fields,
classified,
...(apiKey ? { askJev: (request) => askJev(request, apiKey) } : {}),
signDataLink: sign,
allowRemoteSources: options.allowRemoteSources ?? !isProductionLikeEnv(env),
...(env.MCP_DATASET_ROOT?.trim() ? { datasetRoot: env.MCP_DATASET_ROOT.trim() } : {}),
loadDataset: (id) => {
if (!store)
return undefined;
return datasetFromPayload(store.get(id)?.payload);
},
selectedColumns,
rememberTable: (next) => {
table = next;
},
rememberColumns: (names) => {
selectedColumns = names ? [...names] : undefined;
},
};
};
return {
handler: (args) => handleShowWorkspace(args, makeContext()),
info: {
description: SHOW_WORKSPACE_DESCRIPTION,
inputSchema: SHOW_WORKSPACE_LIST_SCHEMA,
appResource: workspaceChartAppResource(env.MCP_BASE_URL || DEFAULT_BASE_URL, options.loadChartHtml),
},
};
}
/**
* Bind catalog + profile into `createServer(handleShowWorkspace)`.
* The model still only sends `{ intent }`. Rows live on the server and leave as
* an opaque handle, never as tool arguments or result rows.
*
* Stdio (`startWorkspaceServer`) applies hosted defaults: `MCP_BASE_URL`
* → `https://mcp.ui9000.com`, Mapbox token always present, remote persist on.
* A host `env` without `store` still uses `env.STORAGE_DIR`, not `process.env`.
*
* `createWorkspaceServer` stays on the hand-rolled JSON-RPC handle() used by
* in-process tests. Live stdio (bin / injected streams) always uses the SDK.
*/
export function createWorkspaceServer(options = {}) {
const wired = wireWorkspace(options);
return createServer(wired.handler, wired.info);
}
/**
* Stdio entry used by `yarn workspace @fusedashlabs/ui9000-workspace start` and the bin.
* Always MCP SDK + ext-apps so Cursor issues `resources/read` — including when
* tests inject streams.
*/
export async function startWorkspaceServer(streams, options) {
const env = applyWorkspaceHostDefaults(options?.env ?? readDotEnv(process.env));
const dataPath = options?.table || options?.dataPath
? options.dataPath
: resolveWorkspaceDataPath(env[WORKSPACE_DATA_PATH_ENV], options?.root) ??
workspaceDemoCsvPath();
const wired = wireWorkspace({ ...options, env, dataPath });
return connectSdkWorkspace(wired.handler, wired.info, streams);
}
/**
* Absolute paths are kept. Relative paths resolve against `packages/core`,
* same as `STORAGE_DIR` — Cursor/Claude stdio often have cwd=$HOME.
*/
export function resolveWorkspaceDataPath(raw, root = packageRoot()) {
if (raw == null || !raw.trim())
return undefined;
const trimmed = raw.trim();
if (path.isAbsolute(trimmed))
return trimmed;
return path.resolve(root, trimmed);
}
export function loadWorkspaceTable(dataPath, root = packageRoot()) {
const resolved = resolveWorkspaceDataPath(dataPath, root);
if (!resolved)
return { columns: [] };
return parseCsvTable(fs.readFileSync(resolved, 'utf8'));
}
function resolveBoundStore(env, options) {
if (options.store !== undefined)
return options.store;
if (options.env !== undefined) {
return new TtlStore(undefined, resolveDataLinkStorageDir(env));
}
return undefined;
}