ad
This commit is contained in:
@@ -0,0 +1,803 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { startHttpServer, type RunningHttpServer } from "../src/http-server.js";
|
||||
import { createServices } from "../src/mcp-server.js";
|
||||
|
||||
const ALL_TOOLS = [
|
||||
"apply_patch",
|
||||
"chmod_path",
|
||||
"copy_path",
|
||||
"download_file",
|
||||
"exec_command",
|
||||
"hash_file",
|
||||
"list_directory",
|
||||
"list_processes",
|
||||
"make_directory",
|
||||
"move_path",
|
||||
"read_file",
|
||||
"read_process",
|
||||
"remove_path",
|
||||
"replace_in_file",
|
||||
"run_script",
|
||||
"stat_path",
|
||||
"terminate_process",
|
||||
"upload_file",
|
||||
"write_file",
|
||||
"write_stdin",
|
||||
] as const;
|
||||
|
||||
type ToolName = (typeof ALL_TOOLS)[number];
|
||||
type ToolResult = Awaited<ReturnType<Client["callTool"]>>;
|
||||
|
||||
function structured(result: ToolResult): Record<string, unknown> {
|
||||
return (result.structuredContent ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function errorText(result: ToolResult): string {
|
||||
return String(structured(result).error ?? "");
|
||||
}
|
||||
|
||||
function externalRoot(): string | undefined {
|
||||
const value = process.env.MCP_E2E_ROOT?.trim();
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
if (!/^\/tmp\/cokacremote-tools-e2e-[A-Za-z0-9._-]+$/.test(value)) {
|
||||
throw new Error(
|
||||
"MCP_E2E_ROOT must be an isolated /tmp/cokacremote-tools-e2e-* path",
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
describe.sequential("all registered MCP tools", () => {
|
||||
let localDirectory: string | undefined;
|
||||
let testRoot: string;
|
||||
let running: RunningHttpServer | undefined;
|
||||
let client: Client;
|
||||
let transport: StreamableHTTPClientTransport;
|
||||
const exercised = new Set<ToolName>();
|
||||
|
||||
const call = async (
|
||||
name: ToolName,
|
||||
arguments_: Record<string, unknown> = {},
|
||||
): Promise<ToolResult> => {
|
||||
exercised.add(name);
|
||||
return client.callTool({ name, arguments: arguments_ });
|
||||
};
|
||||
|
||||
const callOk = async (
|
||||
name: ToolName,
|
||||
arguments_: Record<string, unknown> = {},
|
||||
): Promise<Record<string, unknown>> => {
|
||||
const result = await call(name, arguments_);
|
||||
expect(result.isError, `${name} unexpectedly returned an error: ${errorText(result)}`).not.toBe(
|
||||
true,
|
||||
);
|
||||
return structured(result);
|
||||
};
|
||||
|
||||
const callError = async (
|
||||
name: ToolName,
|
||||
arguments_: Record<string, unknown> = {},
|
||||
): Promise<string> => {
|
||||
const result = await call(name, arguments_);
|
||||
expect(result.isError, `${name} unexpectedly succeeded`).toBe(true);
|
||||
expect(errorText(result)).not.toBe("");
|
||||
return errorText(result);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const externalUrl = process.env.MCP_E2E_URL?.trim();
|
||||
let endpoint: URL;
|
||||
let authToken: string;
|
||||
if (externalUrl) {
|
||||
authToken = process.env.MCP_E2E_TOKEN?.trim() ?? "";
|
||||
if (!authToken) {
|
||||
throw new Error("MCP_E2E_TOKEN is required with MCP_E2E_URL");
|
||||
}
|
||||
testRoot = externalRoot() ?? `/tmp/cokacremote-tools-e2e-${randomUUID()}`;
|
||||
endpoint = new URL(externalUrl);
|
||||
} else {
|
||||
localDirectory = await mkdtemp(path.join(os.tmpdir(), "cokacremote-all-tools-"));
|
||||
testRoot = path.join(localDirectory, "tool-root");
|
||||
authToken = "all-tools-test-secret";
|
||||
const config = loadConfig(
|
||||
{
|
||||
MCP_AUTH_TOKEN: authToken,
|
||||
MCP_HOST: "127.0.0.1",
|
||||
MCP_DEFAULT_CWD: localDirectory,
|
||||
MCP_MAX_FILE_CHUNK_BYTES: "65536",
|
||||
},
|
||||
localDirectory,
|
||||
);
|
||||
config.port = 0;
|
||||
running = await startHttpServer(config, createServices(config));
|
||||
const address = running.httpServer.address() as AddressInfo;
|
||||
endpoint = new URL(`http://127.0.0.1:${address.port}${config.endpoint}`);
|
||||
}
|
||||
|
||||
client = new Client({ name: "all-tools-e2e", version: "1.0.0" });
|
||||
transport = new StreamableHTTPClientTransport(endpoint, {
|
||||
requestInit: { headers: { authorization: `Bearer ${authToken}` } },
|
||||
});
|
||||
await client.connect(transport);
|
||||
await callOk("make_directory", {
|
||||
path: testRoot,
|
||||
recursive: true,
|
||||
mode: "0700",
|
||||
});
|
||||
}, 20_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (client && testRoot) {
|
||||
await client
|
||||
.callTool({
|
||||
name: "remove_path",
|
||||
arguments: { path: testRoot, recursive: true, force: true },
|
||||
})
|
||||
.catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
await running?.close();
|
||||
if (localDirectory) {
|
||||
await rm(localDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes the exact tool inventory and annotations", async () => {
|
||||
const listed = await client.listTools();
|
||||
expect(listed.tools.map((tool) => tool.name).sort()).toEqual([...ALL_TOOLS]);
|
||||
for (const tool of listed.tools) {
|
||||
expect(tool.inputSchema.type).toBe("object");
|
||||
expect(tool.annotations).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("executes, polls, writes to, times out, lists, and terminates processes", async () => {
|
||||
const completed = await callOk("exec_command", {
|
||||
cmd: "printf '%s\\n' \"$E2E_VALUE\"; pwd; printf 'stderr-ok' >&2; exit 7",
|
||||
workdir: testRoot,
|
||||
env: { E2E_VALUE: "env-ok" },
|
||||
yieldTimeMs: 3000,
|
||||
});
|
||||
expect(completed).toMatchObject({ completed: true, exitCode: 7, stderr: "stderr-ok" });
|
||||
expect(String(completed.stdout)).toContain("env-ok");
|
||||
expect(String(completed.stdout)).toContain(testRoot);
|
||||
expect(await callOk("exec_command", {
|
||||
cmd: "printf shell-ok",
|
||||
workdir: testRoot,
|
||||
shell: "/bin/sh",
|
||||
login: false,
|
||||
yieldTimeMs: 3000,
|
||||
})).toMatchObject({ completed: true, exitCode: 0, stdout: "shell-ok" });
|
||||
|
||||
const bounded = await callOk("exec_command", {
|
||||
cmd: "node -e \"process.stdout.write('x'.repeat(20000))\"",
|
||||
workdir: testRoot,
|
||||
yieldTimeMs: 3000,
|
||||
maxOutputBytes: 16384,
|
||||
});
|
||||
expect(bounded).toMatchObject({ completed: true, exitCode: 0, hasMore: true });
|
||||
let boundedOutput = String(bounded.stdout);
|
||||
let boundedCursor = Number(bounded.nextSeq);
|
||||
for (let page = 0; page < 5 && bounded.hasMore === true; page += 1) {
|
||||
const next = await callOk("read_process", {
|
||||
sessionId: bounded.sessionId,
|
||||
afterSeq: boundedCursor,
|
||||
maxOutputBytes: 16384,
|
||||
});
|
||||
boundedOutput += String(next.stdout);
|
||||
boundedCursor = Number(next.nextSeq);
|
||||
bounded.hasMore = next.hasMore;
|
||||
}
|
||||
expect(boundedOutput).toBe("x".repeat(20000));
|
||||
|
||||
const timedOut = await callOk("exec_command", {
|
||||
cmd: "sleep 10",
|
||||
workdir: testRoot,
|
||||
timeoutMs: 100,
|
||||
yieldTimeMs: 3000,
|
||||
});
|
||||
expect(timedOut).toMatchObject({ completed: true, timedOut: true });
|
||||
expect(String(timedOut.error)).toContain("timeout");
|
||||
|
||||
const interactive = await callOk("exec_command", {
|
||||
cmd: "node -e \"process.stdin.once('data', d => { process.stdout.write(d); process.exit(0); })\"",
|
||||
workdir: testRoot,
|
||||
yieldTimeMs: 0,
|
||||
});
|
||||
expect(interactive).toMatchObject({ running: true, completed: false });
|
||||
const interactiveSession = String(interactive.sessionId);
|
||||
const written = await callOk("write_stdin", {
|
||||
sessionId: interactiveSession,
|
||||
chars: "interactive-ok\\n",
|
||||
closeStdin: true,
|
||||
yieldTimeMs: 3000,
|
||||
});
|
||||
expect(String(written.stdout)).toContain("interactive-ok");
|
||||
const read = await callOk("read_process", {
|
||||
sessionId: interactiveSession,
|
||||
afterSeq: written.nextSeq,
|
||||
waitMs: 1000,
|
||||
});
|
||||
expect(read).toMatchObject({ running: false, completed: true, exitCode: 0 });
|
||||
|
||||
const script = await callOk("run_script", {
|
||||
runtime: "node",
|
||||
script:
|
||||
"process.stdin.once('data', d => { console.log(JSON.stringify({ arg: process.argv[2], env: process.env.E2E_SCRIPT, stdin: d.toString() })); process.exit(0); });",
|
||||
workdir: testRoot,
|
||||
args: ["argument-ok"],
|
||||
env: { E2E_SCRIPT: "script-env-ok" },
|
||||
stdin: "script-stdin-ok",
|
||||
yieldTimeMs: 3000,
|
||||
keepScript: true,
|
||||
});
|
||||
expect(script).toMatchObject({ completed: true, exitCode: 0 });
|
||||
expect(JSON.parse(String(script.stdout).trim())).toEqual({
|
||||
arg: "argument-ok",
|
||||
env: "script-env-ok",
|
||||
stdin: "script-stdin-ok",
|
||||
});
|
||||
expect(String(script.scriptPath)).toMatch(/^\/tmp\/remote-dev-mcp-script-/);
|
||||
const keptScript = await callOk("stat_path", { path: script.scriptPath });
|
||||
expect(keptScript).toMatchObject({ type: "file", mode: "0700" });
|
||||
await callOk("remove_path", {
|
||||
path: path.dirname(String(script.scriptPath)),
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
for (const request of [
|
||||
{ script: "printf default-bash-ok", expected: "default-bash-ok" },
|
||||
{ runtime: "bash", script: "printf bash-ok", expected: "bash-ok" },
|
||||
{ runtime: "sh", script: "printf sh-ok", expected: "sh-ok" },
|
||||
{ runtime: "python", script: "print('python-ok')", expected: "python-ok\n" },
|
||||
{
|
||||
runtime: "custom",
|
||||
interpreter: "/bin/sh",
|
||||
script: "printf custom-ok",
|
||||
expected: "custom-ok",
|
||||
},
|
||||
]) {
|
||||
const runtimeResult = await callOk("run_script", {
|
||||
...request,
|
||||
workdir: testRoot,
|
||||
yieldTimeMs: 3000,
|
||||
});
|
||||
expect(runtimeResult).toMatchObject({
|
||||
completed: true,
|
||||
exitCode: 0,
|
||||
stdout: request.expected,
|
||||
});
|
||||
}
|
||||
expect(await callError("run_script", { runtime: "custom", script: "exit 0" })).toContain(
|
||||
"interpreter is required",
|
||||
);
|
||||
|
||||
const longRunning = await callOk("exec_command", {
|
||||
cmd: "node -e \"setInterval(() => {}, 1000)\"",
|
||||
workdir: testRoot,
|
||||
yieldTimeMs: 0,
|
||||
});
|
||||
const longSession = String(longRunning.sessionId);
|
||||
const processes = await callOk("list_processes");
|
||||
expect(processes.processes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ sessionId: longSession, running: true }),
|
||||
]),
|
||||
);
|
||||
await callOk("terminate_process", {
|
||||
sessionId: longSession,
|
||||
signal: "SIGTERM",
|
||||
graceMs: 1000,
|
||||
});
|
||||
const terminated = await callOk("read_process", {
|
||||
sessionId: longSession,
|
||||
waitMs: 2000,
|
||||
});
|
||||
expect(terminated).toMatchObject({ running: false, completed: true, signal: "SIGTERM" });
|
||||
}, 30_000);
|
||||
|
||||
it("handles text, metadata, listings, permissions, and unified patches", async () => {
|
||||
await callOk("make_directory", {
|
||||
path: "text/nested",
|
||||
cwd: testRoot,
|
||||
recursive: true,
|
||||
mode: "0750",
|
||||
});
|
||||
expect(await callError("make_directory", {
|
||||
path: "missing-parent/child",
|
||||
cwd: testRoot,
|
||||
recursive: false,
|
||||
})).toMatch(/ENOENT|no such file/i);
|
||||
|
||||
const unicodeText = "A😀한글B";
|
||||
await callOk("write_file", {
|
||||
path: "text/nested/unicode.txt",
|
||||
cwd: testRoot,
|
||||
content: unicodeText,
|
||||
fileMode: "0640",
|
||||
});
|
||||
expect(await callOk("stat_path", {
|
||||
path: "text/nested/unicode.txt",
|
||||
cwd: testRoot,
|
||||
})).toMatchObject({ type: "file", mode: "0640" });
|
||||
|
||||
await callOk("chmod_path", {
|
||||
path: "text/nested/unicode.txt",
|
||||
cwd: testRoot,
|
||||
mode: "0600",
|
||||
});
|
||||
await callOk("write_file", {
|
||||
path: "text/nested/unicode.txt",
|
||||
cwd: testRoot,
|
||||
content: unicodeText,
|
||||
mode: "overwrite",
|
||||
fileMode: "0644",
|
||||
});
|
||||
expect(await callOk("stat_path", {
|
||||
path: "text/nested/unicode.txt",
|
||||
cwd: testRoot,
|
||||
})).toMatchObject({ mode: "0644" });
|
||||
|
||||
let offset = 0;
|
||||
let reconstructed = "";
|
||||
for (let part = 0; part < 20; part += 1) {
|
||||
const chunk = await callOk("read_file", {
|
||||
path: "text/nested/unicode.txt",
|
||||
cwd: testRoot,
|
||||
offset,
|
||||
maxBytes: 3,
|
||||
encoding: "utf8",
|
||||
});
|
||||
reconstructed += String(chunk.content);
|
||||
const nextOffset = Number(chunk.nextOffset);
|
||||
expect(nextOffset).toBeGreaterThan(offset);
|
||||
offset = nextOffset;
|
||||
if (chunk.eof === true) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(reconstructed).toBe(unicodeText);
|
||||
|
||||
const encoded = await callOk("read_file", {
|
||||
path: "text/nested/unicode.txt",
|
||||
cwd: testRoot,
|
||||
maxBytes: 65536,
|
||||
encoding: "base64",
|
||||
});
|
||||
expect(Buffer.from(String(encoded.content), "base64").toString("utf8")).toBe(unicodeText);
|
||||
expect(await callError("read_file", {
|
||||
path: "text",
|
||||
cwd: testRoot,
|
||||
})).toContain("not a regular file");
|
||||
|
||||
expect(await callError("write_file", {
|
||||
path: "text/invalid-base64.bin",
|
||||
cwd: testRoot,
|
||||
content: "%%%not-base64%%%",
|
||||
encoding: "base64",
|
||||
})).toMatch(/base64/i);
|
||||
expect(await callError("write_file", {
|
||||
path: "text/no-parent/value.txt",
|
||||
cwd: testRoot,
|
||||
content: "must fail",
|
||||
createParents: false,
|
||||
})).toMatch(/ENOENT|no such file/i);
|
||||
|
||||
await callOk("write_file", {
|
||||
path: "text/append.txt",
|
||||
cwd: testRoot,
|
||||
content: "first",
|
||||
});
|
||||
await callOk("write_file", {
|
||||
path: "text/append.txt",
|
||||
cwd: testRoot,
|
||||
content: "-second",
|
||||
mode: "append",
|
||||
});
|
||||
expect(await callOk("read_file", {
|
||||
path: "text/append.txt",
|
||||
cwd: testRoot,
|
||||
})).toMatchObject({ content: "first-second", eof: true });
|
||||
await callOk("write_file", {
|
||||
path: "text/empty.txt",
|
||||
cwd: testRoot,
|
||||
content: "",
|
||||
});
|
||||
expect(await callOk("read_file", {
|
||||
path: "text/empty.txt",
|
||||
cwd: testRoot,
|
||||
maxBytes: 1,
|
||||
})).toMatchObject({ content: "", bytesRead: 0, eof: true });
|
||||
|
||||
await callOk("write_file", {
|
||||
path: "text/replace.txt",
|
||||
cwd: testRoot,
|
||||
content: "one two one\n",
|
||||
});
|
||||
expect(await callError("replace_in_file", {
|
||||
path: "text/replace.txt",
|
||||
cwd: testRoot,
|
||||
oldText: "one",
|
||||
newText: "ONE",
|
||||
})).toContain("found 2");
|
||||
await callOk("replace_in_file", {
|
||||
path: "text/replace.txt",
|
||||
cwd: testRoot,
|
||||
oldText: "one",
|
||||
newText: "ONE",
|
||||
replaceAll: true,
|
||||
expectedOccurrences: 2,
|
||||
});
|
||||
await callOk("replace_in_file", {
|
||||
path: "text/replace.txt",
|
||||
cwd: testRoot,
|
||||
oldText: "two",
|
||||
newText: "three",
|
||||
expectedOccurrences: 1,
|
||||
});
|
||||
expect(await callOk("read_file", {
|
||||
path: "text/replace.txt",
|
||||
cwd: testRoot,
|
||||
})).toMatchObject({ content: "ONE three ONE\n", eof: true });
|
||||
|
||||
await callOk("write_file", {
|
||||
path: "text/.hidden",
|
||||
cwd: testRoot,
|
||||
content: "hidden",
|
||||
});
|
||||
const visible = await callOk("list_directory", {
|
||||
path: "text",
|
||||
cwd: testRoot,
|
||||
recursive: true,
|
||||
maxDepth: 8,
|
||||
includeHidden: false,
|
||||
includeMetadata: true,
|
||||
});
|
||||
expect(visible.entries).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ relativePath: "nested/unicode.txt", type: "file" }),
|
||||
]),
|
||||
);
|
||||
expect(visible.entries).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ name: ".hidden" })]),
|
||||
);
|
||||
expect(await callOk("list_directory", {
|
||||
path: "text",
|
||||
cwd: testRoot,
|
||||
recursive: true,
|
||||
maxEntries: 2,
|
||||
})).toMatchObject({ count: 2, truncated: true });
|
||||
|
||||
await callOk("exec_command", {
|
||||
cmd: "ln -s nested/unicode.txt text/unicode-link",
|
||||
workdir: testRoot,
|
||||
yieldTimeMs: 3000,
|
||||
});
|
||||
expect(await callOk("stat_path", {
|
||||
path: "text/unicode-link",
|
||||
cwd: testRoot,
|
||||
})).toMatchObject({ type: "symlink", symlinkTarget: "nested/unicode.txt" });
|
||||
|
||||
await callOk("write_file", {
|
||||
path: "patch-target.txt",
|
||||
cwd: testRoot,
|
||||
content: "old\n",
|
||||
});
|
||||
const patch = [
|
||||
"diff --git a/patch-target.txt b/patch-target.txt",
|
||||
"--- a/patch-target.txt",
|
||||
"+++ b/patch-target.txt",
|
||||
"@@ -1 +1 @@",
|
||||
"-old",
|
||||
"+new",
|
||||
"",
|
||||
].join("\n");
|
||||
expect(await callOk("apply_patch", {
|
||||
patch,
|
||||
cwd: testRoot,
|
||||
checkOnly: true,
|
||||
})).toMatchObject({ applied: false, checkOnly: true });
|
||||
expect(await callOk("read_file", {
|
||||
path: "patch-target.txt",
|
||||
cwd: testRoot,
|
||||
})).toMatchObject({ content: "old\n" });
|
||||
expect(await callOk("apply_patch", { patch, cwd: testRoot })).toMatchObject({
|
||||
applied: true,
|
||||
checkOnly: false,
|
||||
});
|
||||
expect(await callOk("apply_patch", {
|
||||
patch,
|
||||
cwd: testRoot,
|
||||
reverse: true,
|
||||
})).toMatchObject({ applied: true });
|
||||
expect(await callOk("read_file", {
|
||||
path: "patch-target.txt",
|
||||
cwd: testRoot,
|
||||
})).toMatchObject({ content: "old\n" });
|
||||
expect(await callError("apply_patch", {
|
||||
patch: "this is not a unified patch",
|
||||
cwd: testRoot,
|
||||
})).not.toBe("");
|
||||
|
||||
await callOk("make_directory", {
|
||||
path: "three-way",
|
||||
cwd: testRoot,
|
||||
recursive: true,
|
||||
});
|
||||
await callOk("write_file", {
|
||||
path: "three-way/value.txt",
|
||||
cwd: testRoot,
|
||||
content: "base\n",
|
||||
});
|
||||
await callOk("exec_command", {
|
||||
cmd: "git init -q && git config user.email e2e@example.invalid && git config user.name E2E && git add value.txt && git commit -qm base",
|
||||
workdir: path.join(testRoot, "three-way"),
|
||||
yieldTimeMs: 3000,
|
||||
});
|
||||
await callOk("write_file", {
|
||||
path: "value.txt",
|
||||
cwd: path.join(testRoot, "three-way"),
|
||||
content: "three-way-result\n",
|
||||
});
|
||||
const generatedPatch = await callOk("exec_command", {
|
||||
cmd: "git diff --binary -- value.txt",
|
||||
workdir: path.join(testRoot, "three-way"),
|
||||
yieldTimeMs: 3000,
|
||||
});
|
||||
await callOk("exec_command", {
|
||||
cmd: "git checkout -- value.txt",
|
||||
workdir: path.join(testRoot, "three-way"),
|
||||
yieldTimeMs: 3000,
|
||||
});
|
||||
expect(await callOk("apply_patch", {
|
||||
patch: generatedPatch.stdout,
|
||||
cwd: path.join(testRoot, "three-way"),
|
||||
threeWay: true,
|
||||
})).toMatchObject({ applied: true });
|
||||
expect(await callOk("read_file", {
|
||||
path: "value.txt",
|
||||
cwd: path.join(testRoot, "three-way"),
|
||||
})).toMatchObject({ content: "three-way-result\n" });
|
||||
}, 30_000);
|
||||
|
||||
it("transfers, hashes, copies, moves, and removes isolated paths", async () => {
|
||||
await callOk("make_directory", {
|
||||
path: "transfer",
|
||||
cwd: testRoot,
|
||||
recursive: true,
|
||||
});
|
||||
expect(await callError("upload_file", {
|
||||
path: "transfer/invalid.bin",
|
||||
cwd: testRoot,
|
||||
dataBase64: "not@base64",
|
||||
truncate: true,
|
||||
})).toMatch(/base64/i);
|
||||
|
||||
const binary = Buffer.from(Array.from({ length: 1024 }, (_, index) => index % 256));
|
||||
const first = binary.subarray(0, 333);
|
||||
const second = binary.subarray(333);
|
||||
await callOk("write_file", {
|
||||
path: "transfer/artifact.bin",
|
||||
cwd: testRoot,
|
||||
content: "stale content that must be truncated",
|
||||
});
|
||||
const firstUpload = await callOk("upload_file", {
|
||||
path: "transfer/artifact.bin",
|
||||
cwd: testRoot,
|
||||
dataBase64: first.toString("base64"),
|
||||
offset: 0,
|
||||
truncate: true,
|
||||
});
|
||||
expect(firstUpload).toMatchObject({
|
||||
bytesWritten: first.length,
|
||||
nextOffset: first.length,
|
||||
chunkSha256: createHash("sha256").update(first).digest("hex"),
|
||||
});
|
||||
await callOk("upload_file", {
|
||||
path: "transfer/artifact.bin",
|
||||
cwd: testRoot,
|
||||
dataBase64: second.toString("base64"),
|
||||
offset: first.length,
|
||||
truncate: false,
|
||||
});
|
||||
|
||||
let downloaded = Buffer.alloc(0);
|
||||
let offset = 0;
|
||||
for (let part = 0; part < 20; part += 1) {
|
||||
const chunk = await callOk("download_file", {
|
||||
path: "transfer/artifact.bin",
|
||||
cwd: testRoot,
|
||||
offset,
|
||||
maxBytes: 113,
|
||||
});
|
||||
downloaded = Buffer.concat([
|
||||
downloaded,
|
||||
Buffer.from(String(chunk.dataBase64), "base64"),
|
||||
]);
|
||||
offset = Number(chunk.nextOffset);
|
||||
if (chunk.eof === true) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(downloaded).toEqual(binary);
|
||||
expect(await callOk("download_file", {
|
||||
path: "transfer/artifact.bin",
|
||||
cwd: testRoot,
|
||||
offset: binary.length,
|
||||
maxBytes: 1,
|
||||
})).toMatchObject({ bytesRead: 0, eof: true, nextOffset: binary.length });
|
||||
for (const algorithm of ["sha256", "sha512", "md5"] as const) {
|
||||
expect(await callOk("hash_file", {
|
||||
path: "transfer/artifact.bin",
|
||||
cwd: testRoot,
|
||||
algorithm,
|
||||
})).toMatchObject({
|
||||
algorithm,
|
||||
digest: createHash(algorithm).update(binary).digest("hex"),
|
||||
});
|
||||
}
|
||||
|
||||
await callOk("write_file", {
|
||||
path: "transfer/base64-copy.bin",
|
||||
cwd: testRoot,
|
||||
content: binary.toString("base64"),
|
||||
encoding: "base64",
|
||||
});
|
||||
expect(await callOk("hash_file", {
|
||||
path: "transfer/base64-copy.bin",
|
||||
cwd: testRoot,
|
||||
})).toMatchObject({ digest: createHash("sha256").update(binary).digest("hex") });
|
||||
|
||||
await callOk("write_file", {
|
||||
path: "transfer/existing.bin",
|
||||
cwd: testRoot,
|
||||
content: "existing",
|
||||
});
|
||||
expect(await callError("copy_path", {
|
||||
sourcePath: "transfer/artifact.bin",
|
||||
destinationPath: "transfer/existing.bin",
|
||||
cwd: testRoot,
|
||||
force: false,
|
||||
})).toContain("already exists");
|
||||
expect(await callError("copy_path", {
|
||||
sourcePath: "transfer/artifact.bin",
|
||||
destinationPath: "transfer/artifact.bin",
|
||||
cwd: testRoot,
|
||||
})).toContain("must be different");
|
||||
await callOk("copy_path", {
|
||||
sourcePath: "transfer/artifact.bin",
|
||||
destinationPath: "transfer/copied.bin",
|
||||
cwd: testRoot,
|
||||
force: true,
|
||||
});
|
||||
expect(await callOk("hash_file", {
|
||||
path: "transfer/copied.bin",
|
||||
cwd: testRoot,
|
||||
})).toMatchObject({ digest: createHash("sha256").update(binary).digest("hex") });
|
||||
|
||||
await callOk("make_directory", {
|
||||
path: "transfer/source-dir",
|
||||
cwd: testRoot,
|
||||
recursive: true,
|
||||
});
|
||||
await callOk("write_file", {
|
||||
path: "transfer/source-dir/value.txt",
|
||||
cwd: testRoot,
|
||||
content: "source",
|
||||
});
|
||||
await callOk("make_directory", {
|
||||
path: "transfer/destination-dir",
|
||||
cwd: testRoot,
|
||||
recursive: true,
|
||||
});
|
||||
await callOk("write_file", {
|
||||
path: "transfer/destination-dir/value.txt",
|
||||
cwd: testRoot,
|
||||
content: "destination",
|
||||
});
|
||||
expect(await callError("copy_path", {
|
||||
sourcePath: "transfer/source-dir",
|
||||
destinationPath: "transfer/destination-dir",
|
||||
cwd: testRoot,
|
||||
recursive: false,
|
||||
})).toContain("recursive=true");
|
||||
expect(await callError("copy_path", {
|
||||
sourcePath: "transfer/source-dir",
|
||||
destinationPath: "transfer/destination-dir",
|
||||
cwd: testRoot,
|
||||
recursive: true,
|
||||
force: false,
|
||||
})).toContain("already exists");
|
||||
await callOk("copy_path", {
|
||||
sourcePath: "transfer/source-dir",
|
||||
destinationPath: "transfer/destination-dir",
|
||||
cwd: testRoot,
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
expect(await callOk("read_file", {
|
||||
path: "transfer/destination-dir/value.txt",
|
||||
cwd: testRoot,
|
||||
})).toMatchObject({ content: "source" });
|
||||
|
||||
await callOk("write_file", {
|
||||
path: "transfer/move-source.txt",
|
||||
cwd: testRoot,
|
||||
content: "move-source",
|
||||
});
|
||||
await callOk("write_file", {
|
||||
path: "transfer/move-destination.txt",
|
||||
cwd: testRoot,
|
||||
content: "move-destination",
|
||||
});
|
||||
expect(await callError("move_path", {
|
||||
sourcePath: "transfer/move-source.txt",
|
||||
destinationPath: "transfer/move-destination.txt",
|
||||
cwd: testRoot,
|
||||
overwrite: false,
|
||||
})).toContain("already exists");
|
||||
expect(await callOk("move_path", {
|
||||
sourcePath: "transfer/move-source.txt",
|
||||
destinationPath: "transfer/move-destination.txt",
|
||||
cwd: testRoot,
|
||||
overwrite: true,
|
||||
})).toMatchObject({ moved: true });
|
||||
expect(await callOk("read_file", {
|
||||
path: "transfer/move-destination.txt",
|
||||
cwd: testRoot,
|
||||
})).toMatchObject({ content: "move-source" });
|
||||
expect(await callOk("move_path", {
|
||||
sourcePath: "transfer/move-destination.txt",
|
||||
destinationPath: "transfer/move-destination.txt",
|
||||
cwd: testRoot,
|
||||
})).toMatchObject({ moved: false, samePath: true });
|
||||
|
||||
await callOk("make_directory", {
|
||||
path: "transfer/remove-dir/nested",
|
||||
cwd: testRoot,
|
||||
recursive: true,
|
||||
});
|
||||
await callOk("write_file", {
|
||||
path: "transfer/remove-dir/nested/value.txt",
|
||||
cwd: testRoot,
|
||||
content: "remove",
|
||||
});
|
||||
expect(await callError("remove_path", {
|
||||
path: "transfer/remove-dir",
|
||||
cwd: testRoot,
|
||||
recursive: false,
|
||||
force: false,
|
||||
})).toMatch(/directory|EISDIR|recursive/i);
|
||||
await callOk("remove_path", {
|
||||
path: "transfer/remove-dir",
|
||||
cwd: testRoot,
|
||||
recursive: true,
|
||||
force: false,
|
||||
});
|
||||
expect(await callError("stat_path", {
|
||||
path: "transfer/remove-dir",
|
||||
cwd: testRoot,
|
||||
})).toMatch(/ENOENT|no such file/i);
|
||||
expect(await callOk("remove_path", {
|
||||
path: "transfer/does-not-exist",
|
||||
cwd: testRoot,
|
||||
force: true,
|
||||
})).toMatchObject({ removed: true });
|
||||
}, 30_000);
|
||||
|
||||
it("exercises every published tool through MCP", () => {
|
||||
expect([...exercised].sort()).toEqual([...ALL_TOOLS]);
|
||||
});
|
||||
});
|
||||
+130
-1
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -127,4 +127,133 @@ describe("FileService", () => {
|
||||
"new\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves UTF-8 characters across every small chunk boundary", async () => {
|
||||
const text = "ASCII¢한😀끝";
|
||||
await files.writeFileContent(
|
||||
"unicode.txt",
|
||||
undefined,
|
||||
text,
|
||||
"utf8",
|
||||
"overwrite",
|
||||
true,
|
||||
);
|
||||
|
||||
for (let maxBytes = 1; maxBytes <= 8; maxBytes += 1) {
|
||||
let offset = 0;
|
||||
let reconstructed = "";
|
||||
for (let part = 0; part < 100; part += 1) {
|
||||
const chunk = await files.readFileChunk(
|
||||
"unicode.txt",
|
||||
undefined,
|
||||
offset,
|
||||
maxBytes,
|
||||
"utf8",
|
||||
);
|
||||
reconstructed += String(chunk.content);
|
||||
const nextOffset = Number(chunk.nextOffset);
|
||||
expect(nextOffset).toBeGreaterThan(offset);
|
||||
expect(Number(chunk.bytesRead)).toBeLessThanOrEqual(maxBytes + 3);
|
||||
offset = nextOffset;
|
||||
if (chunk.eof === true) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(reconstructed).toBe(text);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid base64 and invalid UTF-8 without silent corruption", async () => {
|
||||
await expect(
|
||||
files.writeFileContent(
|
||||
"invalid-write.bin",
|
||||
undefined,
|
||||
"%%%not-base64%%%",
|
||||
"base64",
|
||||
"overwrite",
|
||||
true,
|
||||
),
|
||||
).rejects.toThrow("base64");
|
||||
await expect(
|
||||
files.uploadChunk(
|
||||
"invalid-upload.bin",
|
||||
undefined,
|
||||
"not@base64",
|
||||
0,
|
||||
true,
|
||||
true,
|
||||
),
|
||||
).rejects.toThrow("base64");
|
||||
|
||||
await files.writeFileContent(
|
||||
"invalid-utf8.bin",
|
||||
undefined,
|
||||
Buffer.from([0xff, 0xfe]).toString("base64"),
|
||||
"base64",
|
||||
"overwrite",
|
||||
true,
|
||||
);
|
||||
await expect(
|
||||
files.readFileChunk("invalid-utf8.bin", undefined, 0, 8, "utf8"),
|
||||
).rejects.toThrow("Invalid UTF-8");
|
||||
const binary = await files.readFileChunk(
|
||||
"invalid-utf8.bin",
|
||||
undefined,
|
||||
0,
|
||||
8,
|
||||
"base64",
|
||||
);
|
||||
expect(binary.content).toBe("//4=");
|
||||
});
|
||||
|
||||
it("applies an explicit file mode when overwriting an existing file", async () => {
|
||||
await files.writeFileContent(
|
||||
"mode.txt",
|
||||
undefined,
|
||||
"first",
|
||||
"utf8",
|
||||
"overwrite",
|
||||
true,
|
||||
0o600,
|
||||
);
|
||||
await files.writeFileContent(
|
||||
"mode.txt",
|
||||
undefined,
|
||||
"second",
|
||||
"utf8",
|
||||
"overwrite",
|
||||
true,
|
||||
0o644,
|
||||
);
|
||||
expect((await stat(path.join(temporaryDirectory, "mode.txt"))).mode & 0o777).toBe(
|
||||
0o644,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a non-forced directory copy when the destination exists", async () => {
|
||||
await files.makeDirectory("source", undefined, true);
|
||||
await files.makeDirectory("destination", undefined, true);
|
||||
await files.writeFileContent(
|
||||
"source/value.txt",
|
||||
undefined,
|
||||
"source",
|
||||
"utf8",
|
||||
"overwrite",
|
||||
true,
|
||||
);
|
||||
await files.writeFileContent(
|
||||
"destination/value.txt",
|
||||
undefined,
|
||||
"destination",
|
||||
"utf8",
|
||||
"overwrite",
|
||||
true,
|
||||
);
|
||||
|
||||
await expect(
|
||||
files.copyPath("source", "destination", undefined, true, false),
|
||||
).rejects.toThrow("already exists");
|
||||
expect(await readFile(path.join(temporaryDirectory, "destination/value.txt"), "utf8"))
|
||||
.toBe("destination");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,13 @@ import { loadConfig, type AppConfig } from "../src/config.js";
|
||||
import { startHttpServer, type RunningHttpServer } from "../src/http-server.js";
|
||||
import { createServices, type McpServices } from "../src/mcp-server.js";
|
||||
|
||||
interface JsonRpcResponse {
|
||||
result?: {
|
||||
tools?: Array<{ name: string }>;
|
||||
structuredContent?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
describe("remote development MCP server", () => {
|
||||
let temporaryDirectory: string;
|
||||
let config: AppConfig;
|
||||
@@ -69,6 +76,7 @@ describe("remote development MCP server", () => {
|
||||
});
|
||||
await client.connect(transport);
|
||||
try {
|
||||
expect(transport.sessionId).toBeUndefined();
|
||||
expect(client.getServerVersion()).toMatchObject({
|
||||
name: "cokacremote",
|
||||
version: "0.1.0",
|
||||
@@ -121,4 +129,121 @@ describe("remote development MCP server", () => {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("handles every tool call as an independent stateless request", async () => {
|
||||
const post = async (
|
||||
body: unknown,
|
||||
additionalHeaders: Record<string, string> = {},
|
||||
): Promise<Response> =>
|
||||
fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: "Bearer integration-secret",
|
||||
accept: "application/json, text/event-stream",
|
||||
"content-type": "application/json",
|
||||
...additionalHeaders,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const initializeResponse = await post({
|
||||
jsonrpc: "2.0",
|
||||
id: 10,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2025-11-25",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "stateless-test", version: "1" },
|
||||
},
|
||||
});
|
||||
expect(initializeResponse.status).toBe(200);
|
||||
expect(initializeResponse.headers.get("content-type")).toContain("application/json");
|
||||
expect(initializeResponse.headers.get("mcp-session-id")).toBeNull();
|
||||
expect(initializeResponse.headers.get("x-request-id")).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
);
|
||||
|
||||
const listResponse = await post(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 11,
|
||||
method: "tools/list",
|
||||
params: {},
|
||||
},
|
||||
{ "mcp-session-id": "stale-session-from-the-previous-deployment" },
|
||||
);
|
||||
expect(listResponse.status).toBe(200);
|
||||
const listed = (await listResponse.json()) as JsonRpcResponse;
|
||||
expect(listed.result?.tools?.map((tool) => tool.name)).toContain("exec_command");
|
||||
|
||||
const startResponse = await post({
|
||||
jsonrpc: "2.0",
|
||||
id: 12,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "exec_command",
|
||||
arguments: {
|
||||
cmd: "node -e \"setTimeout(() => console.log('stateless-ok'), 100)\"",
|
||||
yieldTimeMs: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(startResponse.status).toBe(200);
|
||||
const started = (await startResponse.json()) as JsonRpcResponse;
|
||||
const sessionId = started.result?.structuredContent?.sessionId;
|
||||
expect(sessionId).toEqual(expect.any(String));
|
||||
expect(started.result?.structuredContent).toMatchObject({
|
||||
running: true,
|
||||
completed: false,
|
||||
});
|
||||
|
||||
const readResponse = await post({
|
||||
jsonrpc: "2.0",
|
||||
id: 13,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "read_process",
|
||||
arguments: { sessionId, waitMs: 3000 },
|
||||
},
|
||||
});
|
||||
expect(readResponse.status).toBe(200);
|
||||
const read = (await readResponse.json()) as JsonRpcResponse;
|
||||
expect(read.result?.structuredContent?.stdout).toContain("stateless-ok");
|
||||
const nextSeq = read.result?.structuredContent?.nextSeq;
|
||||
expect(nextSeq).toEqual(expect.any(Number));
|
||||
|
||||
const completionResponse = await post({
|
||||
jsonrpc: "2.0",
|
||||
id: 14,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "read_process",
|
||||
arguments: { sessionId, afterSeq: nextSeq, waitMs: 3000 },
|
||||
},
|
||||
});
|
||||
expect(completionResponse.status).toBe(200);
|
||||
const completion = (await completionResponse.json()) as JsonRpcResponse;
|
||||
expect(completion.result?.structuredContent).toMatchObject({
|
||||
running: false,
|
||||
completed: true,
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
const getResponse = await fetch(endpoint, {
|
||||
headers: {
|
||||
authorization: "Bearer integration-secret",
|
||||
accept: "text/event-stream",
|
||||
},
|
||||
});
|
||||
expect(getResponse.status).toBe(405);
|
||||
expect(getResponse.headers.get("allow")).toBe("POST");
|
||||
|
||||
const healthResponse = await fetch(new URL("/health", endpoint));
|
||||
expect(await healthResponse.json()).toMatchObject({
|
||||
status: "ok",
|
||||
transportMode: "stateless-json",
|
||||
activeMcpSessions: 0,
|
||||
activeMcpRequests: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user