feat: add CraftTable MCP OAuth CLI
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { parse } from "jsonc-parser";
|
||||
import { configureAgents, type CommandResult, type CommandRunner, launchCommand, unconfigureAgents } from "../src/agents.js";
|
||||
import { resolveClientOptions } from "../src/config.js";
|
||||
|
||||
class FakeRunner implements CommandRunner {
|
||||
readonly calls: Array<{ command: string; args: string[] }> = [];
|
||||
existing: Record<string, string | undefined> = {};
|
||||
|
||||
async run(command: string, args: string[]): Promise<CommandResult> {
|
||||
this.calls.push({ command, args });
|
||||
const agent = command === "codex" ? "codex" : "claude";
|
||||
if (args[1] === "get") {
|
||||
const value = this.existing[agent];
|
||||
return value === undefined
|
||||
? { code: 1, stdout: "", stderr: "MCP server not found" }
|
||||
: { code: 0, stdout: value, stderr: "" };
|
||||
}
|
||||
if (args[1] === "remove") {
|
||||
this.existing[agent] = undefined;
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
if (args[1] === "add") {
|
||||
const separator = args.indexOf("--");
|
||||
const commandParts = args.slice(separator + 1);
|
||||
this.existing[agent] = JSON.stringify({ transport: { command: commandParts[0], args: commandParts.slice(1) } });
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
return { code: 1, stdout: "", stderr: "unexpected command" };
|
||||
}
|
||||
}
|
||||
|
||||
const options = resolveClientOptions({ url: "https://example.test/mcp", clientId: "client", callbackPort: 48321, env: {} });
|
||||
const cliEntry = path.resolve("dist", "cli.js");
|
||||
const nodePath = path.resolve("bin", "node.exe");
|
||||
|
||||
test("Codex and Claude adapters add, detect idempotency, replace conflicts, remove, and dry-run", async () => {
|
||||
const runner = new FakeRunner();
|
||||
assert.deepEqual(await configureAgents({ target: "codex", options, cliEntry, nodePath, runner }), [{ agent: "codex", action: "added" }]);
|
||||
assert.deepEqual(await configureAgents({ target: "codex", options, cliEntry, nodePath, runner }), [{ agent: "codex", action: "unchanged" }]);
|
||||
runner.existing.codex = JSON.stringify({ transport: { command: "other", args: [] } });
|
||||
await assert.rejects(configureAgents({ target: "codex", options, cliEntry, nodePath, runner, confirm: async () => false }), /not changed/);
|
||||
assert.deepEqual(await configureAgents({ target: "codex", options, cliEntry, nodePath, runner, force: true }), [{ agent: "codex", action: "replaced" }]);
|
||||
assert.deepEqual(await unconfigureAgents({ target: "codex", options, cliEntry, nodePath, runner, dryRun: true }), [{ agent: "codex", action: "would-remove" }]);
|
||||
assert.deepEqual(await unconfigureAgents({ target: "codex", options, cliEntry, nodePath, runner }), [{ agent: "codex", action: "removed" }]);
|
||||
|
||||
assert.deepEqual(await configureAgents({ target: "claude", options, cliEntry, nodePath, runner, dryRun: true }), [{ agent: "claude", action: "would-add" }]);
|
||||
assert.equal(runner.existing.claude, undefined);
|
||||
});
|
||||
|
||||
test("OpenCode adapter preserves JSONC, creates one backup, is idempotent, and unconfigures", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "crafttable-mcp-agent-test-"));
|
||||
const filePath = path.join(directory, "opencode.json");
|
||||
await import("node:fs/promises").then(({ writeFile }) => writeFile(filePath, "{\n // keep this comment\n \"theme\": \"dark\"\n}\n", "utf8"));
|
||||
try {
|
||||
assert.deepEqual(await configureAgents({ target: "opencode", options, cliEntry, nodePath, opencodePath: filePath }), [{ agent: "opencode", action: "added" }]);
|
||||
const configuredText = await readFile(filePath, "utf8");
|
||||
assert.match(configuredText, /keep this comment/);
|
||||
const configured = parse(configuredText) as { theme: string; mcp: Record<string, { type: string; command: string[] }> };
|
||||
assert.equal(configured.theme, "dark");
|
||||
assert.equal(configured.mcp.crafttable?.type, "local");
|
||||
assert.deepEqual(configured.mcp.crafttable?.command, launchCommand(options, cliEntry, nodePath));
|
||||
assert.match(await readFile(`${filePath}.crafttable-mcp.backup`, "utf8"), /keep this comment/);
|
||||
assert.deepEqual(await configureAgents({ target: "opencode", options, cliEntry, nodePath, opencodePath: filePath }), [{ agent: "opencode", action: "unchanged" }]);
|
||||
assert.deepEqual(await unconfigureAgents({ target: "opencode", options, cliEntry, nodePath, opencodePath: filePath }), [{ agent: "opencode", action: "removed" }]);
|
||||
const removed = parse(await readFile(filePath, "utf8")) as { mcp?: Record<string, unknown> };
|
||||
assert.equal(removed.mcp?.crafttable, undefined);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import { createProxyServer } from "../src/bridge.js";
|
||||
|
||||
test("stdio proxy forwards tools, resources, templates, reads, errors, and cursors", async () => {
|
||||
const calls: Array<{ method: string; params: unknown }> = [];
|
||||
const upstream = {
|
||||
listTools: async (params?: { cursor?: string }) => {
|
||||
calls.push({ method: "tools/list", params });
|
||||
return { tools: [{ name: "list_spaces", description: "List spaces", inputSchema: { type: "object" } }], nextCursor: "tools-next" };
|
||||
},
|
||||
callTool: async (params: { name: string; arguments?: Record<string, unknown> }) => {
|
||||
calls.push({ method: "tools/call", params });
|
||||
if (params.name === "explode") throw new Error("upstream failed");
|
||||
return { content: [{ type: "text", text: JSON.stringify(params.arguments) }] };
|
||||
},
|
||||
listResources: async (params?: { cursor?: string }) => {
|
||||
calls.push({ method: "resources/list", params });
|
||||
return { resources: [{ uri: "gamecraft://spaces", name: "Spaces" }], nextCursor: "resources-next" };
|
||||
},
|
||||
listResourceTemplates: async (params?: { cursor?: string }) => {
|
||||
calls.push({ method: "resources/templates/list", params });
|
||||
return { resourceTemplates: [{ uriTemplate: "gamecraft://space/{spaceId}", name: "Space" }] };
|
||||
},
|
||||
readResource: async (params: { uri: string }) => {
|
||||
calls.push({ method: "resources/read", params });
|
||||
return { contents: [{ uri: params.uri, text: "resource-value" }] };
|
||||
},
|
||||
};
|
||||
const server = createProxyServer(upstream);
|
||||
const client = new Client({ name: "bridge-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
assert.equal((await client.listTools({ cursor: "tool-cursor" })).nextCursor, "tools-next");
|
||||
assert.equal((await client.listResources({ cursor: "resource-cursor" })).nextCursor, "resources-next");
|
||||
assert.equal((await client.listResourceTemplates({ cursor: "template-cursor" })).resourceTemplates.length, 1);
|
||||
const resource = await client.readResource({ uri: "gamecraft://spaces" });
|
||||
assert.equal("text" in resource.contents[0]! ? resource.contents[0].text : undefined, "resource-value");
|
||||
const called = await client.callTool({ name: "list_spaces", arguments: { page: 2 } });
|
||||
const content = called.content as Array<{ type: string; text?: string }>;
|
||||
assert.match(String(content[0]?.type === "text" ? content[0].text : ""), /\"page\":2/);
|
||||
await assert.rejects(client.callTool({ name: "explode", arguments: {} }), /upstream failed/);
|
||||
assert.deepEqual(calls.slice(0, 3), [
|
||||
{ method: "tools/list", params: { cursor: "tool-cursor" } },
|
||||
{ method: "resources/list", params: { cursor: "resource-cursor" } },
|
||||
{ method: "resources/templates/list", params: { cursor: "template-cursor" } },
|
||||
]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:net";
|
||||
import test from "node:test";
|
||||
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import { OAuthCallbackServer } from "../src/callbackServer.js";
|
||||
import { credentialAccount, resolveClientOptions, validateMcpUrl } from "../src/config.js";
|
||||
import type { TokenStore } from "../src/credentials.js";
|
||||
import { GameCraftOAuthProvider } from "../src/oauthProvider.js";
|
||||
import { logoutRemote } from "../src/remote.js";
|
||||
|
||||
class MemoryTokens implements TokenStore {
|
||||
readonly values = new Map<string, OAuthTokens>();
|
||||
async get(account: string): Promise<OAuthTokens | undefined> { return this.values.get(account); }
|
||||
async set(account: string, tokens: OAuthTokens): Promise<void> { this.values.set(account, tokens); }
|
||||
async delete(account: string): Promise<void> { this.values.delete(account); }
|
||||
}
|
||||
|
||||
class MemoryDiscovery {
|
||||
values = new Map<string, unknown>();
|
||||
async get(account: string): Promise<undefined> { return this.values.get(account) as undefined; }
|
||||
async set(account: string, state: unknown): Promise<void> { this.values.set(account, state); }
|
||||
async delete(account: string): Promise<void> { this.values.delete(account); }
|
||||
}
|
||||
|
||||
test("connection options enforce HTTPS and isolate credentials by URL and client ID", () => {
|
||||
assert.equal(validateMcpUrl("https://example.test/mcp/").toString(), "https://example.test/mcp");
|
||||
assert.equal(validateMcpUrl("http://127.0.0.1:8787/mcp").protocol, "http:");
|
||||
assert.throws(() => validateMcpUrl("http://example.test/mcp"), /HTTPS/);
|
||||
assert.throws(() => validateMcpUrl("https://example.test/mcp?token=secret"), /query/);
|
||||
const first = resolveClientOptions({ url: "https://one.test/mcp", clientId: "client", callbackPort: 48321, env: {} });
|
||||
const second = resolveClientOptions({ url: "https://two.test/mcp", clientId: "client", callbackPort: 48321, env: {} });
|
||||
const third = resolveClientOptions({ url: "https://one.test/mcp", clientId: "other", callbackPort: 48321, env: {} });
|
||||
assert.notEqual(credentialAccount(first), credentialAccount(second));
|
||||
assert.notEqual(credentialAccount(first), credentialAccount(third));
|
||||
});
|
||||
|
||||
test("OAuth provider validates state and replaces rotated refresh tokens", async () => {
|
||||
const options = resolveClientOptions({ url: "https://example.test/mcp", clientId: "client", callbackPort: 48321, env: {} });
|
||||
const tokens = new MemoryTokens();
|
||||
const discovery = new MemoryDiscovery();
|
||||
const provider = new GameCraftOAuthProvider(options, tokens, discovery as never, () => undefined, "expected-state");
|
||||
assert.equal(provider.validateState("expected-state"), true);
|
||||
assert.equal(provider.validateState("wrong-state"), false);
|
||||
provider.saveCodeVerifier("verifier");
|
||||
assert.equal(provider.codeVerifier(), "verifier");
|
||||
await provider.saveTokens({ access_token: "access-one", refresh_token: "refresh-one", token_type: "Bearer" });
|
||||
await provider.saveTokens({ access_token: "access-two", refresh_token: "refresh-two", token_type: "Bearer" });
|
||||
assert.equal((await provider.tokens())?.refresh_token, "refresh-two");
|
||||
});
|
||||
|
||||
test("OAuth callback validates state without exposing the authorization code", async () => {
|
||||
const port = await availablePort();
|
||||
const callback = new OAuthCallbackServer(port, (state) => state === "expected");
|
||||
await callback.listen();
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/oauth/callback?code=sensitive-code&state=expected`);
|
||||
assert.equal(response.status, 200);
|
||||
assert.doesNotMatch(await response.text(), /sensitive-code/);
|
||||
assert.deepEqual(await callback.wait(1000), { code: "sensitive-code" });
|
||||
} finally {
|
||||
await callback.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("OAuth callback reports timeout and occupied ports", async () => {
|
||||
const timeoutPort = await availablePort();
|
||||
const timeoutServer = new OAuthCallbackServer(timeoutPort, () => true);
|
||||
await timeoutServer.listen();
|
||||
try {
|
||||
await assert.rejects(timeoutServer.wait(10), /timed out/);
|
||||
} finally {
|
||||
await timeoutServer.close();
|
||||
}
|
||||
|
||||
const occupiedPort = await availablePort();
|
||||
const blocker = createServer();
|
||||
await new Promise<void>((resolve) => blocker.listen(occupiedPort, "127.0.0.1", resolve));
|
||||
try {
|
||||
await assert.rejects(new OAuthCallbackServer(occupiedPort, () => true).listen(), /Could not listen/);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => blocker.close((error) => error ? reject(error) : resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
test("logout revokes refresh and access tokens before deleting local credentials", async () => {
|
||||
const options = resolveClientOptions({ url: "https://example.test/mcp", clientId: "client", callbackPort: 48321, env: {} });
|
||||
const account = credentialAccount(options);
|
||||
const tokens = new MemoryTokens();
|
||||
tokens.values.set(account, { access_token: "access", refresh_token: "refresh", token_type: "Bearer" });
|
||||
const discovery = new MemoryDiscovery();
|
||||
const requests: Array<{ url: string; body?: string }> = [];
|
||||
const fetchFn = async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
const url = String(input);
|
||||
requests.push({ url, body: init?.body?.toString() });
|
||||
if (url.includes("oauth-protected-resource")) return Response.json({ authorization_servers: ["https://id.example/oidc"] });
|
||||
if (url.includes("openid-configuration")) return Response.json({ revocation_endpoint: "https://id.example/revoke" });
|
||||
return new Response(null, { status: 200 });
|
||||
};
|
||||
const result = await logoutRemote(options, tokens, discovery as never, false, fetchFn as typeof fetch);
|
||||
assert.deepEqual(result, { hadCredential: true, revoked: true });
|
||||
assert.equal(tokens.values.has(account), false);
|
||||
assert.equal(requests.filter((request) => request.url.endsWith("/revoke")).length, 1);
|
||||
assert.match(requests.find((request) => request.body?.includes("refresh_token"))?.body ?? "", /token=refresh/);
|
||||
});
|
||||
|
||||
test("failed remote revocation retains local credentials", async () => {
|
||||
const options = resolveClientOptions({ url: "https://example.test/mcp", clientId: "client", callbackPort: 48321, env: {} });
|
||||
const account = credentialAccount(options);
|
||||
const tokens = new MemoryTokens();
|
||||
tokens.values.set(account, { access_token: "access", token_type: "Bearer" });
|
||||
const fetchFn = async (input: string | URL | Request): Promise<Response> => {
|
||||
const url = String(input);
|
||||
if (url.includes("oauth-protected-resource")) return Response.json({ authorization_servers: ["https://id.example/oidc"] });
|
||||
if (url.includes("openid-configuration")) return Response.json({ revocation_endpoint: "https://id.example/revoke" });
|
||||
return new Response(null, { status: 500 });
|
||||
};
|
||||
await assert.rejects(logoutRemote(options, tokens, new MemoryDiscovery() as never, false, fetchFn as typeof fetch), /retained/);
|
||||
assert.equal(tokens.values.has(account), true);
|
||||
});
|
||||
|
||||
async function availablePort(): Promise<number> {
|
||||
const server = createServer();
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("could not allocate test port");
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
return address.port;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import test from "node:test";
|
||||
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { isInitializeRequest, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { credentialAccount, resolveClientOptions } from "../src/config.js";
|
||||
import type { TokenStore } from "../src/credentials.js";
|
||||
import { loginRemote } from "../src/remote.js";
|
||||
|
||||
class MemoryTokens implements TokenStore {
|
||||
readonly values = new Map<string, OAuthTokens>();
|
||||
async get(account: string): Promise<OAuthTokens | undefined> { return this.values.get(account); }
|
||||
async set(account: string, tokens: OAuthTokens): Promise<void> { this.values.set(account, tokens); }
|
||||
async delete(account: string): Promise<void> { this.values.delete(account); }
|
||||
}
|
||||
|
||||
class MemoryDiscovery {
|
||||
value: unknown;
|
||||
async get(): Promise<undefined> { return this.value as undefined; }
|
||||
async set(_account: string, value: unknown): Promise<void> { this.value = value; }
|
||||
async delete(): Promise<void> { this.value = undefined; }
|
||||
}
|
||||
|
||||
test("browser PKCE login exchanges, refreshes rotated tokens, and initializes MCP without leaking secrets", async () => {
|
||||
const callbackPort = await availablePort();
|
||||
const app = express();
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
app.use(express.json());
|
||||
const server = app.listen(0, "127.0.0.1");
|
||||
await new Promise<void>((resolve) => server.once("listening", resolve));
|
||||
const address = server.address() as AddressInfo;
|
||||
const origin = `http://127.0.0.1:${address.port}`;
|
||||
const resource = `${origin}/mcp`;
|
||||
const authorizationCodes = new Map<string, { challenge: string; resource: string }>();
|
||||
let authorizationRequest: URL | undefined;
|
||||
let refreshCount = 0;
|
||||
|
||||
let mcpServer: Server | undefined;
|
||||
let transport: StreamableHTTPServerTransport | undefined;
|
||||
|
||||
app.get("/.well-known/oauth-protected-resource/mcp", (_request, response) => response.json({
|
||||
resource,
|
||||
authorization_servers: [origin],
|
||||
scopes_supported: ["openid", "profile", "email", "offline_access"],
|
||||
bearer_methods_supported: ["header"],
|
||||
}));
|
||||
const authorizationMetadata = {
|
||||
issuer: origin,
|
||||
authorization_endpoint: `${origin}/authorize`,
|
||||
token_endpoint: `${origin}/token`,
|
||||
revocation_endpoint: `${origin}/revoke`,
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
token_endpoint_auth_methods_supported: ["none"],
|
||||
};
|
||||
app.get("/.well-known/oauth-authorization-server", (_request, response) => response.json(authorizationMetadata));
|
||||
app.get("/.well-known/openid-configuration", (_request, response) => response.json({
|
||||
...authorizationMetadata,
|
||||
subject_types_supported: ["public"],
|
||||
id_token_signing_alg_values_supported: ["RS256"],
|
||||
}));
|
||||
app.get("/authorize", (request, response) => {
|
||||
authorizationRequest = new URL(request.originalUrl, origin);
|
||||
const redirectUri = String(request.query.redirect_uri ?? "");
|
||||
const state = String(request.query.state ?? "");
|
||||
const challenge = String(request.query.code_challenge ?? "");
|
||||
const requestedResource = String(request.query.resource ?? "");
|
||||
const code = "authorization-code";
|
||||
authorizationCodes.set(code, { challenge, resource: requestedResource });
|
||||
const callback = new URL(redirectUri);
|
||||
callback.searchParams.set("code", code);
|
||||
callback.searchParams.set("state", state);
|
||||
response.redirect(callback.toString());
|
||||
});
|
||||
app.post("/token", (request, response) => {
|
||||
const grantType = String(request.body.grant_type ?? "");
|
||||
if (grantType === "authorization_code") {
|
||||
const code = String(request.body.code ?? "");
|
||||
const record = authorizationCodes.get(code);
|
||||
const verifier = String(request.body.code_verifier ?? "");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
if (!record || challenge !== record.challenge || record.resource !== resource || request.body.resource !== resource || request.body.client_id !== "test-client") {
|
||||
response.status(400).json({ error: "invalid_grant" });
|
||||
return;
|
||||
}
|
||||
response.json({ access_token: "access-one", refresh_token: "refresh-one", token_type: "Bearer", expires_in: 3600 });
|
||||
return;
|
||||
}
|
||||
if (grantType === "refresh_token" && request.body.refresh_token === "refresh-one" && request.body.resource === resource) {
|
||||
refreshCount += 1;
|
||||
response.json({ access_token: "access-two", refresh_token: "refresh-two", token_type: "Bearer", expires_in: 3600 });
|
||||
return;
|
||||
}
|
||||
response.status(400).json({ error: "invalid_grant" });
|
||||
});
|
||||
app.post("/mcp", async (request, response) => {
|
||||
if (request.headers.authorization !== "Bearer access-two") {
|
||||
response.setHeader("WWW-Authenticate", `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp"`);
|
||||
response.status(401).json({ error: "authentication required" });
|
||||
return;
|
||||
}
|
||||
if (!transport && isInitializeRequest(request.body)) {
|
||||
mcpServer = new Server({ name: "mock-gamecraft", version: "1.0.0" }, { capabilities: { tools: {} } });
|
||||
mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [{ name: "list_spaces", description: "List spaces", inputSchema: { type: "object" } }],
|
||||
}));
|
||||
transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), enableJsonResponse: true });
|
||||
await mcpServer.connect(transport);
|
||||
}
|
||||
if (!transport) {
|
||||
response.status(400).json({ error: "MCP session is missing" });
|
||||
return;
|
||||
}
|
||||
await transport.handleRequest(request, response, request.body);
|
||||
});
|
||||
|
||||
const options = resolveClientOptions({ url: resource, clientId: "test-client", callbackPort, env: {} });
|
||||
const tokens = new MemoryTokens();
|
||||
const discovery = new MemoryDiscovery();
|
||||
const output: string[] = [];
|
||||
let browserFailure: unknown;
|
||||
try {
|
||||
const result = await loginRemote(options, tokens, discovery as never, {
|
||||
browser: false,
|
||||
timeoutMs: 3000,
|
||||
writeLine: (line) => {
|
||||
output.push(line);
|
||||
const match = line.match(/https?:\/\/[^\s]+/);
|
||||
if (match) void fetch(match[0], { redirect: "follow" }).catch((error) => { browserFailure = error; });
|
||||
},
|
||||
});
|
||||
assert.equal(browserFailure, undefined);
|
||||
assert.deepEqual(result, { alreadyAuthenticated: false, toolCount: 1 });
|
||||
assert.equal(authorizationRequest?.searchParams.get("code_challenge_method"), "S256");
|
||||
assert.equal(authorizationRequest?.searchParams.get("resource"), resource);
|
||||
assert.match(authorizationRequest?.searchParams.get("scope") ?? "", /offline_access/);
|
||||
assert.equal(refreshCount, 1);
|
||||
assert.equal(tokens.values.get(credentialAccount(options))?.refresh_token, "refresh-two");
|
||||
const renderedOutput = output.join("\n");
|
||||
assert.doesNotMatch(renderedOutput, /authorization-code|access-one|access-two|refresh-one|refresh-two|Authorization:/);
|
||||
} finally {
|
||||
await mcpServer?.close();
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
async function availablePort(): Promise<number> {
|
||||
const app = express();
|
||||
const server = app.listen(0, "127.0.0.1");
|
||||
await new Promise<void>((resolve) => server.once("listening", resolve));
|
||||
const address = server.address() as AddressInfo;
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
return address.port;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { createProxyServer } from "../src/bridge.js";
|
||||
|
||||
const server = createProxyServer({
|
||||
listTools: async (params) => ({ tools: [{ name: "fixture_tool", inputSchema: { type: "object" } }], nextCursor: params?.cursor }),
|
||||
callTool: async () => ({ content: [{ type: "text", text: "ok" }] }),
|
||||
listResources: async () => ({ resources: [] }),
|
||||
listResourceTemplates: async () => ({ resourceTemplates: [] }),
|
||||
readResource: async ({ uri }) => ({ contents: [{ uri, text: "ok" }] }),
|
||||
});
|
||||
|
||||
await server.connect(new StdioServerTransport());
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
test("stdio protocol mode writes only JSON-RPC messages to stdout", async () => {
|
||||
const packageRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const child = spawn(process.execPath, ["--import", "tsx", "test/stdioFixture.ts"], {
|
||||
cwd: packageRoot,
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += String(chunk); });
|
||||
child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += String(chunk); });
|
||||
const messages = [
|
||||
{ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "stdio-test", version: "1.0.0" } } },
|
||||
{ jsonrpc: "2.0", method: "notifications/initialized" },
|
||||
{ jsonrpc: "2.0", id: 2, method: "tools/list", params: { cursor: "next" } },
|
||||
];
|
||||
child.stdin.write(messages.map((message) => JSON.stringify(message)).join("\n") + "\n");
|
||||
await waitFor(() => stdout.split(/\r?\n/).filter(Boolean).some((line) => {
|
||||
try { return (JSON.parse(line) as { id?: number }).id === 2; } catch { return false; }
|
||||
}), 3000);
|
||||
child.stdin.end();
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => { child.kill(); resolve(); }, 1000);
|
||||
child.once("close", () => { clearTimeout(timeout); resolve(); });
|
||||
});
|
||||
const lines = stdout.split(/\r?\n/).filter(Boolean);
|
||||
assert.ok(lines.length >= 2);
|
||||
const parsed = lines.map((line) => JSON.parse(line) as { jsonrpc: string; id?: number; result?: unknown });
|
||||
assert.ok(parsed.every((message) => message.jsonrpc === "2.0"));
|
||||
assert.ok(parsed.some((message) => message.id === 1));
|
||||
assert.ok(parsed.some((message) => message.id === 2));
|
||||
assert.equal(stderr, "");
|
||||
});
|
||||
|
||||
async function waitFor(predicate: () => boolean, timeoutMs: number): Promise<void> {
|
||||
const started = Date.now();
|
||||
while (!predicate()) {
|
||||
if (Date.now() - started > timeoutMs) throw new Error("timed out waiting for stdio response");
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user