@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.
410 lines (409 loc) • 14.9 kB
JavaScript
/**
* Same ingest surface as mcp-ui `load_dataset`: csv | url | path | datasetId.
* Rows stay on the server. The MCP result only gets datasetId + column names.
*/
import { lookup } from 'node:dns/promises';
import * as fs from 'node:fs';
import { isIP } from 'node:net';
import * as path from 'node:path';
import { parseCsvTable, tableRowCount, tableToRows, } from '../profiler/table.js';
export const DATASET_KIND = 'dataset';
export const INGEST_KEYS = ['csv', 'url', 'path', 'datasetId'];
const DEFAULT_MAX_BYTES = 1.5 * 1024 * 1024;
const REMOTE_SOURCE_DISABLED = 'url and path dataset sources are disabled on hosted/production servers. Paste CSV via the csv field.';
const SENSITIVE_PATH = 'path cannot be a hidden, env, or secrets file.';
const SENSITIVE_BASES = new Set([
'id_rsa',
'id_dsa',
'id_ecdsa',
'id_ed25519',
'credentials',
'credentials.json',
'secrets',
'secrets.json',
'env.prod',
'env.local',
]);
const SENSITIVE_DIRS = new Set(['.ssh', '.aws', '.gnupg', '.kube']);
/** Hidden files, key material, and well-known secret directories. Checks the whole path, not only the basename. */
export function isSensitiveDatasetFileName(fileName) {
const parts = fileName.replace(/\\/g, '/').toLowerCase().split('/').filter(Boolean);
if (parts.some((part) => SENSITIVE_DIRS.has(part)))
return true;
const base = parts[parts.length - 1] ?? '';
if (base.startsWith('.') && base !== '.' && base !== '..')
return true;
if (base.endsWith('.pem') || base.endsWith('.key'))
return true;
return SENSITIVE_BASES.has(base);
}
/** System trees a chart table must not read, even when the basename looks harmless. */
export function isSensitiveDatasetPath(filePath) {
if (isSensitiveDatasetFileName(filePath))
return true;
const norm = path.resolve(filePath).replace(/\\/g, '/').toLowerCase();
return ['/etc/', '/proc/', '/sys/', '/private/etc/'].some((prefix) => norm === prefix.slice(0, -1) || norm.startsWith(prefix));
}
export function isDatasetPayload(value) {
if (!value || typeof value !== 'object')
return false;
const root = value;
return (root.config?.kind === DATASET_KIND &&
Array.isArray(root.data?.columns) &&
Array.isArray(root.data?.rows));
}
export function datasetFromPayload(payload) {
if (!isDatasetPayload(payload))
return undefined;
return {
name: typeof payload.config.name === 'string' ? payload.config.name : undefined,
columns: payload.data.columns.filter((name) => typeof name === 'string'),
rows: payload.data.rows.filter((row) => !!row && typeof row === 'object' && !Array.isArray(row)),
};
}
export function datasetPersistPayload(label, table) {
return {
config: { kind: DATASET_KIND, name: label },
data: {
columns: table.columns.map((column) => column.name),
rows: tableToRows(table),
},
};
}
export function idFromDataUrl(dataUrl) {
try {
const parts = new URL(dataUrl).pathname.split('/').filter(Boolean);
const id = parts[parts.length - 1];
return id || undefined;
}
catch {
return undefined;
}
}
export function tableFromRecords(records) {
const names = [];
const seen = new Set();
for (const record of records) {
for (const key of Object.keys(record)) {
if (!seen.has(key)) {
seen.add(key);
names.push(key);
}
}
}
const columns = names.map((name) => ({
name,
values: records.map((record) => stringifyCell(record[name])),
}));
return { columns };
}
export function tableFromStoredDataset(dataset) {
if (dataset.columns.length > 0) {
const columns = dataset.columns.map((name) => ({
name,
values: dataset.rows.map((row) => stringifyCell(row[name])),
}));
return { columns };
}
return tableFromRecords(dataset.rows);
}
function stringifyCell(value) {
if (value == null)
return '';
if (typeof value === 'string')
return value;
if (typeof value === 'number' || typeof value === 'boolean')
return String(value);
try {
return JSON.stringify(value) ?? '';
}
catch {
return '';
}
}
function fail(code, reason) {
return { ok: false, code, reason };
}
function trimmed(value) {
if (typeof value !== 'string')
return undefined;
const next = value.trim();
return next.length > 0 ? next : undefined;
}
/**
* Resolve a user table for this tool call. `{ skip: true }` means use the
* server's current table (demo / last ingest / WORKSPACE_DATA_PATH).
*/
export async function resolveWorkspaceIngest(args, options = {}) {
const csv = trimmed(args.csv);
const url = trimmed(args.url);
const filePath = trimmed(args.path);
const datasetId = trimmed(args.datasetId);
const sources = [csv, url, filePath, datasetId].filter(Boolean).length;
if (sources === 0)
return { ok: true, skip: true };
if (sources > 1) {
return fail('invalid_ingest', 'Provide exactly one of: csv, url, path, or datasetId.');
}
if (datasetId) {
if (!options.loadDataset) {
return fail('dataset_not_found', `Dataset not found or expired (datasetId=${datasetId}).`);
}
const stored = await options.loadDataset(datasetId);
if (!stored) {
return fail('dataset_not_found', `Dataset not found or expired (datasetId=${datasetId}). Pass csv again to reload.`);
}
const table = tableFromStoredDataset(stored);
if (tableRowCount(table) < 1 || table.columns.length < 1) {
return fail('invalid_ingest', 'datasetId resolved to a table with no columns or rows.');
}
return {
ok: true,
table,
rows: tableToRows(table),
label: stored.name || datasetId,
datasetId,
};
}
const loaded = await resolveCsvText({ csv, url, path: filePath }, options);
if (!loaded.ok)
return loaded;
const table = parseCsvTable(loaded.csvText);
if (table.columns.length < 1 || tableRowCount(table) < 1) {
return fail('invalid_ingest', 'CSV must include a header row and at least one data row.');
}
return {
ok: true,
table,
rows: tableToRows(table),
label: loaded.label,
};
}
async function resolveCsvText(source, options) {
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
const allowRemote = options.allowRemoteSources !== false;
if (source.csv) {
return capCsv(source.csv, 'pasted-csv', maxBytes);
}
if (source.url) {
if (!allowRemote)
return fail('invalid_ingest', REMOTE_SOURCE_DISABLED);
if (!/^https?:\/\//i.test(source.url)) {
return fail('invalid_ingest', 'url must start with http:// or https://.');
}
const blocked = await blockedDatasetUrl(source.url);
if (blocked)
return fail('invalid_ingest', blocked);
try {
const fetchImpl = options.fetchImpl ?? fetch;
const res = await fetchImpl(source.url, { method: 'GET', redirect: 'error' });
if (res.status >= 300 && res.status < 400) {
return fail('invalid_ingest', 'url redirects are refused.');
}
if (!res.ok) {
return fail('invalid_ingest', `Failed to fetch url (HTTP ${res.status}).`);
}
const csvText = await res.text();
return capCsv(csvText, source.url, maxBytes);
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
return fail('invalid_ingest', `Failed to fetch url: ${message}`);
}
}
if (source.path) {
if (!allowRemote)
return fail('invalid_ingest', REMOTE_SOURCE_DISABLED);
const cwd = path.resolve(options.cwd ?? process.cwd());
const resolved = path.isAbsolute(source.path)
? path.resolve(source.path)
: path.resolve(cwd, source.path);
const relative = !path.isAbsolute(source.path);
if (relative && resolved !== cwd && !resolved.startsWith(cwd + path.sep)) {
return fail('invalid_ingest', 'Relative path must stay inside the working directory.');
}
let real;
let realCwd;
try {
real = fs.realpathSync(resolved);
realCwd = fs.realpathSync(cwd);
}
catch {
return fail('invalid_ingest', `File not found: ${source.path}`);
}
if (relative && real !== realCwd && !real.startsWith(realCwd + path.sep)) {
return fail('invalid_ingest', 'Relative path must stay inside the working directory.');
}
if (isSensitiveDatasetPath(real)) {
return fail('invalid_ingest', SENSITIVE_PATH);
}
const datasetRoot = options.datasetRoot?.trim();
if (datasetRoot) {
let realRoot;
try {
realRoot = fs.realpathSync(path.resolve(datasetRoot));
}
catch {
return fail('invalid_ingest', 'Dataset root is not a directory.');
}
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) {
return fail('invalid_ingest', 'Path must stay inside the dataset root.');
}
}
let stat;
try {
stat = fs.statSync(real);
}
catch {
return fail('invalid_ingest', `File not found: ${source.path}`);
}
if (!stat.isFile()) {
return fail('invalid_ingest', `File not found: ${source.path}`);
}
const csvText = fs.readFileSync(real, 'utf8');
return capCsv(csvText, path.basename(real), maxBytes);
}
return fail('invalid_ingest', 'Provide exactly one of: csv, url, path, or datasetId.');
}
const BLOCKED_URL_HOSTS = new Set([
'localhost',
'metadata.google.internal',
]);
async function lookupAll(hostname) {
return lookup(hostname, { all: true, verbatim: true });
}
/**
* Refuse loopback, link-local, private, and cloud-metadata hosts before fetch.
* A public name is refused when any resolved address is in those ranges.
*/
export async function blockedDatasetUrl(raw, lookupHost = lookupAll) {
let url;
try {
url = new URL(raw);
}
catch {
return 'url is not a valid http(s) URL.';
}
if (url.username || url.password)
return 'url must not include credentials.';
const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
if (BLOCKED_URL_HOSTS.has(host) ||
host.endsWith('.local') ||
host.endsWith('.internal') ||
isBlockedIp(host) ||
isAmbiguousNumericHost(host)) {
return 'url host is not allowed.';
}
if (isIP(host))
return undefined;
let records;
try {
records = await lookupHost(host);
}
catch {
return 'url host could not be resolved.';
}
if (records.length === 0 || records.some((record) => isBlockedIp(record.address))) {
return 'url host is not allowed.';
}
return undefined;
}
/** Integer and short dotted forms (`2130706433`, `127.1`) that isIP() does not classify. */
function isAmbiguousNumericHost(host) {
return /^[\d.]+$/.test(host) && isIP(host) === 0;
}
function isBlockedIp(host) {
const kind = isIP(host);
if (kind === 4) {
const [a = -1, b = -1] = host.split('.').map((part) => Number(part));
if (a === 0 || a === 10 || a === 127)
return true;
if (a === 169 && b === 254)
return true;
if (a === 172 && b >= 16 && b <= 31)
return true;
if (a === 192 && b === 168)
return true;
if (a === 100 && b >= 64 && b <= 127)
return true;
if (a >= 224)
return true;
return false;
}
if (kind === 6)
return isBlockedIpv6(host);
return false;
}
function isBlockedIpv6(host) {
const parts = expandIpv6(host.toLowerCase());
if (!parts)
return true;
const mapped = parts[0] === 0 &&
parts[1] === 0 &&
parts[2] === 0 &&
parts[3] === 0 &&
parts[4] === 0 &&
parts[5] === 0xffff;
if (mapped || parts.slice(0, 6).every((part) => part === 0)) {
const hi = parts[6] ?? 0;
const lo = parts[7] ?? 0;
return isBlockedIp(`${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`);
}
if (parts.every((part) => part === 0))
return true;
if (parts[7] === 1 && parts.slice(0, 7).every((part) => part === 0))
return true;
const first = parts[0] ?? 0;
// 6to4 embeds an IPv4 in the 32 bits after 2002:.
if (first === 0x2002) {
const hi = parts[1] ?? 0;
const lo = parts[2] ?? 0;
return isBlockedIp(`${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`);
}
if ((first & 0xfe00) === 0xfc00)
return true;
if ((first & 0xffc0) === 0xfe80)
return true;
if ((first & 0xff00) === 0xff00)
return true;
return false;
}
function expandIpv6(host) {
let input = host;
const dotted = input.match(/^(.*:)(\d+\.\d+\.\d+\.\d+)$/);
if (dotted?.[1] && dotted[2]) {
if (isIP(dotted[2]) !== 4)
return undefined;
const [a = 0, b = 0, c = 0, d = 0] = dotted[2].split('.').map((part) => Number(part));
const hi = ((a << 8) | b).toString(16);
const lo = ((c << 8) | d).toString(16);
input = `${dotted[1]}${hi}:${lo}`;
}
const halves = input.split('::');
if (halves.length > 2)
return undefined;
const left = halves[0] ? halves[0].split(':') : [];
const right = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
if (halves.length === 1 && left.length !== 8)
return undefined;
const missing = 8 - left.length - right.length;
if (missing < 0)
return undefined;
const groups = [...left, ...Array(missing).fill('0'), ...right];
if (groups.length !== 8)
return undefined;
const nums = groups.map((part) => (/^[0-9a-f]{1,4}$/.test(part) ? Number.parseInt(part, 16) : Number.NaN));
if (nums.some((part) => !Number.isFinite(part)))
return undefined;
return nums;
}
function capCsv(csvText, label, maxBytes) {
const bytes = Buffer.byteLength(csvText, 'utf8');
if (bytes > maxBytes) {
return fail('invalid_ingest', `CSV is too large (${bytes} bytes). Maximum is ${maxBytes} bytes.`);
}
if (!csvText.trim()) {
return fail('invalid_ingest', 'CSV is empty.');
}
return { ok: true, csvText, label };
}