Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ Available tools:
- `quantlab_paper_sessions_health`
- `quantlab_desktop_smoke`
- `quantlab_read_file`
- `quantlab_outputs_list`
- `quantlab_artifact_read`

The server entrypoint is `mcp-server.mjs`, and the `mcp` npm script runs it directly.

Expand Down
165 changes: 165 additions & 0 deletions desktop/mcp-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,53 @@ import { z } from "zod";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DESKTOP_ROOT = __dirname;
const PROJECT_ROOT = path.resolve(DESKTOP_ROOT, "..");
const OUTPUTS_ROOT = path.resolve(PROJECT_ROOT, "outputs");
const PYTHON_EXECUTABLE = process.env.QUANTLAB_PYTHON || "python";
const MAX_OUTPUT_CHARS = 12000;
const BINARY_ARTIFACT_EXTENSIONS = new Set([
".png",
".jpg",
".jpeg",
".gif",
".webp",
".bmp",
".ico",
".pdf",
]);

function truncateText(text) {
if (text.length <= MAX_OUTPUT_CHARS) return text;
return `${text.slice(0, MAX_OUTPUT_CHARS)}\n...[truncated]`;
}

function resolveOutputsPath(relativePath) {
const requested = relativePath || "";
const resolvedPath = path.resolve(OUTPUTS_ROOT, requested);
const relative = path.relative(OUTPUTS_ROOT, resolvedPath);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(`Refusing to access outside outputs/: ${relativePath}`);
}
return resolvedPath;
}

function formatBytes(size) {
if (!Number.isFinite(size)) return "unknown";
if (size < 1024) return `${size} B`;
const units = ["KB", "MB", "GB"];
let value = size / 1024;
for (const unit of units) {
if (value < 1024 || unit === units[units.length - 1]) {
return `${value.toFixed(value >= 10 ? 1 : 2)} ${unit}`;
}
value /= 1024;
}
return `${size} B`;
}

function toIsoString(date) {
return date instanceof Date ? date.toISOString() : null;
}

function runProcess(command, args, options = {}) {
return new Promise((resolve) => {
const child = spawn(command, args, {
Expand Down Expand Up @@ -74,6 +113,66 @@ async function runPythonCli(args, timeoutMs = 120000) {
});
}

async function listOutputs(relativePath = "") {
const targetPath = resolveOutputsPath(relativePath);
const stat = await fs.stat(targetPath);
if (!stat.isDirectory()) {
throw new Error(`Not a directory under outputs/: ${relativePath || "."}`);
}

const entries = await fs.readdir(targetPath, { withFileTypes: true });
const detailed = [];
for (const entry of entries) {
const entryPath = path.join(targetPath, entry.name);
const entryStat = await fs.stat(entryPath);
detailed.push({
name: entry.name,
kind: entry.isDirectory() ? "directory" : "file",
relative_path: path.relative(PROJECT_ROOT, entryPath).replaceAll("\\", "/"),
size_bytes: entry.isDirectory() ? null : entryStat.size,
size_human: entry.isDirectory() ? null : formatBytes(entryStat.size),
modified_at: toIsoString(entryStat.mtime),
});
}

detailed.sort((left, right) => {
if (left.kind !== right.kind) {
return left.kind === "directory" ? -1 : 1;
}
return left.name.localeCompare(right.name);
});

return {
root: "outputs",
requested_path: relativePath || ".",
absolute_path: targetPath,
entry_count: detailed.length,
entries: detailed,
};
}

async function readOutputsArtifact(relativePath) {
const targetPath = resolveOutputsPath(relativePath);
const stat = await fs.stat(targetPath);
if (stat.isDirectory()) {
throw new Error(`Expected a file under outputs/, got directory: ${relativePath}`);
}
if (BINARY_ARTIFACT_EXTENSIONS.has(path.extname(targetPath).toLowerCase())) {
throw new Error(`Binary artifact reading is not supported for ${relativePath}`);
}

const data = await fs.readFile(targetPath, "utf8");
return {
root: "outputs",
requested_path: relativePath,
absolute_path: targetPath,
bytes: stat.size,
modified_at: toIsoString(stat.mtime),
truncated: data.length > MAX_OUTPUT_CHARS,
content: truncateText(data),
};
}

async function formatProcessResult(label, result, commandLine) {
const sections = [
`Command: ${commandLine}`,
Expand Down Expand Up @@ -186,6 +285,72 @@ async function main() {
}
});

server.registerTool("quantlab_outputs_list", {
description: "List artifacts and directories under outputs/.",
inputSchema: {
relative_path: z.string().optional().default("").describe("Path relative to outputs/"),
},
}, async ({ relative_path }) => {
try {
const payload = await listOutputs(relative_path);
return {
content: [
{
type: "text",
text: JSON.stringify(payload, null, 2),
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Failed to list outputs/${relative_path || ""}: ${error.message || String(error)}`,
},
],
isError: true,
};
}
});

server.registerTool("quantlab_artifact_read", {
description: "Read a text artifact within outputs/.",
inputSchema: {
relative_path: z.string().describe("Path relative to outputs/"),
},
}, async ({ relative_path }) => {
try {
const payload = await readOutputsArtifact(relative_path);
return {
content: [
{
type: "text",
text: [
`Path: outputs/${payload.requested_path}`,
`Bytes: ${payload.bytes}`,
`Size: ${formatBytes(payload.bytes)}`,
`Modified at: ${payload.modified_at || "unknown"}`,
`Truncated: ${payload.truncated ? "yes" : "no"}`,
"",
payload.content,
].join("\n"),
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Failed to read outputs/${relative_path}: ${error.message || String(error)}`,
},
],
isError: true,
};
}
});

const transport = new StdioServerTransport();
await server.connect(transport);
}
Expand Down
Loading