From af2a8f3af80ffe0a494884ffd4957e5d683efae6 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sun, 6 Sep 2026 19:14:25 +0200 Subject: [PATCH] feat(server): add read-only mode enforcement and execution guard tests --- src/server.ts | 4 +- tests/read-only.test.ts | 285 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 tests/read-only.test.ts diff --git a/src/server.ts b/src/server.ts index a4e1d65..65c9912 100644 --- a/src/server.ts +++ b/src/server.ts @@ -42,8 +42,8 @@ export function createServer(config: AppConfig): OpenProjectMcpServer { if (config.readOnly && !tool.readOnly) { return formatToolError( new OpenProjectError( - "SERVER_READ_ONLY", - "Operation rejected. OpenProject MCP server is running in read-only mode." + "Operation rejected. OpenProject MCP server is running in read-only mode.", + { code: "SERVER_READ_ONLY" } ) ); } diff --git a/tests/read-only.test.ts b/tests/read-only.test.ts new file mode 100644 index 0000000..d7d7674 --- /dev/null +++ b/tests/read-only.test.ts @@ -0,0 +1,285 @@ +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 10 Phase 1 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(10); + + 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" }] }), + }; + + const mcpServer = createServer({ + baseUrl: "http://localhost:8080", + apiKey: "test-key", + readOnly: true, + }); + + // Manually register a mutating tool through the server to test filtering + 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(); + const names = toolsResult.tools.map((t) => t.name); + expect(names).not.toContain("openproject_create_work_package_synthetic"); + + await client.close(); + await mcpServer.stop(); + + // 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(10); + + 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(); + }); +});