@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.
295 lines (294 loc) • 12.1 kB
JavaScript
/**
* Thin MCP server. Registers exactly one tool: show_workspace.
*
* The handler is injected. This module does not import `handleShowWorkspace`,
* `src/engine`, or the catalog — it only moves JSON-RPC frames between a
* transport and the handler, so the tool contract can change without touching
* the server. The registered name is `SHOW_WORKSPACE_NAME` from the tool
* module so it cannot drift from the contract.
*
* In-process tests use this hand-rolled `handle()`. Live stdio
* (`startWorkspaceServer`) uses `createSdkWorkspaceServer` (MCP SDK 1.30 +
* ext-apps), the same wire Cursor issues `resources/read` against.
*/
import { SHOW_WORKSPACE_NAME } from '../tool/show-workspace.js';
import { chartTypeForComponent } from '../tool/widget-payload.js';
import { workspaceMeta, omitRowArrays } from '../inspector/meta-contract.js';
import { chartIdFromDataUrl, formatWorkspaceAppText } from './mcp-app.js';
export { SHOW_WORKSPACE_NAME };
export const PROTOCOL_VERSION = '2025-06-18';
/** Versions this hand-rolled server will answer with. Anything else stays on PROTOCOL_VERSION. */
const SUPPORTED_PROTOCOL_VERSIONS = ['2024-11-05', '2025-03-26', PROTOCOL_VERSION];
/** Echo a supported client version. An absent or unknown one keeps the server default. */
export function negotiateProtocol(params) {
const requested = params !== null && typeof params === 'object' && !Array.isArray(params)
? params.protocolVersion
: undefined;
if (typeof requested === 'string' &&
SUPPORTED_PROTOCOL_VERSIONS.includes(requested)) {
return requested;
}
return PROTOCOL_VERSION;
}
export const SERVER_INFO = { name: 'ui9000-core', version: '0.0.0' };
/**
* Returned on initialize. Hosts such as Claude put this in context before they
* pick a tool. It does not remove the host's own chart tools.
*/
export const WORKSPACE_INSTRUCTIONS = [
'For any table or chart, call show_workspace.',
"Do not draw that chart with the chat's built-in tools.",
"Pass the user's words as utterance.",
'Pass requestedChart only when they named a chart. On a new table, a known chart the utterance does not name is ignored and a chart is drawn. An unknown name is kept. A datasetId call with no new csv, url, or path keeps the id.',
'A result with spec is the chart. Show that chart. Do not tell the user nothing was drawn, and do not ask them to choose between charts.',
].join(' ');
const DEFAULT_INPUT_SCHEMA = { type: 'object' };
const PARSE_ERROR = -32700;
const INVALID_REQUEST = -32600;
const METHOD_NOT_FOUND = -32601;
const INVALID_PARAMS = -32602;
const INTERNAL_ERROR = -32603;
/**
* Builds the server. One tool, one handler, no registry: a second registerTool
* call is impossible by construction, so generate_* tools cannot appear.
*/
export function createServer(handler, info = {}) {
if (typeof handler !== 'function') {
throw new TypeError('createServer requires a show_workspace handler function.');
}
const tool = {
name: SHOW_WORKSPACE_NAME,
inputSchema: info.inputSchema ?? DEFAULT_INPUT_SCHEMA,
};
if (info.description !== undefined) {
tool.description = info.description;
}
if (info.appResource) {
tool._meta = {
ui: {
resourceUri: info.appResource.uri,
visibility: ['model', 'app'],
csp: info.appResource.ui.csp,
},
'ui/resourceUri': info.appResource.uri,
};
}
const tools = Object.freeze([tool]);
const appResource = info.appResource;
async function handle(message) {
if (message === null || typeof message !== 'object' || Array.isArray(message)) {
return errorResponse(0, INVALID_REQUEST, 'Request must be a JSON-RPC object.');
}
const request = message;
const method = request.method;
const id = request.id;
if (typeof method !== 'string') {
return errorResponse(isId(id) ? id : 0, INVALID_REQUEST, 'Request is missing a method.');
}
// No id means a notification: the protocol forbids a reply.
if (!isId(id))
return null;
switch (method) {
case 'initialize':
return okResponse(id, {
protocolVersion: negotiateProtocol(request.params),
capabilities: appResource
? {
tools: {},
resources: {},
extensions: {
'io.modelcontextprotocol/ui': {
mimeTypes: [appResource.mimeType],
},
},
}
: { tools: {} },
serverInfo: SERVER_INFO,
instructions: WORKSPACE_INSTRUCTIONS,
});
case 'ping':
return okResponse(id, {});
case 'tools/list':
return okResponse(id, { tools });
case 'tools/call':
return callTool(id, request.params);
case 'resources/list':
return listResources(id);
case 'resources/read':
return readResource(id, request.params);
default:
return errorResponse(id, METHOD_NOT_FOUND, `Unknown method: ${method}`);
}
}
async function callTool(id, params) {
if (params === null || typeof params !== 'object' || Array.isArray(params)) {
return errorResponse(id, INVALID_PARAMS, 'tools/call params must be an object.');
}
const { name, arguments: args } = params;
if (name !== SHOW_WORKSPACE_NAME) {
return errorResponse(id, INVALID_PARAMS, `Unknown tool: ${String(name)}. This server registers ${SHOW_WORKSPACE_NAME} only.`);
}
let result;
try {
result = await handler(args);
}
catch (error) {
// A throwing handler is a tool error, not a transport error: the client
// keeps the session and can retry.
return okResponse(id, {
content: [{ type: 'text', text: describeError(error) }],
isError: true,
});
}
try {
return okResponse(id, workspaceToolResult(result, appResource));
}
catch (error) {
return okResponse(id, {
content: [{ type: 'text', text: describeError(error) }],
isError: true,
});
}
}
async function listResources(id) {
if (!appResource) {
return errorResponse(id, METHOD_NOT_FOUND, 'Unknown method: resources/list');
}
return okResponse(id, {
resources: [
{
uri: appResource.uri,
name: appResource.name,
mimeType: appResource.mimeType,
description: appResource.description,
_meta: { ui: appResource.ui },
},
],
});
}
async function readResource(id, params) {
if (!appResource) {
return errorResponse(id, METHOD_NOT_FOUND, 'Unknown method: resources/read');
}
if (params === null || typeof params !== 'object' || Array.isArray(params)) {
return errorResponse(id, INVALID_PARAMS, 'resources/read params must be an object.');
}
const uri = params.uri;
if (uri !== appResource.uri) {
return errorResponse(id, INVALID_PARAMS, `Unknown resource: ${String(uri)}`);
}
try {
const text = await appResource.loadHtml();
return okResponse(id, {
contents: [
{
uri: appResource.uri,
mimeType: appResource.mimeType,
text,
_meta: { ui: appResource.ui },
},
],
});
}
catch (error) {
return errorResponse(id, INTERNAL_ERROR, describeError(error));
}
}
return { tools, handle };
}
function toolResult(result, appResource) {
const app = appResource ? workspaceAppEnvelope(result, appResource) : undefined;
const payload = {
content: [{ type: 'text', text: app?.text ?? stringify(result) }],
};
if (result !== null && typeof result === 'object' && !Array.isArray(result)) {
payload.structuredContent = app?.structured ?? result;
}
if (app?._meta)
payload._meta = app._meta;
// A refused tool result is still a successful JSON-RPC response. isError is
// what tells the host the call failed, on both the hand-rolled and SDK paths.
if (result !== null &&
typeof result === 'object' &&
!Array.isArray(result) &&
result.ok === false) {
payload.isError = true;
}
return payload;
}
/** Same CallToolResult envelope the stdio SDK path returns. */
export function workspaceToolResult(result, appResource) {
return toolResult(result, appResource);
}
function workspaceAppEnvelope(result, appResource) {
if (result === null || typeof result !== 'object' || Array.isArray(result))
return undefined;
const body = result;
if (body.ok !== true)
return undefined;
const spec = body.spec && typeof body.spec === 'object' && !Array.isArray(body.spec)
? body.spec
: undefined;
const dataUrl = typeof body.dataUrl === 'string'
? body.dataUrl
: typeof spec?.dataUrl === 'string'
? spec.dataUrl
: undefined;
const fromBody = typeof body.chartType === 'string' ? body.chartType.trim() : '';
const fromSpec = typeof spec?.component === 'string' ? chartTypeForComponent(spec.component) : '';
const chartType = fromBody || fromSpec;
if (typeof dataUrl !== 'string' || !dataUrl || !chartType)
return undefined;
const meta = {
chartType,
dataUrl,
chartId: chartIdFromDataUrl(dataUrl),
};
const summary = typeof body.summary === 'string' ? body.summary : `${chartType}`;
const traceId = typeof body.traceId === 'string' ? body.traceId : undefined;
const trace = isTrace(body.trace) ? omitRowArrays(body.trace, true) : undefined;
const proposal = isProposal(body.proposal) ? omitRowArrays(body.proposal, true) : undefined;
return {
text: formatWorkspaceAppText(summary, { ...meta, ...(traceId ? { traceId } : {}) }),
structured: omitRowArrays({ ...body, ...meta }),
_meta: workspaceMeta({
resourceUri: appResource.uri,
csp: appResource.ui.csp,
...meta,
...(trace ? { trace } : {}),
...(proposal ? { proposal } : {}),
}),
};
}
function isTrace(value) {
return !!value && typeof value === 'object' && !Array.isArray(value) && 'objective' in value;
}
function isProposal(value) {
if (!value || typeof value !== 'object' || Array.isArray(value))
return false;
const proposal = value;
return typeof proposal.action === 'string' && typeof proposal.preview === 'string' && typeof proposal.id === 'string';
}
function stringify(value) {
if (typeof value === 'string')
return value;
try {
return JSON.stringify(value) ?? String(value);
}
catch {
return String(value);
}
}
function describeError(error) {
return error instanceof Error ? error.message : String(error);
}
function isId(value) {
return typeof value === 'string' || typeof value === 'number';
}
export function okResponse(id, result) {
return { jsonrpc: '2.0', id, result };
}
export function errorResponse(id, code, message) {
return { jsonrpc: '2.0', id, error: { code, message } };
}
export { PARSE_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND, INVALID_PARAMS, INTERNAL_ERROR };