diff --git a/docs/reference/mcp-server.md b/docs/reference/mcp-server.md index e4024fa90..9d27229f7 100644 --- a/docs/reference/mcp-server.md +++ b/docs/reference/mcp-server.md @@ -5,6 +5,17 @@ Rulesync provides an MCP (Model Context Protocol) server that enables AI agents > [!NOTE] > The MCP server exposes the only one tool to minimize your agent's token usage. Approximately less than 1k tokens for the tool definition. +## Supported Features and Operations + +The single `rulesyncTool` multiplexes by `feature` and `operation`: + +- `rule`, `command`, `subagent`, `skill`: `list`, `get`, `put`, `delete` +- `ignore`, `mcp`, `permissions`, `hooks`: `get`, `put`, `delete` +- `generate`: `run` +- `import`: `run` + +The `permissions` feature operates on `.rulesync/permissions.json` and the `hooks` feature operates on `.rulesync/hooks.json`. Both accept a `content` string (valid JSON) on `put`. + ## Usage ### Starting the MCP Server diff --git a/skills/rulesync/mcp-server.md b/skills/rulesync/mcp-server.md index e4024fa90..9d27229f7 100644 --- a/skills/rulesync/mcp-server.md +++ b/skills/rulesync/mcp-server.md @@ -5,6 +5,17 @@ Rulesync provides an MCP (Model Context Protocol) server that enables AI agents > [!NOTE] > The MCP server exposes the only one tool to minimize your agent's token usage. Approximately less than 1k tokens for the tool definition. +## Supported Features and Operations + +The single `rulesyncTool` multiplexes by `feature` and `operation`: + +- `rule`, `command`, `subagent`, `skill`: `list`, `get`, `put`, `delete` +- `ignore`, `mcp`, `permissions`, `hooks`: `get`, `put`, `delete` +- `generate`: `run` +- `import`: `run` + +The `permissions` feature operates on `.rulesync/permissions.json` and the `hooks` feature operates on `.rulesync/hooks.json`. Both accept a `content` string (valid JSON) on `put`. + ## Usage ### Starting the MCP Server diff --git a/src/e2e/e2e-mcp-server.spec.ts b/src/e2e/e2e-mcp-server.spec.ts new file mode 100644 index 000000000..2c9e5a40a --- /dev/null +++ b/src/e2e/e2e-mcp-server.spec.ts @@ -0,0 +1,236 @@ +import { join } from "node:path"; + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { afterEach, describe, expect, it } from "vitest"; +import { z } from "zod/mini"; + +import { + RULESYNC_HOOKS_RELATIVE_FILE_PATH, + RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH, +} from "../constants/rulesync-paths.js"; +import { fileExists, readFileContent } from "../utils/file.js"; +import { rulesyncArgs, rulesyncCmd, useTestDirectory } from "./e2e-helper.js"; + +/** + * Spawn the `rulesync mcp` daemon and return a connected MCP SDK client. + * The caller MUST invoke the returned `close` callback to kill the child + * process and release the stdio transport, even if the test throws. + */ +async function connectRulesyncMcpServer( + cwd: string, +): Promise<{ client: Client; close: () => Promise }> { + // Build a clean env map for the child. StdioClientTransport requires + // Record, whereas process.env has `string | undefined` + // values — filter out `undefined` entries before passing along. + const childEnv: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (typeof value === "string") { + childEnv[key] = value; + } + } + + const transport = new StdioClientTransport({ + command: rulesyncCmd, + args: [...rulesyncArgs, "mcp"], + cwd, + env: childEnv, + stderr: "pipe", + }); + + const client = new Client( + { name: "rulesync-e2e-client", version: "0.0.0" }, + { capabilities: {} }, + ); + + await client.connect(transport); + + const close = async (): Promise => { + try { + await client.close(); + } catch { + // Ignore close errors — we're tearing down anyway. + } + }; + + return { client, close }; +} + +/** + * Shape of a `tools/call` response. Validated with zod so no type + * assertions are needed at the call sites. + */ +const toolCallResponseSchema = z.object({ + isError: z.optional(z.boolean()), + content: z.array(z.looseObject({ type: z.string(), text: z.optional(z.string()) })), +}); + +/** + * Extract the concatenated text content from a `tools/call` result. + */ +function resultText(result: unknown): string { + const parsed = toolCallResponseSchema.parse(result); + return parsed.content + .filter((part) => part.type === "text" && typeof part.text === "string") + .map((part) => part.text ?? "") + .join(""); +} + +describe("E2E: mcp server (daemon over stdio JSON-RPC)", () => { + const { getTestDir } = useTestDirectory(); + + let close: (() => Promise) | undefined; + + afterEach(async () => { + if (close) { + await close(); + close = undefined; + } + }); + + it("should round-trip put/get/delete for permissions feature", { timeout: 30_000 }, async () => { + const testDir = getTestDir(); + + const connection = await connectRulesyncMcpServer(testDir); + close = connection.close; + const { client } = connection; + + const permissionsPayload = { + permission: { + bash: { + "*": "ask", + "git *": "allow", + "rm -rf *": "deny", + }, + }, + }; + const permissionsContent = JSON.stringify(permissionsPayload, null, 2); + + // put + const putResult = await client.callTool({ + name: "rulesyncTool", + arguments: { + feature: "permissions", + operation: "put", + content: permissionsContent, + }, + }); + expect(putResult.isError).not.toBe(true); + + // Verify the file exists on disk with the expected content. + const writtenContent = await readFileContent( + join(testDir, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH), + ); + expect(JSON.parse(writtenContent)).toEqual(permissionsPayload); + + // get + const getResult = await client.callTool({ + name: "rulesyncTool", + arguments: { + feature: "permissions", + operation: "get", + }, + }); + expect(getResult.isError).not.toBe(true); + const getText = resultText(getResult); + expect(getText).toContain("git *"); + expect(getText).toContain("allow"); + + // delete + const deleteResult = await client.callTool({ + name: "rulesyncTool", + arguments: { + feature: "permissions", + operation: "delete", + }, + }); + expect(deleteResult.isError).not.toBe(true); + + // Verify the file no longer exists. + expect(await fileExists(join(testDir, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH))).toBe(false); + }); + + it("should round-trip put/get/delete for hooks feature", { timeout: 30_000 }, async () => { + const testDir = getTestDir(); + + const connection = await connectRulesyncMcpServer(testDir); + close = connection.close; + const { client } = connection; + + const hooksPayload = { + hooks: { + preToolUse: [ + { + matcher: "Bash", + type: "command", + command: "echo hi", + }, + ], + }, + }; + const hooksContent = JSON.stringify(hooksPayload, null, 2); + + // put + const putResult = await client.callTool({ + name: "rulesyncTool", + arguments: { + feature: "hooks", + operation: "put", + content: hooksContent, + }, + }); + expect(putResult.isError).not.toBe(true); + + const writtenContent = await readFileContent(join(testDir, RULESYNC_HOOKS_RELATIVE_FILE_PATH)); + expect(JSON.parse(writtenContent)).toEqual(hooksPayload); + + // get + const getResult = await client.callTool({ + name: "rulesyncTool", + arguments: { + feature: "hooks", + operation: "get", + }, + }); + expect(getResult.isError).not.toBe(true); + const getText = resultText(getResult); + expect(getText).toContain("preToolUse"); + expect(getText).toContain("echo hi"); + + // delete + const deleteResult = await client.callTool({ + name: "rulesyncTool", + arguments: { + feature: "hooks", + operation: "delete", + }, + }); + expect(deleteResult.isError).not.toBe(true); + + expect(await fileExists(join(testDir, RULESYNC_HOOKS_RELATIVE_FILE_PATH))).toBe(false); + }); + + it( + "should return an error when permissions put is called without content", + { timeout: 30_000 }, + async () => { + const testDir = getTestDir(); + + const connection = await connectRulesyncMcpServer(testDir); + close = connection.close; + const { client } = connection; + + const putResult = await client.callTool({ + name: "rulesyncTool", + arguments: { + feature: "permissions", + operation: "put", + }, + }); + + expect(putResult.isError).toBe(true); + const errorText = resultText(putResult); + expect(errorText).toContain("content is required"); + }, + ); +}); diff --git a/src/mcp/hooks.test.ts b/src/mcp/hooks.test.ts new file mode 100644 index 000000000..4045f3f37 --- /dev/null +++ b/src/mcp/hooks.test.ts @@ -0,0 +1,169 @@ +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + RULESYNC_HOOKS_RELATIVE_FILE_PATH, + RULESYNC_RELATIVE_DIR_PATH, +} from "../constants/rulesync-paths.js"; +import { setupTestDirectory } from "../test-utils/test-directories.js"; +import { ensureDir, writeFileContent } from "../utils/file.js"; +import { hooksTools } from "./hooks.js"; + +describe("Hooks Tools", () => { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + describe("getHooksFile", () => { + it("should get the hooks configuration file", async () => { + const rulesyncDir = join(testDir, RULESYNC_RELATIVE_DIR_PATH); + await ensureDir(rulesyncDir); + + const hooksConfig = { + hooks: { + preToolUse: [ + { + command: "echo pre", + type: "command", + }, + ], + }, + }; + + await writeFileContent(join(rulesyncDir, "hooks.json"), JSON.stringify(hooksConfig, null, 2)); + + const result = await hooksTools.getHooksFile.execute(); + const parsed = JSON.parse(result); + + expect(parsed.relativePathFromCwd).toBe(RULESYNC_HOOKS_RELATIVE_FILE_PATH); + const contentParsed = JSON.parse(parsed.content); + expect(contentParsed.hooks.preToolUse[0].command).toBe("echo pre"); + }); + + it("should throw error for non-existent hooks file", async () => { + const rulesyncDir = join(testDir, RULESYNC_RELATIVE_DIR_PATH); + await ensureDir(rulesyncDir); + + await expect(hooksTools.getHooksFile.execute()).rejects.toThrow(); + }); + }); + + describe("putHooksFile", () => { + it("should create a new hooks file", async () => { + const rulesyncDir = join(testDir, RULESYNC_RELATIVE_DIR_PATH); + await ensureDir(rulesyncDir); + + const hooksConfig = { + hooks: { + sessionStart: [ + { + command: "echo start", + type: "command", + }, + ], + }, + }; + + const result = await hooksTools.putHooksFile.execute({ + content: JSON.stringify(hooksConfig, null, 2), + }); + const parsed = JSON.parse(result); + + expect(parsed.relativePathFromCwd).toBe(RULESYNC_HOOKS_RELATIVE_FILE_PATH); + const contentParsed = JSON.parse(parsed.content); + expect(contentParsed.hooks.sessionStart[0].command).toBe("echo start"); + + // Verify round-trip via get + const getResult = await hooksTools.getHooksFile.execute(); + const getParsed = JSON.parse(getResult); + const getContentParsed = JSON.parse(getParsed.content); + expect(getContentParsed.hooks.sessionStart[0].command).toBe("echo start"); + }); + + it("should reject invalid JSON content", async () => { + const rulesyncDir = join(testDir, RULESYNC_RELATIVE_DIR_PATH); + await ensureDir(rulesyncDir); + + await expect( + hooksTools.putHooksFile.execute({ + content: "not valid json {{{", + }), + ).rejects.toThrow(/Invalid JSON format/i); + }); + + it("should reject oversized hooks files", async () => { + const rulesyncDir = join(testDir, RULESYNC_RELATIVE_DIR_PATH); + await ensureDir(rulesyncDir); + + const largeValue = "a".repeat(1024 * 1024 + 1); + const largeContent = JSON.stringify({ + hooks: { + sessionStart: [ + { + command: largeValue, + type: "command", + }, + ], + }, + }); + + await expect( + hooksTools.putHooksFile.execute({ + content: largeContent, + }), + ).rejects.toThrow(/exceeds maximum/i); + }); + }); + + describe("deleteHooksFile", () => { + it("should delete an existing hooks file", async () => { + const rulesyncDir = join(testDir, RULESYNC_RELATIVE_DIR_PATH); + await ensureDir(rulesyncDir); + + const hooksConfig = { + hooks: { + sessionStart: [ + { + command: "echo start", + type: "command", + }, + ], + }, + }; + + await writeFileContent(join(rulesyncDir, "hooks.json"), JSON.stringify(hooksConfig, null, 2)); + + // Verify it exists + await expect(hooksTools.getHooksFile.execute()).resolves.toBeDefined(); + + // Delete it + const result = await hooksTools.deleteHooksFile.execute(); + const parsed = JSON.parse(result); + + expect(parsed.relativePathFromCwd).toBe(RULESYNC_HOOKS_RELATIVE_FILE_PATH); + + // Verify it's deleted + await expect(hooksTools.getHooksFile.execute()).rejects.toThrow(); + }); + + it("should succeed when deleting non-existent hooks file (idempotent)", async () => { + const rulesyncDir = join(testDir, RULESYNC_RELATIVE_DIR_PATH); + await ensureDir(rulesyncDir); + + const result = await hooksTools.deleteHooksFile.execute(); + const parsed = JSON.parse(result); + + expect(parsed.relativePathFromCwd).toBe(RULESYNC_HOOKS_RELATIVE_FILE_PATH); + }); + }); +}); diff --git a/src/mcp/hooks.ts b/src/mcp/hooks.ts new file mode 100644 index 000000000..83f73a6f9 --- /dev/null +++ b/src/mcp/hooks.ts @@ -0,0 +1,180 @@ +import { join } from "node:path"; + +import { z } from "zod/mini"; + +import { RULESYNC_HOOKS_RELATIVE_FILE_PATH } from "../constants/rulesync-paths.js"; +import { RulesyncHooks } from "../features/hooks/rulesync-hooks.js"; +import { formatError } from "../utils/error.js"; +import { ensureDir, removeFile, writeFileContent } from "../utils/file.js"; + +const maxHooksSizeBytes = 1024 * 1024; // 1MB + +/** + * Tool to get the hooks configuration file + */ +async function getHooksFile(): Promise<{ + relativePathFromCwd: string; + content: string; +}> { + try { + const rulesyncHooks = await RulesyncHooks.fromFile({ + validate: true, + }); + + const relativePathFromCwd = join( + rulesyncHooks.getRelativeDirPath(), + rulesyncHooks.getRelativeFilePath(), + ); + + return { + relativePathFromCwd, + content: rulesyncHooks.getFileContent(), + }; + } catch (error) { + throw new Error( + `Failed to read hooks file (${RULESYNC_HOOKS_RELATIVE_FILE_PATH}): ${formatError(error)}`, + { + cause: error, + }, + ); + } +} + +/** + * Tool to create or update the hooks configuration file (upsert operation) + */ +async function putHooksFile({ content }: { content: string }): Promise<{ + relativePathFromCwd: string; + content: string; +}> { + // Check file size constraint + if (content.length > maxHooksSizeBytes) { + throw new Error( + `Hooks file size ${content.length} bytes exceeds maximum ${maxHooksSizeBytes} bytes (1MB) for ${RULESYNC_HOOKS_RELATIVE_FILE_PATH}`, + ); + } + + // Validate JSON format + try { + JSON.parse(content); + } catch (error) { + throw new Error( + `Invalid JSON format in hooks file (${RULESYNC_HOOKS_RELATIVE_FILE_PATH}): ${formatError(error)}`, + { + cause: error, + }, + ); + } + + try { + const baseDir = process.cwd(); + const paths = RulesyncHooks.getSettablePaths(); + + const relativeDirPath = paths.relativeDirPath; + const relativeFilePath = paths.relativeFilePath; + const fullPath = join(baseDir, relativeDirPath, relativeFilePath); + + // Create a RulesyncHooks instance to validate the content + const rulesyncHooks = new RulesyncHooks({ + baseDir, + relativeDirPath, + relativeFilePath, + fileContent: content, + validate: true, + }); + + // Ensure directory exists + await ensureDir(join(baseDir, relativeDirPath)); + + // Write the file + await writeFileContent(fullPath, content); + + const relativePathFromCwd = join(relativeDirPath, relativeFilePath); + + return { + relativePathFromCwd, + content: rulesyncHooks.getFileContent(), + }; + } catch (error) { + throw new Error( + `Failed to write hooks file (${RULESYNC_HOOKS_RELATIVE_FILE_PATH}): ${formatError(error)}`, + { + cause: error, + }, + ); + } +} + +/** + * Tool to delete the hooks configuration file + */ +async function deleteHooksFile(): Promise<{ + relativePathFromCwd: string; +}> { + try { + const baseDir = process.cwd(); + const paths = RulesyncHooks.getSettablePaths(); + + const filePath = join(baseDir, paths.relativeDirPath, paths.relativeFilePath); + + await removeFile(filePath); + + const relativePathFromCwd = join(paths.relativeDirPath, paths.relativeFilePath); + + return { + relativePathFromCwd, + }; + } catch (error) { + throw new Error( + `Failed to delete hooks file (${RULESYNC_HOOKS_RELATIVE_FILE_PATH}): ${formatError(error)}`, + { + cause: error, + }, + ); + } +} + +/** + * Schema for hooks-related tool parameters + */ +export const hooksToolSchemas = { + getHooksFile: z.object({}), + putHooksFile: z.object({ + content: z.string(), + }), + deleteHooksFile: z.object({}), +} as const; + +/** + * Tool definitions for hooks-related operations + */ +export const hooksTools = { + getHooksFile: { + name: "getHooksFile", + description: `Get the hooks configuration file (${RULESYNC_HOOKS_RELATIVE_FILE_PATH}).`, + parameters: hooksToolSchemas.getHooksFile, + execute: async () => { + const result = await getHooksFile(); + return JSON.stringify(result, null, 2); + }, + }, + putHooksFile: { + name: "putHooksFile", + description: + "Create or update the hooks configuration file (upsert operation). content parameter is required and must be valid JSON.", + parameters: hooksToolSchemas.putHooksFile, + execute: async (args: { content: string }) => { + const result = await putHooksFile({ content: args.content }); + return JSON.stringify(result, null, 2); + }, + }, + deleteHooksFile: { + name: "deleteHooksFile", + description: "Delete the hooks configuration file.", + parameters: hooksToolSchemas.deleteHooksFile, + execute: async () => { + const result = await deleteHooksFile(); + return JSON.stringify(result, null, 2); + }, + }, +} as const; diff --git a/src/mcp/permissions.test.ts b/src/mcp/permissions.test.ts new file mode 100644 index 000000000..07fe2f2fe --- /dev/null +++ b/src/mcp/permissions.test.ts @@ -0,0 +1,165 @@ +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH, + RULESYNC_RELATIVE_DIR_PATH, +} from "../constants/rulesync-paths.js"; +import { setupTestDirectory } from "../test-utils/test-directories.js"; +import { ensureDir, writeFileContent } from "../utils/file.js"; +import { permissionsTools } from "./permissions.js"; + +describe("Permissions Tools", () => { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + describe("getPermissionsFile", () => { + it("should get the permissions configuration file", async () => { + const rulesyncDir = join(testDir, RULESYNC_RELATIVE_DIR_PATH); + await ensureDir(rulesyncDir); + + const permissionsConfig = { + permission: { + bash: { + "*": "ask", + "git *": "allow", + "rm *": "deny", + }, + }, + }; + + await writeFileContent( + join(rulesyncDir, "permissions.json"), + JSON.stringify(permissionsConfig, null, 2), + ); + + const result = await permissionsTools.getPermissionsFile.execute(); + const parsed = JSON.parse(result); + + expect(parsed.relativePathFromCwd).toBe(RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH); + const contentParsed = JSON.parse(parsed.content); + expect(contentParsed.permission.bash["git *"]).toBe("allow"); + }); + + it("should throw error for non-existent permissions file", async () => { + const rulesyncDir = join(testDir, RULESYNC_RELATIVE_DIR_PATH); + await ensureDir(rulesyncDir); + + await expect(permissionsTools.getPermissionsFile.execute()).rejects.toThrow(); + }); + }); + + describe("putPermissionsFile", () => { + it("should create a new permissions file", async () => { + const rulesyncDir = join(testDir, RULESYNC_RELATIVE_DIR_PATH); + await ensureDir(rulesyncDir); + + const permissionsConfig = { + permission: { + bash: { + "*": "ask", + }, + }, + }; + + const result = await permissionsTools.putPermissionsFile.execute({ + content: JSON.stringify(permissionsConfig, null, 2), + }); + const parsed = JSON.parse(result); + + expect(parsed.relativePathFromCwd).toBe(RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH); + const contentParsed = JSON.parse(parsed.content); + expect(contentParsed.permission.bash["*"]).toBe("ask"); + + // Verify round-trip via get + const getResult = await permissionsTools.getPermissionsFile.execute(); + const getParsed = JSON.parse(getResult); + const getContentParsed = JSON.parse(getParsed.content); + expect(getContentParsed.permission.bash["*"]).toBe("ask"); + }); + + it("should reject invalid JSON content", async () => { + const rulesyncDir = join(testDir, RULESYNC_RELATIVE_DIR_PATH); + await ensureDir(rulesyncDir); + + await expect( + permissionsTools.putPermissionsFile.execute({ + content: "not valid json {{{", + }), + ).rejects.toThrow(/Invalid JSON format/i); + }); + + it("should reject oversized permissions files", async () => { + const rulesyncDir = join(testDir, RULESYNC_RELATIVE_DIR_PATH); + await ensureDir(rulesyncDir); + + const largeValue = "a".repeat(1024 * 1024 + 1); + const largeContent = JSON.stringify({ + permission: { + bash: { + [largeValue]: "allow", + }, + }, + }); + + await expect( + permissionsTools.putPermissionsFile.execute({ + content: largeContent, + }), + ).rejects.toThrow(/exceeds maximum/i); + }); + }); + + describe("deletePermissionsFile", () => { + it("should delete an existing permissions file", async () => { + const rulesyncDir = join(testDir, RULESYNC_RELATIVE_DIR_PATH); + await ensureDir(rulesyncDir); + + const permissionsConfig = { + permission: { + bash: { + "*": "ask", + }, + }, + }; + + await writeFileContent( + join(rulesyncDir, "permissions.json"), + JSON.stringify(permissionsConfig, null, 2), + ); + + // Verify it exists + await expect(permissionsTools.getPermissionsFile.execute()).resolves.toBeDefined(); + + // Delete it + const result = await permissionsTools.deletePermissionsFile.execute(); + const parsed = JSON.parse(result); + + expect(parsed.relativePathFromCwd).toBe(RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH); + + // Verify it's deleted + await expect(permissionsTools.getPermissionsFile.execute()).rejects.toThrow(); + }); + + it("should succeed when deleting non-existent permissions file (idempotent)", async () => { + const rulesyncDir = join(testDir, RULESYNC_RELATIVE_DIR_PATH); + await ensureDir(rulesyncDir); + + const result = await permissionsTools.deletePermissionsFile.execute(); + const parsed = JSON.parse(result); + + expect(parsed.relativePathFromCwd).toBe(RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH); + }); + }); +}); diff --git a/src/mcp/permissions.ts b/src/mcp/permissions.ts new file mode 100644 index 000000000..ae62caba9 --- /dev/null +++ b/src/mcp/permissions.ts @@ -0,0 +1,180 @@ +import { join } from "node:path"; + +import { z } from "zod/mini"; + +import { RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH } from "../constants/rulesync-paths.js"; +import { RulesyncPermissions } from "../features/permissions/rulesync-permissions.js"; +import { formatError } from "../utils/error.js"; +import { ensureDir, removeFile, writeFileContent } from "../utils/file.js"; + +const maxPermissionsSizeBytes = 1024 * 1024; // 1MB + +/** + * Tool to get the permissions configuration file + */ +async function getPermissionsFile(): Promise<{ + relativePathFromCwd: string; + content: string; +}> { + try { + const rulesyncPermissions = await RulesyncPermissions.fromFile({ + validate: true, + }); + + const relativePathFromCwd = join( + rulesyncPermissions.getRelativeDirPath(), + rulesyncPermissions.getRelativeFilePath(), + ); + + return { + relativePathFromCwd, + content: rulesyncPermissions.getFileContent(), + }; + } catch (error) { + throw new Error( + `Failed to read permissions file (${RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH}): ${formatError(error)}`, + { + cause: error, + }, + ); + } +} + +/** + * Tool to create or update the permissions configuration file (upsert operation) + */ +async function putPermissionsFile({ content }: { content: string }): Promise<{ + relativePathFromCwd: string; + content: string; +}> { + // Check file size constraint + if (content.length > maxPermissionsSizeBytes) { + throw new Error( + `Permissions file size ${content.length} bytes exceeds maximum ${maxPermissionsSizeBytes} bytes (1MB) for ${RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH}`, + ); + } + + // Validate JSON format + try { + JSON.parse(content); + } catch (error) { + throw new Error( + `Invalid JSON format in permissions file (${RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH}): ${formatError(error)}`, + { + cause: error, + }, + ); + } + + try { + const baseDir = process.cwd(); + const paths = RulesyncPermissions.getSettablePaths(); + + const relativeDirPath = paths.relativeDirPath; + const relativeFilePath = paths.relativeFilePath; + const fullPath = join(baseDir, relativeDirPath, relativeFilePath); + + // Create a RulesyncPermissions instance to validate the content + const rulesyncPermissions = new RulesyncPermissions({ + baseDir, + relativeDirPath, + relativeFilePath, + fileContent: content, + validate: true, + }); + + // Ensure directory exists + await ensureDir(join(baseDir, relativeDirPath)); + + // Write the file + await writeFileContent(fullPath, content); + + const relativePathFromCwd = join(relativeDirPath, relativeFilePath); + + return { + relativePathFromCwd, + content: rulesyncPermissions.getFileContent(), + }; + } catch (error) { + throw new Error( + `Failed to write permissions file (${RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH}): ${formatError(error)}`, + { + cause: error, + }, + ); + } +} + +/** + * Tool to delete the permissions configuration file + */ +async function deletePermissionsFile(): Promise<{ + relativePathFromCwd: string; +}> { + try { + const baseDir = process.cwd(); + const paths = RulesyncPermissions.getSettablePaths(); + + const filePath = join(baseDir, paths.relativeDirPath, paths.relativeFilePath); + + await removeFile(filePath); + + const relativePathFromCwd = join(paths.relativeDirPath, paths.relativeFilePath); + + return { + relativePathFromCwd, + }; + } catch (error) { + throw new Error( + `Failed to delete permissions file (${RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH}): ${formatError(error)}`, + { + cause: error, + }, + ); + } +} + +/** + * Schema for permissions-related tool parameters + */ +export const permissionsToolSchemas = { + getPermissionsFile: z.object({}), + putPermissionsFile: z.object({ + content: z.string(), + }), + deletePermissionsFile: z.object({}), +} as const; + +/** + * Tool definitions for permissions-related operations + */ +export const permissionsTools = { + getPermissionsFile: { + name: "getPermissionsFile", + description: `Get the permissions configuration file (${RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH}).`, + parameters: permissionsToolSchemas.getPermissionsFile, + execute: async () => { + const result = await getPermissionsFile(); + return JSON.stringify(result, null, 2); + }, + }, + putPermissionsFile: { + name: "putPermissionsFile", + description: + "Create or update the permissions configuration file (upsert operation). content parameter is required and must be valid JSON.", + parameters: permissionsToolSchemas.putPermissionsFile, + execute: async (args: { content: string }) => { + const result = await putPermissionsFile({ content: args.content }); + return JSON.stringify(result, null, 2); + }, + }, + deletePermissionsFile: { + name: "deletePermissionsFile", + description: "Delete the permissions configuration file.", + parameters: permissionsToolSchemas.deletePermissionsFile, + execute: async () => { + const result = await deletePermissionsFile(); + return JSON.stringify(result, null, 2); + }, + }, +} as const; diff --git a/src/mcp/tools.test.ts b/src/mcp/tools.test.ts index a14b22428..57843f365 100644 --- a/src/mcp/tools.test.ts +++ b/src/mcp/tools.test.ts @@ -105,6 +105,118 @@ describe("rulesyncTool", () => { ).rejects.toThrow(); }); + it("supports permissions content operations", async () => { + const rulesyncDir = join(testDir, ".rulesync"); + await ensureDir(rulesyncDir); + + const content = JSON.stringify({ + permission: { + bash: { + "git *": "allow", + "rm *": "deny", + }, + }, + }); + + const putResult = await rulesyncTool.execute({ + feature: "permissions", + operation: "put", + content, + }); + + const putParsed = JSON.parse(putResult); + expect(putParsed.relativePathFromCwd).toBe(".rulesync/permissions.json"); + expect(putParsed.content).toContain("git *"); + + const getResult = await rulesyncTool.execute({ + feature: "permissions", + operation: "get", + }); + + const getParsed = JSON.parse(getResult); + expect(getParsed.content).toContain("git *"); + expect(getParsed.content).toContain("rm *"); + + const deleteResult = await rulesyncTool.execute({ + feature: "permissions", + operation: "delete", + }); + + const deleteParsed = JSON.parse(deleteResult); + expect(deleteParsed.relativePathFromCwd).toBe(".rulesync/permissions.json"); + + // Verify the file is deleted by checking get throws + await expect( + rulesyncTool.execute({ + feature: "permissions", + operation: "get", + }), + ).rejects.toThrow(); + + // put without content should throw the correct error + await expect( + rulesyncTool.execute({ + feature: "permissions", + operation: "put", + }), + ).rejects.toThrow("content is required for permissions put operation"); + }); + + it("supports hooks content operations", async () => { + const rulesyncDir = join(testDir, ".rulesync"); + await ensureDir(rulesyncDir); + + const content = JSON.stringify({ + hooks: { + preToolUse: [{ command: "echo pre" }], + sessionStart: [{ command: "echo start" }], + }, + }); + + const putResult = await rulesyncTool.execute({ + feature: "hooks", + operation: "put", + content, + }); + + const putParsed = JSON.parse(putResult); + expect(putParsed.relativePathFromCwd).toBe(".rulesync/hooks.json"); + expect(putParsed.content).toContain("echo pre"); + + const getResult = await rulesyncTool.execute({ + feature: "hooks", + operation: "get", + }); + + const getParsed = JSON.parse(getResult); + expect(getParsed.content).toContain("echo pre"); + expect(getParsed.content).toContain("echo start"); + + const deleteResult = await rulesyncTool.execute({ + feature: "hooks", + operation: "delete", + }); + + const deleteParsed = JSON.parse(deleteResult); + expect(deleteParsed.relativePathFromCwd).toBe(".rulesync/hooks.json"); + + // Verify the file is deleted by checking get throws + await expect( + rulesyncTool.execute({ + feature: "hooks", + operation: "get", + }), + ).rejects.toThrow(); + + // put without content should throw the correct error + await expect( + rulesyncTool.execute({ + feature: "hooks", + operation: "put", + }), + ).rejects.toThrow("content is required for hooks put operation"); + }); + it("handles ignore file lifecycle through a single tool", async () => { const rulesyncDir = join(testDir, ".rulesync"); await ensureDir(rulesyncDir); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 1a8e54087..8398ad011 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -18,9 +18,11 @@ import { } from "../features/subagents/rulesync-subagent.js"; import { commandTools } from "./commands.js"; import { generateOptionsSchema, generateTools } from "./generate.js"; +import { hooksTools } from "./hooks.js"; import { ignoreTools } from "./ignore.js"; import { importOptionsSchema, importTools } from "./import.js"; import { mcpTools } from "./mcp.js"; +import { permissionsTools } from "./permissions.js"; import { ruleTools } from "./rules.js"; import { skillTools } from "./skills.js"; import { subagentTools } from "./subagents.js"; @@ -32,6 +34,8 @@ const rulesyncFeatureSchema = z.enum([ "skill", "ignore", "mcp", + "permissions", + "hooks", "generate", "import", ]); @@ -60,7 +64,7 @@ type RulesyncOperation = z.infer; type RulesyncToolArgs = z.infer; type RulesyncFrontmatterFeature = Exclude< RulesyncFeature, - "ignore" | "mcp" | "generate" | "import" + "ignore" | "mcp" | "permissions" | "hooks" | "generate" | "import" >; type RulesyncFrontmatterByFeature = { rule: RulesyncRuleFrontmatter; @@ -76,6 +80,8 @@ const supportedOperationsByFeature: Record skill: ["list", "get", "put", "delete"], ignore: ["get", "put", "delete"], mcp: ["get", "put", "delete"], + permissions: ["get", "put", "delete"], + hooks: ["get", "put", "delete"], generate: ["run"], import: ["run"], }; @@ -175,7 +181,7 @@ function ensureBody({ body, feature, operation }: RulesyncToolArgs): string { export const rulesyncTool = { name: "rulesyncTool", description: - "Manage Rulesync files through a single MCP tool. Features: rule/command/subagent/skill support list/get/put/delete; ignore/mcp support get/put/delete only; generate supports run only; import supports run only. Parameters: list requires no targetPathFromCwd (lists all items); get/delete require targetPathFromCwd; put requires targetPathFromCwd, frontmatter, and body (or content for ignore/mcp); generate/run uses generateOptions to configure generation; import/run uses importOptions to configure import.", + "Manage Rulesync files through a single MCP tool. Features: rule/command/subagent/skill support list/get/put/delete; ignore/mcp/permissions/hooks support get/put/delete only; generate supports run only; import supports run only. Parameters: list requires no targetPathFromCwd (lists all items); get/delete require targetPathFromCwd; put requires targetPathFromCwd, frontmatter, and body (or content for ignore/mcp/permissions/hooks); generate/run uses generateOptions to configure generation; import/run uses importOptions to configure import.", parameters: rulesyncToolSchema, execute: async (args: RulesyncToolArgs) => { const parsed = rulesyncToolSchema.parse(args); @@ -312,6 +318,36 @@ export const rulesyncTool = { return mcpTools.deleteMcpFile.execute(); } + case "permissions": { + if (parsed.operation === "get") { + return permissionsTools.getPermissionsFile.execute(); + } + + if (parsed.operation === "put") { + if (!parsed.content) { + throw new Error("content is required for permissions put operation"); + } + + return permissionsTools.putPermissionsFile.execute({ content: parsed.content }); + } + + return permissionsTools.deletePermissionsFile.execute(); + } + case "hooks": { + if (parsed.operation === "get") { + return hooksTools.getHooksFile.execute(); + } + + if (parsed.operation === "put") { + if (!parsed.content) { + throw new Error("content is required for hooks put operation"); + } + + return hooksTools.putHooksFile.execute({ content: parsed.content }); + } + + return hooksTools.deleteHooksFile.execute(); + } case "generate": { // Only "run" operation is supported for generate feature return generateTools.executeGenerate.execute(parsed.generateOptions ?? {});