first commit

This commit is contained in:
kst
2026-08-19 22:52:46 +09:00
commit 8454476c93
30 changed files with 7352 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { loadConfig } from "../src/config.js";
describe("loadConfig", () => {
it("requires authentication unless explicitly disabled", () => {
expect(() => loadConfig({}, "/tmp")).toThrow("MCP_AUTH_TOKEN is required");
expect(loadConfig({ MCP_ALLOW_NO_AUTH: "true" }, "/tmp").allowNoAuth).toBe(true);
});
it("loads full-access host settings", () => {
const config = loadConfig(
{
MCP_AUTH_TOKEN: "secret",
MCP_PORT: "4321",
MCP_DEFAULT_CWD: "/",
MCP_ALLOWED_HOSTS: "mcp.example.com,localhost",
},
"/tmp",
);
expect(config).toMatchObject({
port: 4321,
defaultCwd: "/",
authToken: "secret",
allowedHosts: ["mcp.example.com", "localhost"],
});
});
it("requires public HTTPS metadata when OAuth is enabled", () => {
expect(() =>
loadConfig({ MCP_AUTH_TOKEN: "secret", MCP_OAUTH_ENABLED: "true" }, "/tmp"),
).toThrow("MCP_OAUTH_ISSUER is required");
const config = loadConfig(
{
MCP_AUTH_TOKEN: "secret",
MCP_OAUTH_ENABLED: "true",
MCP_PUBLIC_URL: "https://mcp.example.com",
MCP_OAUTH_STATE_FILE: "/tmp/oauth-state.json",
},
"/tmp",
);
expect(config).toMatchObject({
oauthEnabled: true,
oauthIssuerUrl: "https://mcp.example.com/",
oauthResourceUrl: "https://mcp.example.com/mcp",
oauthStateFile: "/tmp/oauth-state.json",
});
});
});
+130
View File
@@ -0,0 +1,130 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { FileService } from "../src/file-service.js";
describe("FileService", () => {
let temporaryDirectory: string;
let files: FileService;
beforeEach(async () => {
temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), "remote-dev-mcp-test-"));
files = new FileService({
defaultCwd: temporaryDirectory,
maxChunkBytes: 1024 * 1024,
maxEditFileBytes: 1024 * 1024,
maxOutputBytes: 1024 * 1024,
});
});
afterEach(async () => {
await rm(temporaryDirectory, { recursive: true, force: true });
});
it("writes, reads, lists, and replaces text", async () => {
await files.writeFileContent(
"src/example.txt",
undefined,
"alpha beta\n",
"utf8",
"overwrite",
true,
);
await files.replaceInFile(
"src/example.txt",
undefined,
"beta",
"gamma",
false,
1,
);
const read = await files.readFileChunk(
"src/example.txt",
undefined,
0,
1024,
"utf8",
);
const listed = await files.listDirectory(".", undefined, {
recursive: true,
includeMetadata: true,
});
expect(read.content).toBe("alpha gamma\n");
expect(read.eof).toBe(true);
expect(listed.entries).toEqual(
expect.arrayContaining([
expect.objectContaining({ relativePath: path.join("src", "example.txt") }),
]),
);
});
it("uploads and downloads binary chunks with offsets", async () => {
const first = await files.uploadChunk(
"artifact.bin",
undefined,
Buffer.from("hello").toString("base64"),
0,
true,
true,
);
await files.uploadChunk(
"artifact.bin",
undefined,
Buffer.from(" world").toString("base64"),
first.nextOffset as number,
false,
true,
);
const downloaded = await files.downloadChunk(
"artifact.bin",
undefined,
0,
1024,
);
const hashed = await files.hashFile("artifact.bin", undefined, "sha256");
expect(Buffer.from(downloaded.dataBase64 as string, "base64").toString()).toBe(
"hello world",
);
expect(downloaded.eof).toBe(true);
expect(hashed.digest).toBe(
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
);
});
it("validates and applies a unified diff", async () => {
await writeFile(path.join(temporaryDirectory, "patch.txt"), "old\n", "utf8");
const patchText = [
"diff --git a/patch.txt b/patch.txt",
"--- a/patch.txt",
"+++ b/patch.txt",
"@@ -1 +1 @@",
"-old",
"+new",
"",
].join("\n");
const checked = await files.applyPatch(patchText, undefined, {
checkOnly: true,
reverse: false,
threeWay: false,
});
const applied = await files.applyPatch(patchText, undefined, {
checkOnly: false,
reverse: false,
threeWay: false,
});
expect(checked.applied).toBe(false);
expect(applied.applied).toBe(true);
expect(await readFile(path.join(temporaryDirectory, "patch.txt"), "utf8")).toBe(
"new\n",
);
});
});
+124
View File
@@ -0,0 +1,124 @@
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, type AppConfig } from "../src/config.js";
import { startHttpServer, type RunningHttpServer } from "../src/http-server.js";
import { createServices, type McpServices } from "../src/mcp-server.js";
describe("remote development MCP server", () => {
let temporaryDirectory: string;
let config: AppConfig;
let services: McpServices;
let running: RunningHttpServer;
let endpoint: URL;
beforeAll(async () => {
temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), "remote-dev-mcp-http-test-"));
config = loadConfig(
{
MCP_AUTH_TOKEN: "integration-secret",
MCP_HOST: "127.0.0.1",
MCP_DEFAULT_CWD: temporaryDirectory,
MCP_MAX_FILE_CHUNK_BYTES: "65536",
},
temporaryDirectory,
);
config.port = 0;
services = createServices(config);
running = await startHttpServer(config, services);
const address = running.httpServer.address() as AddressInfo;
endpoint = new URL(`http://127.0.0.1:${address.port}${config.endpoint}`);
});
afterAll(async () => {
await running.close();
await rm(temporaryDirectory, { recursive: true, force: true });
});
it("rejects unauthenticated MCP initialization", async () => {
const response = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2025-11-25",
capabilities: {},
clientInfo: { name: "test", version: "1" },
},
}),
});
expect(response.status).toBe(401);
});
it("lists tools and executes script and file workflows", async () => {
const client = new Client({ name: "integration-test", version: "1.0.0" });
const transport = new StreamableHTTPClientTransport(endpoint, {
requestInit: {
headers: { Authorization: "Bearer integration-secret" },
},
});
await client.connect(transport);
try {
expect(client.getServerVersion()).toMatchObject({
name: "cokacremote",
version: "0.1.0",
});
const tools = await client.listTools();
expect(tools.tools.map((tool) => tool.name)).toEqual(
expect.arrayContaining([
"exec_command",
"run_script",
"write_stdin",
"read_file",
"write_file",
"apply_patch",
"upload_file",
"download_file",
]),
);
const scriptResult = await client.callTool({
name: "run_script",
arguments: {
runtime: "node",
script: "console.log(6 * 7)",
yieldTimeMs: 2000,
},
});
expect(scriptResult.isError).not.toBe(true);
expect(scriptResult.structuredContent).toMatchObject({
completed: true,
exitCode: 0,
stdout: "42\n",
});
const writeResult = await client.callTool({
name: "write_file",
arguments: { path: "hello.txt", content: "hello MCP\n" },
});
expect(writeResult.isError).not.toBe(true);
const readResult = await client.callTool({
name: "read_file",
arguments: { path: "hello.txt" },
});
expect(readResult.structuredContent).toMatchObject({
content: "hello MCP\n",
eof: true,
});
} finally {
await transport.terminateSession();
await client.close();
}
});
});
+249
View File
@@ -0,0 +1,249 @@
import { createHash, randomBytes } from "node:crypto";
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
import type { AddressInfo } from "node:net";
import { createServer } 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, type AppConfig } from "../src/config.js";
import { startHttpServer, type RunningHttpServer } from "../src/http-server.js";
import { createServices } from "../src/mcp-server.js";
async function reservePort(): Promise<number> {
const server = createServer();
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const port = (server.address() as AddressInfo).port;
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
return port;
}
function form(values: Record<string, string>): URLSearchParams {
return new URLSearchParams(values);
}
describe("OAuth 2.1 MCP authorization", () => {
let temporaryDirectory: string;
let stateFile: string;
let config: AppConfig;
let running: RunningHttpServer;
let baseUrl: string;
let resourceUrl: string;
beforeAll(async () => {
temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), "remote-dev-mcp-oauth-test-"));
stateFile = path.join(temporaryDirectory, "oauth", "state.json");
const port = await reservePort();
baseUrl = `http://127.0.0.1:${port}`;
resourceUrl = `${baseUrl}/mcp`;
config = loadConfig(
{
MCP_AUTH_TOKEN: "oauth-login-secret",
MCP_OAUTH_ENABLED: "true",
MCP_PUBLIC_URL: baseUrl,
MCP_OAUTH_STATE_FILE: stateFile,
MCP_HOST: "127.0.0.1",
MCP_PORT: String(port),
MCP_DEFAULT_CWD: temporaryDirectory,
},
temporaryDirectory,
);
running = await startHttpServer(config, createServices(config));
});
afterAll(async () => {
await running.close();
await rm(temporaryDirectory, { recursive: true, force: true });
});
it("discovers, authorizes with PKCE, refreshes, revokes, and calls MCP tools", async () => {
const unauthenticated = await fetch(resourceUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2025-11-25",
capabilities: {},
clientInfo: { name: "oauth-test", version: "1" },
},
}),
});
expect(unauthenticated.status).toBe(401);
expect(unauthenticated.headers.get("www-authenticate")).toContain(
`${baseUrl}/.well-known/oauth-protected-resource/mcp`,
);
for (const metadataPath of [
"/.well-known/oauth-protected-resource",
"/.well-known/oauth-protected-resource/mcp",
]) {
const response = await fetch(`${baseUrl}${metadataPath}`);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
resource: resourceUrl,
authorization_servers: [`${baseUrl}/`],
scopes_supported: ["mcp:tools"],
resource_name: "cokacremote",
});
}
const metadataResponse = await fetch(`${baseUrl}/.well-known/oauth-authorization-server`);
expect(metadataResponse.status).toBe(200);
expect(await metadataResponse.json()).toMatchObject({
issuer: `${baseUrl}/`,
authorization_endpoint: `${baseUrl}/authorize`,
token_endpoint: `${baseUrl}/token`,
registration_endpoint: `${baseUrl}/register`,
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: expect.arrayContaining(["none"]),
});
const redirectUri = "https://chatgpt.com/connector/oauth/test-callback";
const registrationResponse = await fetch(`${baseUrl}/register`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
redirect_uris: [redirectUri],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
client_name: "ChatGPT OAuth integration test",
scope: "mcp:tools",
}),
});
expect(registrationResponse.status).toBe(201);
const registered = (await registrationResponse.json()) as { client_id: string };
expect(registered.client_id).toBeTruthy();
const codeVerifier = randomBytes(48).toString("base64url");
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
const authorizationValues = {
client_id: registered.client_id,
redirect_uri: redirectUri,
response_type: "code",
code_challenge: codeChallenge,
code_challenge_method: "S256",
scope: "mcp:tools",
state: "oauth-test-state",
resource: resourceUrl,
};
const loginPage = await fetch(`${baseUrl}/authorize?${form(authorizationValues)}`, {
redirect: "manual",
});
expect(loginPage.status).toBe(200);
expect(loginPage.headers.get("content-security-policy")).toContain(
"form-action 'self' https://chatgpt.com",
);
expect(await loginPage.text()).toContain("MCP 인증키");
const rejectedLogin = await fetch(`${baseUrl}/authorize`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: form({ ...authorizationValues, access_key: "wrong-key" }),
redirect: "manual",
});
expect(rejectedLogin.status).toBe(401);
const approvedLogin = await fetch(`${baseUrl}/authorize`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: form({ ...authorizationValues, access_key: "oauth-login-secret" }),
redirect: "manual",
});
expect(approvedLogin.status).toBe(302);
const callback = new URL(approvedLogin.headers.get("location")!);
expect(callback.origin + callback.pathname).toBe(redirectUri);
expect(callback.searchParams.get("state")).toBe("oauth-test-state");
const authorizationCode = callback.searchParams.get("code");
expect(authorizationCode).toBeTruthy();
const tokenResponse = await fetch(`${baseUrl}/token`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: form({
grant_type: "authorization_code",
client_id: registered.client_id,
code: authorizationCode!,
code_verifier: codeVerifier,
redirect_uri: redirectUri,
resource: resourceUrl,
}),
});
expect(tokenResponse.status).toBe(200);
const tokens = (await tokenResponse.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
scope: string;
};
expect(tokens).toMatchObject({ expires_in: 3600, scope: "mcp:tools" });
await running.close();
running = await startHttpServer(config, createServices(config));
const client = new Client({ name: "oauth-integration-test", version: "1.0.0" });
const transport = new StreamableHTTPClientTransport(new URL(resourceUrl), {
requestInit: { headers: { Authorization: `Bearer ${tokens.access_token}` } },
});
await client.connect(transport);
try {
const tools = await client.listTools();
expect(tools.tools.some((tool) => tool.name === "run_script")).toBe(true);
} finally {
await transport.terminateSession();
await client.close();
}
const refreshResponse = await fetch(`${baseUrl}/token`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: form({
grant_type: "refresh_token",
client_id: registered.client_id,
refresh_token: tokens.refresh_token,
resource: resourceUrl,
}),
});
expect(refreshResponse.status).toBe(200);
const refreshed = (await refreshResponse.json()) as {
access_token: string;
refresh_token: string;
};
expect(refreshed.access_token).not.toBe(tokens.access_token);
expect(refreshed.refresh_token).not.toBe(tokens.refresh_token);
const revokeResponse = await fetch(`${baseUrl}/revoke`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: form({ client_id: registered.client_id, token: refreshed.access_token }),
});
expect(revokeResponse.status).toBe(200);
const revokedRequest = await fetch(resourceUrl, {
method: "POST",
headers: {
authorization: `Bearer ${refreshed.access_token}`,
"content-type": "application/json",
},
body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "initialize", params: {} }),
});
expect(revokedRequest.status).toBe(401);
expect((await stat(stateFile)).mode & 0o777).toBe(0o600);
const persisted = await readFile(stateFile, "utf8");
expect(persisted).not.toContain(tokens.access_token);
expect(persisted).not.toContain(tokens.refresh_token);
});
});
+79
View File
@@ -0,0 +1,79 @@
import { afterEach, describe, expect, it } from "vitest";
import { ProcessManager } from "../src/process-manager.js";
function createManager(): ProcessManager {
return new ProcessManager({
maxRetainedOutputBytes: 1024 * 1024,
processRetentionMs: 60_000,
maxProcesses: 16,
defaultMaxOutputBytes: 1024 * 1024,
});
}
describe("ProcessManager", () => {
let manager: ProcessManager | undefined;
afterEach(async () => {
await manager?.shutdown();
});
it("captures stdout, stderr, and exit state", async () => {
manager = createManager();
const sessionId = manager.start({
executable: "/bin/bash",
args: ["-c", "printf stdout; printf stderr >&2"],
commandForDisplay: "test output",
cwd: process.cwd(),
});
await manager.waitForExit(sessionId, 2000);
const result = await manager.read(sessionId);
expect(result).toMatchObject({
running: false,
exitCode: 0,
stdout: "stdout",
stderr: "stderr",
timedOut: false,
});
expect(result.output).toContain("stdout");
expect(result.output).toContain("stderr");
});
it("supports interactive stdin and closes cleanly", async () => {
manager = createManager();
const sessionId = manager.start({
executable: "/bin/cat",
args: [],
commandForDisplay: "cat",
cwd: process.cwd(),
});
await manager.write(sessionId, "hello\n", true);
await manager.waitForExit(sessionId, 2000);
const result = await manager.read(sessionId);
expect(result.running).toBe(false);
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe("hello\n");
});
it("terminates a command when its timeout expires", async () => {
manager = createManager();
const sessionId = manager.start({
executable: "/bin/bash",
args: ["-c", "sleep 10"],
commandForDisplay: "sleep 10",
cwd: process.cwd(),
timeoutMs: 50,
});
await manager.waitForExit(sessionId, 3000);
const result = await manager.read(sessionId);
expect(result.running).toBe(false);
expect(result.timedOut).toBe(true);
expect(result.error).toContain("timeout");
});
});