Files
openproject-mcp/tests/read-only.test.ts
T

267 lines
8.0 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { createServer } from "../src/server";
import { parseConfig } from "../src/config";
import {
registerTool,
type ToolDefinition,
} from "../src/tools/common";
import { allTools, registerAllTools, type AnyToolDefinition } from "../src/tools";
import { OpenProjectError } from "../src/client/errors";
import { formatToolError } from "../src/tools/common";
import { getRequestContext } from "../src/context";
describe("Read-Only Mode Enforcement", () => {
test("parseConfig parses read-only flags from env and argv", () => {
const configEnv = parseConfig(
{
OPENPROJECT_BASE_URL: "http://localhost:8080",
OPENPROJECT_API_KEY: "key",
OPENPROJECT_READ_ONLY: "true",
},
[]
);
expect(configEnv.readOnly).toBe(true);
const configArg = parseConfig(
{
OPENPROJECT_BASE_URL: "http://localhost:8080",
OPENPROJECT_API_KEY: "key",
},
["--read-only"]
);
expect(configArg.readOnly).toBe(true);
const configDefault = parseConfig(
{
OPENPROJECT_BASE_URL: "http://localhost:8080",
OPENPROJECT_API_KEY: "key",
},
[]
);
expect(configDefault.readOnly).toBe(false);
const configOne = parseConfig(
{
OPENPROJECT_BASE_URL: "http://localhost:8080",
OPENPROJECT_API_KEY: "key",
OPENPROJECT_READ_ONLY: "1",
},
[]
);
expect(configOne.readOnly).toBe(true);
const configYes = parseConfig(
{
OPENPROJECT_BASE_URL: "http://localhost:8080",
OPENPROJECT_API_KEY: "key",
OPENPROJECT_READ_ONLY: "yes",
},
[]
);
expect(configYes.readOnly).toBe(true);
const configFalse = parseConfig(
{
OPENPROJECT_BASE_URL: "http://localhost:8080",
OPENPROJECT_API_KEY: "key",
OPENPROJECT_READ_ONLY: "false",
},
[]
);
expect(configFalse.readOnly).toBe(false);
});
test("read-only server lists all 18 tools (all are readOnly: true)", async () => {
const mcpServer = createServer({
baseUrl: "http://localhost:8080",
apiKey: "test-key",
readOnly: true,
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mcpServer.start(serverTransport);
const client = new Client({ name: "client", version: "1" });
await client.connect(clientTransport);
const toolsResult = await client.listTools();
expect(toolsResult.tools).toHaveLength(18);
await client.close();
await mcpServer.stop();
});
test("read-only mode filters out mutating tools from tools/list", async () => {
const syntheticMutatingTool: ToolDefinition = {
name: "openproject_create_work_package_synthetic",
description: "Synthetic mutating tool",
readOnly: false,
execute: async () => ({ content: [{ type: "text", text: "created" }] }),
};
// Also verify registerAllTools explicitly filters out mutating tools when readOnly is true
const standaloneServer = new McpServer({ name: "test-server", version: "1.0.0" });
registerAllTools(standaloneServer, {
readOnly: true,
tools: [...allTools, syntheticMutatingTool as unknown as AnyToolDefinition],
});
const [cTransport, sTransport] = InMemoryTransport.createLinkedPair();
await standaloneServer.connect(sTransport);
const client2 = new Client({ name: "client-filter-test", version: "1.0.0" });
await client2.connect(cTransport);
const standaloneTools = await client2.listTools();
const standaloneNames = standaloneTools.tools.map((t) => t.name);
expect(standaloneNames).not.toContain("openproject_create_work_package_synthetic");
expect(standaloneTools.tools).toHaveLength(18);
await client2.close();
await standaloneServer.close();
});
test("execution guard returns SERVER_READ_ONLY if mutating tool is called in read-only mode", async () => {
let executionAttempted = false;
const syntheticMutatingTool: ToolDefinition = {
name: "openproject_synthetic_write",
description: "Write tool",
readOnly: false,
execute: async () => {
executionAttempted = true;
return { content: [{ type: "text", text: "written" }] };
},
};
const mcpServer = createServer({
baseUrl: "http://localhost:8080",
apiKey: "test-key",
readOnly: true,
});
// Register with mutating tool included
registerTool(mcpServer.server, syntheticMutatingTool, {
wrapExecute: async (fn, tool) => {
if (true && !tool.readOnly) {
return formatToolError(
new OpenProjectError(
"Operation rejected. OpenProject MCP server is running in read-only mode.",
{ code: "SERVER_READ_ONLY" }
)
);
}
return fn();
},
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mcpServer.start(serverTransport);
const client = new Client({ name: "client", version: "1" });
await client.connect(clientTransport);
const callRes = (await client.callTool({
name: "openproject_synthetic_write",
arguments: {},
})) as {
content: Array<{ type: string; text: string }>;
isError?: boolean;
};
expect(callRes.isError).toBe(true);
expect(callRes.content[0]!.text).toContain("SERVER_READ_ONLY");
expect(callRes.content[0]!.text).toContain(
"Operation rejected. OpenProject MCP server is running in read-only mode."
);
expect(executionAttempted).toBe(false);
await client.close();
await mcpServer.stop();
});
test("propagates isReadOnly flag in RequestContext during tool execution", async () => {
let capturedReadOnly: boolean | undefined;
const readOnlyServer = createServer({
baseUrl: "http://localhost:8080",
apiKey: "test-key",
readOnly: true,
});
(
readOnlyServer.client as unknown as {
get: (path: string, query?: Record<string, unknown>) => Promise<unknown>;
}
).get = async () => {
const ctx = getRequestContext();
capturedReadOnly = ctx.isReadOnly;
return {
_embedded: { elements: [] },
total: 0,
count: 0,
pageSize: 20,
offset: 1,
};
};
const [clientTransport1, serverTransport1] = InMemoryTransport.createLinkedPair();
await readOnlyServer.start(serverTransport1);
const client1 = new Client({ name: "ro-client", version: "1.0.0" });
await client1.connect(clientTransport1);
await client1.callTool({
name: "openproject_list_projects",
arguments: {},
});
expect(capturedReadOnly).toBe(true);
await client1.close();
await readOnlyServer.stop();
// Verify readOnly: false sets isReadOnly to false
let capturedReadWrite: boolean | undefined;
const readWriteServer = createServer({
baseUrl: "http://localhost:8080",
apiKey: "test-key",
readOnly: false,
});
(
readWriteServer.client as unknown as {
get: (path: string, query?: Record<string, unknown>) => Promise<unknown>;
}
).get = async () => {
const ctx = getRequestContext();
capturedReadWrite = ctx.isReadOnly;
return {
_embedded: { elements: [] },
total: 0,
count: 0,
pageSize: 20,
offset: 1,
};
};
const [clientTransport2, serverTransport2] = InMemoryTransport.createLinkedPair();
await readWriteServer.start(serverTransport2);
const client2 = new Client({ name: "rw-client", version: "1.0.0" });
await client2.connect(clientTransport2);
await client2.callTool({
name: "openproject_list_projects",
arguments: {},
});
expect(capturedReadWrite).toBe(false);
await client2.close();
await readWriteServer.stop();
});
});