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 11 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(11); 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(11); 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) => Promise; } ).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) => Promise; } ).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(); }); });