From 82230b19aacb41703d86d198c235d9e59855aae3 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 21 Feb 2026 16:36:01 -0300 Subject: [PATCH 1/7] feat(mcp): superdoc mcp server --- apps/docs/docs.json | 3 +- apps/docs/document-engine/mcp.mdx | 179 +++++++ apps/docs/document-engine/overview.mdx | 13 +- apps/mcp/README.md | 123 +++++ apps/mcp/package.json | 33 ++ apps/mcp/src/__tests__/protocol.test.ts | 158 ++++++ .../mcp/src/__tests__/session-manager.test.ts | 77 +++ apps/mcp/src/__tests__/tools.test.ts | 81 +++ apps/mcp/src/index.ts | 35 ++ apps/mcp/src/session-manager.ts | 109 ++++ apps/mcp/src/tools/create.ts | 49 ++ apps/mcp/src/tools/format.ts | 43 ++ apps/mcp/src/tools/index.ts | 15 + apps/mcp/src/tools/lifecycle.ts | 80 +++ apps/mcp/src/tools/mutation.ts | 108 ++++ apps/mcp/src/tools/query.ts | 130 +++++ apps/mcp/tsconfig.json | 14 + pnpm-lock.yaml | 489 +++++++++++++++--- 18 files changed, 1655 insertions(+), 84 deletions(-) create mode 100644 apps/docs/document-engine/mcp.mdx create mode 100644 apps/mcp/README.md create mode 100644 apps/mcp/package.json create mode 100644 apps/mcp/src/__tests__/protocol.test.ts create mode 100644 apps/mcp/src/__tests__/session-manager.test.ts create mode 100644 apps/mcp/src/__tests__/tools.test.ts create mode 100644 apps/mcp/src/index.ts create mode 100644 apps/mcp/src/session-manager.ts create mode 100644 apps/mcp/src/tools/create.ts create mode 100644 apps/mcp/src/tools/format.ts create mode 100644 apps/mcp/src/tools/index.ts create mode 100644 apps/mcp/src/tools/lifecycle.ts create mode 100644 apps/mcp/src/tools/mutation.ts create mode 100644 apps/mcp/src/tools/query.ts create mode 100644 apps/mcp/tsconfig.json diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 80e400fc6..fe4867592 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -96,7 +96,8 @@ "document-engine/overview", "document-api/overview", "document-engine/sdks", - "document-engine/cli" + "document-engine/cli", + "document-engine/mcp" ] }, { diff --git a/apps/docs/document-engine/mcp.mdx b/apps/docs/document-engine/mcp.mdx new file mode 100644 index 000000000..7586eef39 --- /dev/null +++ b/apps/docs/document-engine/mcp.mdx @@ -0,0 +1,179 @@ +--- +title: MCP Server +sidebarTitle: MCP Server +description: Give AI agents direct access to .docx files through the Model Context Protocol +keywords: "mcp, model context protocol, ai agents, claude, cursor, windsurf, document automation, llm docx" +--- + +The SuperDoc MCP server lets AI agents open, read, edit, and save `.docx` files. It exposes the same operations as the [Document API](/document-api/overview) through the [Model Context Protocol](https://modelcontextprotocol.io) — the open standard for connecting AI tools to agents. + + +The MCP server is in alpha. Tools and output formats may change. + + +## Setup + +Install once. Your MCP client spawns the server automatically on each conversation. + + + + ```bash + claude mcp add superdoc -- npx @superdoc-dev/mcp + ``` + + + Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: + + ```json + { + "mcpServers": { + "superdoc": { + "command": "npx", + "args": ["@superdoc-dev/mcp"] + } + } + } + ``` + + + Add to `~/.cursor/mcp.json`: + + ```json + { + "mcpServers": { + "superdoc": { + "command": "npx", + "args": ["@superdoc-dev/mcp"] + } + } + } + ``` + + + Add to `~/.codeium/windsurf/mcp_config.json`: + + ```json + { + "mcpServers": { + "superdoc": { + "command": "npx", + "args": ["@superdoc-dev/mcp"] + } + } + } + ``` + + + +## Workflow + +Every interaction follows the same pattern: open, read or edit, save, close. + +``` +superdoc_open → superdoc_find / superdoc_get_text → edit tools → superdoc_save → superdoc_close +``` + +1. `superdoc_open` loads a `.docx` file and returns a `session_id` +2. `superdoc_find` locates content and returns target addresses +3. Edit tools (`superdoc_insert`, `superdoc_replace`, `superdoc_delete`, `superdoc_format`) use those addresses +4. `superdoc_save` writes changes to disk +5. `superdoc_close` releases the session + +## Tools + +13 tools in five groups. All tools take a `session_id` from `superdoc_open`. + +### Lifecycle + +| Tool | Input | Description | +| --- | --- | --- | +| `superdoc_open` | `path` | Open a `.docx` file. Returns `session_id` and file path | +| `superdoc_save` | `session_id`, `out?` | Save to the original path, or to `out` if specified | +| `superdoc_close` | `session_id` | Close the session. Unsaved changes are lost | + +### Query + +| Tool | Input | Description | +| --- | --- | --- | +| `superdoc_find` | `session_id`, `type?`, `pattern?`, `limit?`, `offset?` | Search by node type, text pattern, or both. Returns matches with addresses | +| `superdoc_get_node` | `session_id`, `address` | Get details about a specific node by its address | +| `superdoc_info` | `session_id` | Document metadata: structure summary, node counts, capabilities | +| `superdoc_get_text` | `session_id` | Full plain-text content of the document | + +### Mutation + +| Tool | Input | Description | +| --- | --- | --- | +| `superdoc_insert` | `session_id`, `text`, `target` | Insert text at a target position | +| `superdoc_replace` | `session_id`, `text`, `target` | Replace content at a target range | +| `superdoc_delete` | `session_id`, `target` | Delete content at a target range | + +### Format + +| Tool | Input | Description | +| --- | --- | --- | +| `superdoc_format` | `session_id`, `style`, `target` | Toggle formatting on a text range. Styles: `bold`, `italic`, `underline`, `strikethrough` | + +### Create + +| Tool | Input | Description | +| --- | --- | --- | +| `superdoc_create` | `session_id`, `type`, `text?`, `level?`, `at?` | Create a block element. Types: `paragraph`, `heading` (headings require `level` 1-6) | + +## Example conversation + +Here's what a typical agent interaction looks like: + +``` +User: "Replace all mentions of 'Acme Corp' with 'NewCo Inc.' in contract.docx" + +Agent calls: superdoc_open({ path: "/path/to/contract.docx" }) + → { session_id: "contract-a1b2c3", filePath: "/path/to/contract.docx" } + +Agent calls: superdoc_find({ session_id: "contract-a1b2c3", pattern: "Acme Corp" }) + → { matches: [{ kind: "text", blockId: "p1", range: { start: 12, end: 21 } }, ...], total: 4 } + +Agent calls: superdoc_replace({ session_id: "contract-a1b2c3", text: "NewCo Inc.", target: "
" }) + → (repeats for each match) + +Agent calls: superdoc_save({ session_id: "contract-a1b2c3" }) + → { path: "/path/to/contract.docx", byteLength: 24583 } + +Agent calls: superdoc_close({ session_id: "contract-a1b2c3" }) +``` + +## How it works + +The MCP server runs as a local subprocess, communicating over stdio. It manages document sessions in memory — each `superdoc_open` creates an Editor instance, and all subsequent operations run against that in-memory state until you `superdoc_save`. + +``` +AI Agent (Claude, Cursor, Windsurf) + │ MCP protocol (stdio) + ▼ +@superdoc-dev/mcp + │ Document API + ▼ +SuperDoc Editor (in-memory) + │ export + ▼ +.docx file on disk +``` + +Your documents never leave your machine. The server runs locally, reads files from disk, and writes back to disk. + +## Debugging + +Test the server directly with the MCP Inspector: + +```bash +npx @modelcontextprotocol/inspector -- npx @superdoc-dev/mcp +``` + +This opens a browser UI where you can call each tool manually and inspect the raw JSON-RPC messages. + +## Related + +- [CLI](/document-engine/cli) — edit documents from the terminal +- [SDKs](/document-engine/sdks) — typed Node.js and Python wrappers +- [Document API](/document-api/overview) — the in-browser API that defines the operation set +- [AI Agents](/getting-started/ai-agents) — headless mode for server-side AI workflows diff --git a/apps/docs/document-engine/overview.mdx b/apps/docs/document-engine/overview.mdx index a426f5c41..0197f695e 100644 --- a/apps/docs/document-engine/overview.mdx +++ b/apps/docs/document-engine/overview.mdx @@ -9,24 +9,26 @@ keywords: "document engine, document api, sdk, cli, headless docx, document auto Document Engine is in alpha and subject to breaking changes while the contract and adapters continue to evolve. -Document Engine is the programmatic surface of SuperDoc. It gives you three ways to read and edit `.docx` files without a visible editor: +Document Engine is the programmatic surface of SuperDoc. It gives you four ways to read and edit `.docx` files without a visible editor: | Surface | Use case | Runtime | | --- | --- | --- | | [Document API](/document-api/overview) | In-browser editing via `editor.doc.*` methods | Browser (ProseMirror) | | [SDKs](/document-engine/sdks) | Node.js and Python wrappers for backend automation | Node / Python | | [CLI](/document-engine/cli) | Terminal commands for scripting and CI pipelines | Any shell | +| [MCP Server](/document-engine/mcp) | AI agent access via the Model Context Protocol | Local subprocess | -All three surfaces share the same operation set. An operation like `find` is available as `editor.doc.find()` in the browser, `superdoc.doc.find()` in the SDK, and `superdoc find` in the CLI. +All four surfaces share the same operation set. An operation like `find` is available as `editor.doc.find()` in the browser, `superdoc.doc.find()` in the SDK, `superdoc find` in the CLI, and `superdoc_find` in MCP. ## How it works -The Document API defines the canonical operations. The CLI wraps them in a stdio-based process. The SDKs manage the CLI process and expose typed methods for each operation. +The Document API defines the canonical operations. The CLI and MCP server wrap them for different consumers. The SDKs manage the CLI process and expose typed methods. ``` Document API (source of truth) - → CLI (stdio process) - → Node SDK / Python SDK (typed wrappers) + → CLI (terminal interface) + → MCP Server (AI agent interface) + → Node SDK / Python SDK (typed wrappers over CLI) ``` ## Where to start @@ -34,3 +36,4 @@ Document API (source of truth) - **Building a web editor?** Start with the [Document API](/document-api/overview). - **Automating documents from a backend?** Start with the [SDKs](/document-engine/sdks). - **Scripting from the terminal or CI?** Start with the [CLI](/document-engine/cli). +- **Connecting AI agents (Claude, Cursor, Windsurf)?** Start with the [MCP Server](/document-engine/mcp). diff --git a/apps/mcp/README.md b/apps/mcp/README.md new file mode 100644 index 000000000..706f3e289 --- /dev/null +++ b/apps/mcp/README.md @@ -0,0 +1,123 @@ +# @superdoc-dev/mcp + +MCP server for SuperDoc. Lets AI agents open, read, edit, and save `.docx` files through the [Model Context Protocol](https://modelcontextprotocol.io). + +Works with Claude Code, Claude Desktop, Cursor, Windsurf, OpenAI Codex, and any MCP-compatible client. + +## Quick start + +```bash +npx @superdoc-dev/mcp +``` + +The server communicates over stdio. You don't run it directly — your MCP client spawns it as a subprocess. + +## Setup + +### Claude Code + +```bash +claude mcp add superdoc -- npx @superdoc-dev/mcp +``` + +### Claude Desktop + +Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "superdoc": { + "command": "npx", + "args": ["@superdoc-dev/mcp"] + } + } +} +``` + +### Cursor + +Add to `~/.cursor/mcp.json`: + +```json +{ + "mcpServers": { + "superdoc": { + "command": "npx", + "args": ["@superdoc-dev/mcp"] + } + } +} +``` + +## Tools + +13 tools, grouped by purpose: + +### Lifecycle + +| Tool | Description | +| --- | --- | +| `superdoc_open` | Open a `.docx` file and get a `session_id` | +| `superdoc_save` | Save the document to disk | +| `superdoc_close` | Close the session and release memory | + +### Query + +| Tool | Description | +| --- | --- | +| `superdoc_find` | Search by text pattern, node type, or both | +| `superdoc_get_node` | Get details about a specific node | +| `superdoc_info` | Get document metadata and structure | +| `superdoc_get_text` | Get the full plain text of the document | + +### Mutation + +| Tool | Description | +| --- | --- | +| `superdoc_insert` | Insert text at a position | +| `superdoc_replace` | Replace content at a range | +| `superdoc_delete` | Delete content at a range | + +### Format + +| Tool | Description | +| --- | --- | +| `superdoc_format` | Toggle formatting (`bold`, `italic`, `underline`, `strikethrough`) on a text range | + +### Create + +| Tool | Description | +| --- | --- | +| `superdoc_create` | Create a new block element (`paragraph`, `heading`) | + +## Workflow + +Every interaction follows the same pattern: + +``` +open → read/edit → save → close +``` + +1. `superdoc_open` loads a document and returns a `session_id` +2. `superdoc_find` locates content and returns addresses +3. Edit tools use those addresses to modify content +4. `superdoc_save` writes changes to disk +5. `superdoc_close` releases the session + +## Development + +```bash +# Run locally +bun run src/index.ts + +# Run tests +bun test + +# Test with MCP Inspector +npx @modelcontextprotocol/inspector -- bun run src/index.ts +``` + +## License + +See the [SuperDoc license](../../LICENSE). diff --git a/apps/mcp/package.json b/apps/mcp/package.json new file mode 100644 index 000000000..64c90de04 --- /dev/null +++ b/apps/mcp/package.json @@ -0,0 +1,33 @@ +{ + "name": "@superdoc-dev/mcp", + "version": "1.0.0", + "type": "module", + "bin": { + "superdoc-mcp": "./dist/index.js" + }, + "files": [ + "dist", + "skill" + ], + "scripts": { + "dev": "bun run src/index.ts", + "build": "bun build src/index.ts --outdir dist --target node --format esm", + "test": "NODE_ENV=test bun test", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.26.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@superdoc/document-api": "workspace:*", + "@superdoc/super-editor": "workspace:*", + "superdoc": "workspace:*", + "@types/bun": "catalog:", + "@types/node": "catalog:", + "typescript": "catalog:" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/apps/mcp/src/__tests__/protocol.test.ts b/apps/mcp/src/__tests__/protocol.test.ts new file mode 100644 index 000000000..75c30c79a --- /dev/null +++ b/apps/mcp/src/__tests__/protocol.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, afterAll } from 'bun:test'; +import { resolve } from 'node:path'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +const BLANK_DOCX = resolve(import.meta.dir, '../../../../shared/common/data/blank.docx'); +const SERVER_ENTRY = resolve(import.meta.dir, '../index.ts'); + +const EXPECTED_TOOLS = [ + 'superdoc_open', + 'superdoc_save', + 'superdoc_close', + 'superdoc_find', + 'superdoc_get_node', + 'superdoc_info', + 'superdoc_get_text', + 'superdoc_insert', + 'superdoc_replace', + 'superdoc_delete', + 'superdoc_format', + 'superdoc_create', +]; + +function textContent(result: Awaited>): string { + const content = 'content' in result ? result.content : []; + const first = (content as Array<{ type: string; text?: string }>)[0]; + return first?.text ?? ''; +} + +function parseContent(result: Awaited>): unknown { + return JSON.parse(textContent(result)); +} + +describe('MCP protocol integration', () => { + let client: Client; + let transport: StdioClientTransport; + + // Connect once for all tests — spawns the server subprocess + const ready = (async () => { + transport = new StdioClientTransport({ + command: 'bun', + args: ['run', SERVER_ENTRY], + stderr: 'pipe', + }); + client = new Client({ name: 'test-client', version: '1.0.0' }); + await client.connect(transport); + })(); + + afterAll(async () => { + await transport?.close(); + }); + + it('connects and lists all expected tools', async () => { + await ready; + const { tools } = await client.listTools(); + const names = tools.map((t) => t.name).sort(); + + expect(names).toEqual([...EXPECTED_TOOLS].sort()); + }); + + it('tools have required annotations', async () => { + await ready; + const { tools } = await client.listTools(); + + for (const tool of tools) { + expect(tool.annotations).toBeDefined(); + expect(typeof tool.annotations!.readOnlyHint).toBe('boolean'); + } + }); + + it('open → info → get_text → close workflow', async () => { + await ready; + + // Open + const openResult = await client.callTool({ name: 'superdoc_open', arguments: { path: BLANK_DOCX } }); + const opened = parseContent(openResult) as { session_id: string; filePath: string }; + expect(opened.session_id).toBeString(); + expect(opened.filePath).toBe(BLANK_DOCX); + + const sid = opened.session_id; + + // Info + const infoResult = await client.callTool({ name: 'superdoc_info', arguments: { session_id: sid } }); + expect(textContent(infoResult)).toBeTruthy(); + + // Get text + const textResult = await client.callTool({ name: 'superdoc_get_text', arguments: { session_id: sid } }); + expect(textContent(textResult)).toBeDefined(); + + // Close + const closeResult = await client.callTool({ name: 'superdoc_close', arguments: { session_id: sid } }); + const closed = parseContent(closeResult) as { closed: boolean }; + expect(closed.closed).toBe(true); + }); + + it('open → create → find → save → close workflow', async () => { + await ready; + + // Open + const openResult = await client.callTool({ name: 'superdoc_open', arguments: { path: BLANK_DOCX } }); + const { session_id: sid } = parseContent(openResult) as { session_id: string }; + + // Create a paragraph + const createResult = await client.callTool({ + name: 'superdoc_create', + arguments: { session_id: sid, type: 'paragraph', text: 'MCP integration test' }, + }); + expect(textContent(createResult)).toContain('success'); + + // Find it + const findResult = await client.callTool({ + name: 'superdoc_find', + arguments: { session_id: sid, pattern: 'MCP integration' }, + }); + const found = parseContent(findResult) as { matches: unknown[]; total: number }; + expect(found.total).toBeGreaterThan(0); + + // Save to temp path + const tmpPath = resolve(import.meta.dir, '../../../../tmp-protocol-test.docx'); + const saveResult = await client.callTool({ + name: 'superdoc_save', + arguments: { session_id: sid, out: tmpPath }, + }); + const saved = parseContent(saveResult) as { path: string; byteLength: number }; + expect(saved.byteLength).toBeGreaterThan(0); + + // Close + await client.callTool({ name: 'superdoc_close', arguments: { session_id: sid } }); + + // Clean up temp file + const { unlink } = await import('node:fs/promises'); + await unlink(tmpPath).catch(() => {}); + }); + + it('returns isError for invalid session', async () => { + await ready; + + const result = await client.callTool({ + name: 'superdoc_find', + arguments: { session_id: 'nonexistent', pattern: 'test' }, + }); + + expect(result).toHaveProperty('isError', true); + expect(textContent(result)).toContain('No open session'); + }); + + it('returns isError for invalid file path', async () => { + await ready; + + const result = await client.callTool({ + name: 'superdoc_open', + arguments: { path: '/nonexistent/file.docx' }, + }); + + expect(result).toHaveProperty('isError', true); + expect(textContent(result)).toContain('Failed to open document'); + }); +}); diff --git a/apps/mcp/src/__tests__/session-manager.test.ts b/apps/mcp/src/__tests__/session-manager.test.ts new file mode 100644 index 000000000..bd5751dfb --- /dev/null +++ b/apps/mcp/src/__tests__/session-manager.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, afterEach } from 'bun:test'; +import { resolve } from 'node:path'; +import { SessionManager } from '../session-manager.js'; + +const BLANK_DOCX = resolve(import.meta.dir, '../../../../shared/common/data/blank.docx'); + +describe('SessionManager', () => { + const manager = new SessionManager(); + + afterEach(async () => { + await manager.closeAll(); + }); + + it('opens a .docx file and returns a session', async () => { + const session = await manager.open(BLANK_DOCX); + + expect(session.id).toBeString(); + expect(session.filePath).toBe(BLANK_DOCX); + expect(session.editor).toBeDefined(); + expect(session.api).toBeDefined(); + expect(session.openedAt).toBeNumber(); + }); + + it('retrieves an open session by id', async () => { + const session = await manager.open(BLANK_DOCX); + const retrieved = manager.get(session.id); + + expect(retrieved).toBe(session); + }); + + it('throws when getting a non-existent session', () => { + expect(() => manager.get('nonexistent')).toThrow('No open session'); + }); + + it('lists open sessions', async () => { + const session = await manager.open(BLANK_DOCX); + const list = manager.list(); + + expect(list).toHaveLength(1); + expect(list[0].id).toBe(session.id); + expect(list[0].filePath).toBe(BLANK_DOCX); + }); + + it('closes a session', async () => { + const session = await manager.open(BLANK_DOCX); + await manager.close(session.id); + + expect(() => manager.get(session.id)).toThrow('No open session'); + expect(manager.list()).toHaveLength(0); + }); + + it('close is idempotent for unknown ids', async () => { + await manager.close('nonexistent'); + // should not throw + }); + + it('saves a document to a temp path', async () => { + const session = await manager.open(BLANK_DOCX); + const tmpPath = resolve(import.meta.dir, '../../../../tmp-test-output.docx'); + + const result = await manager.save(session.id, tmpPath); + + expect(result.path).toBe(tmpPath); + expect(result.byteLength).toBeGreaterThan(0); + + // Clean up + const { unlink } = await import('node:fs/promises'); + await unlink(tmpPath).catch(() => {}); + }); + + it('generates human-friendly session ids', async () => { + const session = await manager.open(BLANK_DOCX); + + // Should contain part of the filename + expect(session.id).toMatch(/^blank-[a-f0-9]{6}$/); + }); +}); diff --git a/apps/mcp/src/__tests__/tools.test.ts b/apps/mcp/src/__tests__/tools.test.ts new file mode 100644 index 000000000..ce36edc59 --- /dev/null +++ b/apps/mcp/src/__tests__/tools.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, afterEach } from 'bun:test'; +import { resolve } from 'node:path'; +import { SessionManager } from '../session-manager.js'; + +const BLANK_DOCX = resolve(import.meta.dir, '../../../../shared/common/data/blank.docx'); + +describe('MCP tools integration', () => { + const sessions = new SessionManager(); + + afterEach(async () => { + await sessions.closeAll(); + }); + + it('open → info → close lifecycle', async () => { + const session = await sessions.open(BLANK_DOCX); + const { api } = sessions.get(session.id); + + const info = api.invoke({ operationId: 'info', input: {} }); + expect(info).toBeDefined(); + + await sessions.close(session.id); + expect(() => sessions.get(session.id)).toThrow(); + }); + + it('getText returns document text', async () => { + const session = await sessions.open(BLANK_DOCX); + const { api } = sessions.get(session.id); + + const text = api.invoke({ operationId: 'getText', input: {} }); + expect(text).toBeDefined(); + // blank.docx may have empty or minimal text + expect(typeof text === 'string' || typeof text === 'object').toBe(true); + }); + + it('find returns results for paragraphs', async () => { + const session = await sessions.open(BLANK_DOCX); + const { api } = sessions.get(session.id); + + const result = api.invoke({ + operationId: 'find', + input: { + query: { select: { type: 'node', nodeType: 'paragraph' } }, + }, + }); + + expect(result).toBeDefined(); + }); + + it('create.paragraph adds a paragraph', async () => { + const session = await sessions.open(BLANK_DOCX); + const { api } = sessions.get(session.id); + + const result = api.invoke({ + operationId: 'create.paragraph', + input: { text: 'Hello from MCP' }, + }); + + expect(result).toBeDefined(); + + // Verify the text was inserted + const text = api.invoke({ operationId: 'getText', input: {} }); + expect(String(text)).toContain('Hello from MCP'); + }); + + it('insert + getText roundtrip', async () => { + const session = await sessions.open(BLANK_DOCX); + const { api } = sessions.get(session.id); + + // Create a paragraph first to have a target + const created = api.invoke({ + operationId: 'create.paragraph', + input: { text: 'Initial text' }, + }) as { paragraph?: unknown; insertionPoint?: unknown }; + + expect(created).toBeDefined(); + + // Verify + const text = api.invoke({ operationId: 'getText', input: {} }); + expect(String(text)).toContain('Initial text'); + }); +}); diff --git a/apps/mcp/src/index.ts b/apps/mcp/src/index.ts new file mode 100644 index 000000000..f811a3ac1 --- /dev/null +++ b/apps/mcp/src/index.ts @@ -0,0 +1,35 @@ +#!/usr/bin/env node +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { SessionManager } from './session-manager.js'; +import { registerAllTools } from './tools/index.js'; + +const server = new McpServer({ + name: 'superdoc', + version: '1.0.0', +}); + +const sessions = new SessionManager(); + +registerAllTools(server, sessions); + +const transport = new StdioServerTransport(); + +async function main(): Promise { + await server.connect(transport); +} + +main().catch((err) => { + console.error('SuperDoc MCP server failed to start:', err); + process.exit(1); +}); + +process.on('SIGINT', async () => { + await sessions.closeAll(); + process.exit(0); +}); + +process.on('SIGTERM', async () => { + await sessions.closeAll(); + process.exit(0); +}); diff --git a/apps/mcp/src/session-manager.ts b/apps/mcp/src/session-manager.ts new file mode 100644 index 000000000..f032ae2da --- /dev/null +++ b/apps/mcp/src/session-manager.ts @@ -0,0 +1,109 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { randomBytes } from 'node:crypto'; +import { resolve, basename } from 'node:path'; +import { Editor } from 'superdoc/super-editor'; +import { getDocumentApiAdapters } from '@superdoc/super-editor/document-api-adapters'; +import { createDocumentApi, type DocumentApi } from '@superdoc/document-api'; + +export interface Session { + id: string; + filePath: string; + editor: Editor; + api: DocumentApi; + openedAt: number; +} + +export class SessionManager { + private sessions = new Map(); + + async open(filePath: string): Promise { + const absolutePath = resolve(filePath); + + const bytes = await readFile(absolutePath); + + const editor = await Editor.open(Buffer.from(bytes), { + documentId: absolutePath, + user: { id: 'mcp', name: 'MCP Server' }, + }); + + const adapters = getDocumentApiAdapters(editor); + const api = createDocumentApi(adapters); + + const id = generateSessionId(absolutePath); + + const session: Session = { + id, + filePath: absolutePath, + editor, + api, + openedAt: Date.now(), + }; + + this.sessions.set(id, session); + return session; + } + + get(sessionId: string): Session { + const session = this.sessions.get(sessionId); + if (!session) { + throw new Error(`No open session with id "${sessionId}". Use superdoc_open first.`); + } + return session; + } + + async save(sessionId: string, outputPath?: string): Promise<{ path: string; byteLength: number }> { + const session = this.get(sessionId); + const targetPath = outputPath ? resolve(outputPath) : session.filePath; + + const exported = await session.editor.exportDocument(); + const bytes = toUint8Array(exported); + + await writeFile(targetPath, bytes); + + return { path: targetPath, byteLength: bytes.byteLength }; + } + + async close(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) return; + + session.editor.destroy(); + this.sessions.delete(sessionId); + } + + async closeAll(): Promise { + for (const session of this.sessions.values()) { + session.editor.destroy(); + } + this.sessions.clear(); + } + + list(): Array<{ id: string; filePath: string; openedAt: number }> { + return Array.from(this.sessions.values()).map((s) => ({ + id: s.id, + filePath: s.filePath, + openedAt: s.openedAt, + })); + } +} + +function generateSessionId(filePath: string): string { + const stem = basename(filePath).replace(/\.[^.]+$/, ''); + const normalized = + stem + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/-{2,}/g, '-') + .replace(/^[._-]+|[._-]+$/g, '') || 'session'; + const suffix = randomBytes(4).toString('hex').slice(0, 6); + return `${normalized.slice(0, 57)}-${suffix}`; +} + +function toUint8Array(data: unknown): Uint8Array { + if (data instanceof Uint8Array) return data; + if (data instanceof ArrayBuffer) return new Uint8Array(data); + if (ArrayBuffer.isView(data)) { + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + } + throw new Error('Exported document data is not binary.'); +} diff --git a/apps/mcp/src/tools/create.ts b/apps/mcp/src/tools/create.ts new file mode 100644 index 000000000..3263ef78c --- /dev/null +++ b/apps/mcp/src/tools/create.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { SessionManager } from '../session-manager.js'; + +const TYPES = ['paragraph', 'heading'] as const; + +export function registerCreateTools(server: McpServer, sessions: SessionManager): void { + server.registerTool( + 'superdoc_create', + { + title: 'Create Block', + description: + 'Create a new block element in the document. Supports paragraphs and headings. Optionally specify text content and position.', + inputSchema: { + session_id: z.string().describe('Session ID from superdoc_open.'), + type: z.enum(TYPES).describe('The type of block to create.'), + text: z.string().optional().describe('Text content for the new block.'), + level: z.number().min(1).max(6).optional().describe('Heading level (1-6). Required when type is "heading".'), + at: z + .string() + .optional() + .describe('JSON-encoded position specifying where to create the block. If omitted, appends to the end.'), + }, + annotations: { readOnlyHint: false }, + }, + async ({ session_id, type, text, level, at }) => { + try { + const { api } = sessions.get(session_id); + const input: Record = {}; + if (text != null) input.text = text; + if (level != null) input.level = level; + if (at != null) input.at = JSON.parse(at); + + const result = api.invoke({ + operationId: `create.${type}`, + input, + }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Create failed: ${(err as Error).message}` }], + isError: true, + }; + } + }, + ); +} diff --git a/apps/mcp/src/tools/format.ts b/apps/mcp/src/tools/format.ts new file mode 100644 index 000000000..522cfeb1d --- /dev/null +++ b/apps/mcp/src/tools/format.ts @@ -0,0 +1,43 @@ +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { SessionManager } from '../session-manager.js'; + +const STYLES = ['bold', 'italic', 'underline', 'strikethrough'] as const; + +export function registerFormatTools(server: McpServer, sessions: SessionManager): void { + server.registerTool( + 'superdoc_format', + { + title: 'Format Text', + description: 'Toggle a formatting style on a text range. Use superdoc_find to locate the target range first.', + inputSchema: { + session_id: z.string().describe('Session ID from superdoc_open.'), + style: z.enum(STYLES).describe('The formatting style to toggle.'), + target: z + .string() + .describe( + 'JSON-encoded target address specifying the text range to format. Get this from superdoc_find results.', + ), + }, + annotations: { readOnlyHint: false }, + }, + async ({ session_id, style, target }) => { + try { + const { api } = sessions.get(session_id); + const parsed = JSON.parse(target); + const result = api.invoke({ + operationId: `format.${style}`, + input: { target: parsed }, + }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Format failed: ${(err as Error).message}` }], + isError: true, + }; + } + }, + ); +} diff --git a/apps/mcp/src/tools/index.ts b/apps/mcp/src/tools/index.ts new file mode 100644 index 000000000..9a93de5d1 --- /dev/null +++ b/apps/mcp/src/tools/index.ts @@ -0,0 +1,15 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { SessionManager } from '../session-manager.js'; +import { registerLifecycleTools } from './lifecycle.js'; +import { registerQueryTools } from './query.js'; +import { registerMutationTools } from './mutation.js'; +import { registerFormatTools } from './format.js'; +import { registerCreateTools } from './create.js'; + +export function registerAllTools(server: McpServer, sessions: SessionManager): void { + registerLifecycleTools(server, sessions); + registerQueryTools(server, sessions); + registerMutationTools(server, sessions); + registerFormatTools(server, sessions); + registerCreateTools(server, sessions); +} diff --git a/apps/mcp/src/tools/lifecycle.ts b/apps/mcp/src/tools/lifecycle.ts new file mode 100644 index 000000000..c0e1530bd --- /dev/null +++ b/apps/mcp/src/tools/lifecycle.ts @@ -0,0 +1,80 @@ +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { SessionManager } from '../session-manager.js'; + +export function registerLifecycleTools(server: McpServer, sessions: SessionManager): void { + server.registerTool( + 'superdoc_open', + { + title: 'Open Document', + description: + 'Open a Word document (.docx) for reading and editing. Must be called before any other operation. Returns a session_id to use in subsequent calls.', + inputSchema: { + path: z.string().describe('Absolute path to the .docx file.'), + }, + annotations: { readOnlyHint: false }, + }, + async ({ path }) => { + try { + const session = await sessions.open(path); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ session_id: session.id, filePath: session.filePath }), + }, + ], + }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Failed to open document: ${(err as Error).message}` }], + isError: true, + }; + } + }, + ); + + server.registerTool( + 'superdoc_save', + { + title: 'Save Document', + description: 'Save the document to disk. Writes to the original path unless "out" is specified.', + inputSchema: { + session_id: z.string().describe('Session ID from superdoc_open.'), + out: z.string().optional().describe('Save to a different file path instead of the original.'), + }, + annotations: { readOnlyHint: false }, + }, + async ({ session_id, out }) => { + try { + const result = await sessions.save(session_id, out); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Failed to save: ${(err as Error).message}` }], + isError: true, + }; + } + }, + ); + + server.registerTool( + 'superdoc_close', + { + title: 'Close Document', + description: 'Close a document session and release memory. Unsaved changes will be lost.', + inputSchema: { + session_id: z.string().describe('Session ID to close.'), + }, + annotations: { readOnlyHint: false, destructiveHint: true }, + }, + async ({ session_id }) => { + await sessions.close(session_id); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ closed: true }) }], + }; + }, + ); +} diff --git a/apps/mcp/src/tools/mutation.ts b/apps/mcp/src/tools/mutation.ts new file mode 100644 index 000000000..312e51a9e --- /dev/null +++ b/apps/mcp/src/tools/mutation.ts @@ -0,0 +1,108 @@ +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { SessionManager } from '../session-manager.js'; + +export function registerMutationTools(server: McpServer, sessions: SessionManager): void { + server.registerTool( + 'superdoc_insert', + { + title: 'Insert Text', + description: + 'Insert text at a target position in the document. Use superdoc_find first to get valid target addresses.', + inputSchema: { + session_id: z.string().describe('Session ID from superdoc_open.'), + text: z.string().describe('The text content to insert.'), + target: z + .string() + .describe('JSON-encoded target address specifying where to insert. Get this from superdoc_find results.'), + }, + annotations: { readOnlyHint: false }, + }, + async ({ session_id, text, target }) => { + try { + const { api } = sessions.get(session_id); + const parsed = JSON.parse(target); + const result = api.invoke({ + operationId: 'insert', + input: { text, target: parsed }, + }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Insert failed: ${(err as Error).message}` }], + isError: true, + }; + } + }, + ); + + server.registerTool( + 'superdoc_replace', + { + title: 'Replace Text', + description: + 'Replace content at a target range with new text. Use superdoc_find to locate the target range first.', + inputSchema: { + session_id: z.string().describe('Session ID from superdoc_open.'), + text: z.string().describe('The replacement text.'), + target: z + .string() + .describe('JSON-encoded target address specifying what to replace. Get this from superdoc_find results.'), + }, + annotations: { readOnlyHint: false }, + }, + async ({ session_id, text, target }) => { + try { + const { api } = sessions.get(session_id); + const parsed = JSON.parse(target); + const result = api.invoke({ + operationId: 'replace', + input: { text, target: parsed }, + }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Replace failed: ${(err as Error).message}` }], + isError: true, + }; + } + }, + ); + + server.registerTool( + 'superdoc_delete', + { + title: 'Delete Content', + description: 'Delete content at a target range. Use superdoc_find to locate the target range first.', + inputSchema: { + session_id: z.string().describe('Session ID from superdoc_open.'), + target: z + .string() + .describe('JSON-encoded target address specifying what to delete. Get this from superdoc_find results.'), + }, + annotations: { readOnlyHint: false, destructiveHint: true }, + }, + async ({ session_id, target }) => { + try { + const { api } = sessions.get(session_id); + const parsed = JSON.parse(target); + const result = api.invoke({ + operationId: 'delete', + input: { target: parsed }, + }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Delete failed: ${(err as Error).message}` }], + isError: true, + }; + } + }, + ); +} diff --git a/apps/mcp/src/tools/query.ts b/apps/mcp/src/tools/query.ts new file mode 100644 index 000000000..85d414ac1 --- /dev/null +++ b/apps/mcp/src/tools/query.ts @@ -0,0 +1,130 @@ +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { SessionManager } from '../session-manager.js'; + +export function registerQueryTools(server: McpServer, sessions: SessionManager): void { + server.registerTool( + 'superdoc_find', + { + title: 'Find in Document', + description: + 'Search the document for nodes matching a type, text pattern, or both. Returns matching nodes with their addresses (use addresses in subsequent edit operations).', + inputSchema: { + session_id: z.string().describe('Session ID from superdoc_open.'), + type: z.string().optional().describe('Node type to filter by (e.g. "heading", "paragraph", "table", "image").'), + pattern: z.string().optional().describe('Text pattern to search for (substring match).'), + limit: z.number().optional().describe('Maximum number of results.'), + offset: z.number().optional().describe('Skip this many results (for pagination).'), + }, + annotations: { readOnlyHint: true }, + }, + async ({ session_id, type, pattern, limit, offset }) => { + try { + const { api } = sessions.get(session_id); + const query: Record = {}; + + if (type) { + query.select = { type }; + } + if (pattern) { + query.select = { ...(query.select as object), pattern, mode: 'contains' }; + } + + const input: Record = { query }; + if (limit != null) input.limit = limit; + if (offset != null) input.offset = offset; + + const result = api.invoke({ operationId: 'find', input }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Find failed: ${(err as Error).message}` }], + isError: true, + }; + } + }, + ); + + server.registerTool( + 'superdoc_get_node', + { + title: 'Get Node', + description: + 'Get detailed information about a specific document node by its address (from superdoc_find results).', + inputSchema: { + session_id: z.string().describe('Session ID from superdoc_open.'), + address: z.string().describe('JSON-encoded node address from superdoc_find results.'), + }, + annotations: { readOnlyHint: true }, + }, + async ({ session_id, address }) => { + try { + const { api } = sessions.get(session_id); + const parsed = JSON.parse(address); + const result = api.invoke({ operationId: 'getNode', input: parsed }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Get node failed: ${(err as Error).message}` }], + isError: true, + }; + } + }, + ); + + server.registerTool( + 'superdoc_info', + { + title: 'Document Info', + description: 'Return document metadata: structure summary, node counts, and capabilities.', + inputSchema: { + session_id: z.string().describe('Session ID from superdoc_open.'), + }, + annotations: { readOnlyHint: true }, + }, + async ({ session_id }) => { + try { + const { api } = sessions.get(session_id); + const result = api.invoke({ operationId: 'info', input: {} }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Info failed: ${(err as Error).message}` }], + isError: true, + }; + } + }, + ); + + server.registerTool( + 'superdoc_get_text', + { + title: 'Get Document Text', + description: 'Return the full plain-text content of the document.', + inputSchema: { + session_id: z.string().describe('Session ID from superdoc_open.'), + }, + annotations: { readOnlyHint: true }, + }, + async ({ session_id }) => { + try { + const { api } = sessions.get(session_id); + const result = api.invoke({ operationId: 'getText', input: {} }); + return { + content: [{ type: 'text' as const, text: typeof result === 'string' ? result : JSON.stringify(result) }], + }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Get text failed: ${(err as Error).message}` }], + isError: true, + }; + } + }, + ); +} diff --git a/apps/mcp/tsconfig.json b/apps/mcp/tsconfig.json new file mode 100644 index 000000000..470e85813 --- /dev/null +++ b/apps/mcp/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "types": ["bun"], + "paths": { + "@superdoc/super-editor/document-api-adapters": ["../../packages/super-editor/src/document-api-adapters/index.ts"] + } + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a43af1678..bf97374ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -433,7 +433,7 @@ importers: version: 14.0.3 mintlify: specifier: ^4.2.331 - version: 4.2.374(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(typescript@5.9.3) + version: 4.2.374(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@25.3.0)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(typescript@5.9.3) remark-mdx: specifier: ^3.1.1 version: 3.1.1 @@ -447,6 +447,34 @@ importers: specifier: ^5.1.0 version: 5.1.0 + apps/mcp: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.26.0 + version: 1.26.0(zod@4.3.6) + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@superdoc/document-api': + specifier: workspace:* + version: link:../../packages/document-api + '@superdoc/super-editor': + specifier: workspace:* + version: link:../../packages/super-editor + '@types/bun': + specifier: 'catalog:' + version: 1.3.9 + '@types/node': + specifier: 'catalog:' + version: 22.19.2 + superdoc: + specifier: workspace:* + version: link:../../packages/superdoc + typescript: + specifier: 'catalog:' + version: 5.9.3 + apps/vscode-ext: dependencies: superdoc: @@ -2236,6 +2264,12 @@ packages: y-protocols: ^1.0.6 yjs: ^13.6.8 + '@hono/node-server@1.19.9': + resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -2621,6 +2655,16 @@ packages: '@mintlify/validation@0.1.604': resolution: {integrity: sha512-UeT6ZwstePMwE8oGYasbNiPtBj304r6P9iN5BnlinQ8TePPVrTG/Y171/00pZUfcHEd0ySlFGj2saHdUtKyhFg==} + '@modelcontextprotocol/sdk@1.26.0': + resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@napi-rs/canvas-android-arm64@0.1.94': resolution: {integrity: sha512-YQ6K83RWNMQOtgpk1aIML97QTE3zxPmVCHTi5eA8Nss4+B9JZi5J7LHQr7B5oD7VwSfWd++xsPdUiJ1+frqsMg==} engines: {node: '>= 10'} @@ -4288,6 +4332,10 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -4691,6 +4739,10 @@ packages: resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -5129,6 +5181,10 @@ packages: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + content-type@1.0.5: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} @@ -5181,6 +5237,10 @@ packages: cookie-signature@1.0.7: resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + cookie@0.4.2: resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==} engines: {node: '>= 0.6'} @@ -5883,6 +5943,14 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + evp_bytestokey@1.0.3: resolution: {integrity: sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==} @@ -5912,6 +5980,12 @@ packages: express-rate-limit@5.5.1: resolution: {integrity: sha512-MTjE2eIbHv5DyfuFz4zLYWxpqVhEhkTiwFGuB74Q9CSou2WHO52nlE5y3Zlg6SIsiYUIPj6ifFxnkPz6O3sIUg==} + express-rate-limit@8.2.1: + resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@4.18.2: resolution: {integrity: sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==} engines: {node: '>= 0.10.0'} @@ -5920,6 +5994,10 @@ packages: resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} engines: {node: '>= 0.10.0'} + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} @@ -6014,6 +6092,10 @@ packages: resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-up-simple@1.0.1: resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} engines: {node: '>=18'} @@ -6095,6 +6177,10 @@ packages: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + from2@2.3.0: resolution: {integrity: sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==} @@ -6480,6 +6566,10 @@ packages: hmac-drbg@1.0.1: resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} + hono@4.12.1: + resolution: {integrity: sha512-hi9afu8g0lfJVLolxElAZGANCTTl6bewIdsRNhaywfP9K8BPf++F2z6OLrYGIinUwpRKzbZHMhPwvc0ZEpAwGw==} + engines: {node: '>=16.9.0'} + hook-std@4.0.0: resolution: {integrity: sha512-IHI4bEVOt3vRUDJ+bFA9VUJlo7SzvFARPNLw75pqSmAOP2HmTWfFJtPvLBrDrlgjEYXY9zs7SFdHPQaJShkSCQ==} engines: {node: '>=20'} @@ -6685,6 +6775,10 @@ packages: resolution: {integrity: sha512-2dYz766i9HprMBasCMvHMuazJ7u4WzhJwo5kb3iPSiW/iRYV6uPari3zHoqZlnuaR7V1bEiNMxikhp37rdBXbw==} engines: {node: '>=12'} + ip-address@10.0.1: + resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} + engines: {node: '>= 12'} + ip-address@10.1.0: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} @@ -6879,6 +6973,9 @@ packages: is-promise@2.2.2: resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -7022,6 +7119,9 @@ packages: jju@1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -7096,6 +7196,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} @@ -7650,6 +7753,10 @@ packages: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + meow@12.1.1: resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==} engines: {node: '>=16.10'} @@ -7664,6 +7771,10 @@ packages: merge-descriptors@1.0.3: resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -7901,6 +8012,10 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mime@1.6.0: resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} engines: {node: '>=4'} @@ -8063,6 +8178,10 @@ packages: resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} engines: {node: '>= 0.6'} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} @@ -8674,6 +8793,9 @@ packages: path-to-regexp@3.3.0: resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -8751,6 +8873,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-conf@2.1.0: resolution: {integrity: sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==} engines: {node: '>=4'} @@ -9057,6 +9183,10 @@ packages: resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} engines: {node: '>= 0.8'} + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -9439,6 +9569,10 @@ packages: rope-sequence@1.3.4: resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -9567,6 +9701,10 @@ packages: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + serialize-error@13.0.1: resolution: {integrity: sha512-bBZaRwLH9PN5HbLCjPId4dP5bNGEtumcErgOX952IsvOhVPrm3/AeK1y0UHA/QaPG701eg0yEnOKsCOC6X/kaA==} engines: {node: '>=20'} @@ -9582,6 +9720,10 @@ packages: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + serve@14.2.5: resolution: {integrity: sha512-Qn/qMkzCcMFVPb60E/hQy+iRLpiU8PamOfOSYoAHmmF+fFFmpPpqa6Oci2iWYpTdOUM3VF+TINud7CfbQnsZbA==} engines: {node: '>= 14'} @@ -10243,6 +10385,10 @@ packages: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -10967,6 +11113,11 @@ packages: peerDependencies: zod: ^3.20.0 + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: ^3.25 || ^4 + zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} @@ -12208,6 +12359,10 @@ snapshots: - bufferutil - utf-8-validate + '@hono/node-server@1.19.9(hono@4.12.1)': + dependencies: + hono: 4.12.1 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -12296,128 +12451,128 @@ snapshots: '@inquirer/ansi@1.0.2': {} - '@inquirer/checkbox@4.3.2(@types/node@22.19.2)': + '@inquirer/checkbox@4.3.2(@types/node@25.3.0)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@25.3.0) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/type': 3.0.10(@types/node@25.3.0) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 25.3.0 - '@inquirer/confirm@5.1.21(@types/node@22.19.2)': + '@inquirer/confirm@5.1.21(@types/node@25.3.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@25.3.0) + '@inquirer/type': 3.0.10(@types/node@25.3.0) optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 25.3.0 - '@inquirer/core@10.3.2(@types/node@22.19.2)': + '@inquirer/core@10.3.2(@types/node@25.3.0)': dependencies: '@inquirer/ansi': 1.0.2 '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/type': 3.0.10(@types/node@25.3.0) cli-width: 4.1.0 mute-stream: 2.0.0 signal-exit: 4.1.0 wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 25.3.0 - '@inquirer/editor@4.2.23(@types/node@22.19.2)': + '@inquirer/editor@4.2.23(@types/node@25.3.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/external-editor': 1.0.3(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@25.3.0) + '@inquirer/external-editor': 1.0.3(@types/node@25.3.0) + '@inquirer/type': 3.0.10(@types/node@25.3.0) optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 25.3.0 - '@inquirer/expand@4.0.23(@types/node@22.19.2)': + '@inquirer/expand@4.0.23(@types/node@25.3.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@25.3.0) + '@inquirer/type': 3.0.10(@types/node@25.3.0) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 25.3.0 - '@inquirer/external-editor@1.0.3(@types/node@22.19.2)': + '@inquirer/external-editor@1.0.3(@types/node@25.3.0)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.2 optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 25.3.0 '@inquirer/figures@1.0.15': {} - '@inquirer/input@4.3.1(@types/node@22.19.2)': + '@inquirer/input@4.3.1(@types/node@25.3.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@25.3.0) + '@inquirer/type': 3.0.10(@types/node@25.3.0) optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 25.3.0 - '@inquirer/number@3.0.23(@types/node@22.19.2)': + '@inquirer/number@3.0.23(@types/node@25.3.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@25.3.0) + '@inquirer/type': 3.0.10(@types/node@25.3.0) optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 25.3.0 - '@inquirer/password@4.0.23(@types/node@22.19.2)': + '@inquirer/password@4.0.23(@types/node@25.3.0)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@25.3.0) + '@inquirer/type': 3.0.10(@types/node@25.3.0) optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 25.3.0 - '@inquirer/prompts@7.9.0(@types/node@22.19.2)': - dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@22.19.2) - '@inquirer/confirm': 5.1.21(@types/node@22.19.2) - '@inquirer/editor': 4.2.23(@types/node@22.19.2) - '@inquirer/expand': 4.0.23(@types/node@22.19.2) - '@inquirer/input': 4.3.1(@types/node@22.19.2) - '@inquirer/number': 3.0.23(@types/node@22.19.2) - '@inquirer/password': 4.0.23(@types/node@22.19.2) - '@inquirer/rawlist': 4.1.11(@types/node@22.19.2) - '@inquirer/search': 3.2.2(@types/node@22.19.2) - '@inquirer/select': 4.4.2(@types/node@22.19.2) + '@inquirer/prompts@7.9.0(@types/node@25.3.0)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@25.3.0) + '@inquirer/confirm': 5.1.21(@types/node@25.3.0) + '@inquirer/editor': 4.2.23(@types/node@25.3.0) + '@inquirer/expand': 4.0.23(@types/node@25.3.0) + '@inquirer/input': 4.3.1(@types/node@25.3.0) + '@inquirer/number': 3.0.23(@types/node@25.3.0) + '@inquirer/password': 4.0.23(@types/node@25.3.0) + '@inquirer/rawlist': 4.1.11(@types/node@25.3.0) + '@inquirer/search': 3.2.2(@types/node@25.3.0) + '@inquirer/select': 4.4.2(@types/node@25.3.0) optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 25.3.0 - '@inquirer/rawlist@4.1.11(@types/node@22.19.2)': + '@inquirer/rawlist@4.1.11(@types/node@25.3.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@25.3.0) + '@inquirer/type': 3.0.10(@types/node@25.3.0) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 25.3.0 - '@inquirer/search@3.2.2(@types/node@22.19.2)': + '@inquirer/search@3.2.2(@types/node@25.3.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@25.3.0) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/type': 3.0.10(@types/node@25.3.0) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 25.3.0 - '@inquirer/select@4.4.2(@types/node@22.19.2)': + '@inquirer/select@4.4.2(@types/node@25.3.0)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@25.3.0) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/type': 3.0.10(@types/node@25.3.0) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 25.3.0 - '@inquirer/type@3.0.10(@types/node@22.19.2)': + '@inquirer/type@3.0.10(@types/node@25.3.0)': optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 25.3.0 '@isaacs/cliui@8.0.2': dependencies: @@ -12566,9 +12721,9 @@ snapshots: '@microsoft/tsdoc@0.16.0': {} - '@mintlify/cli@4.0.977(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(typescript@5.9.3)': + '@mintlify/cli@4.0.977(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@25.3.0)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(typescript@5.9.3)': dependencies: - '@inquirer/prompts': 7.9.0(@types/node@22.19.2) + '@inquirer/prompts': 7.9.0(@types/node@25.3.0) '@mintlify/common': 1.0.748(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@5.9.3) '@mintlify/link-rot': 3.0.912(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@5.9.3) '@mintlify/models': 0.0.274 @@ -12583,7 +12738,7 @@ snapshots: front-matter: 4.0.2 fs-extra: 11.2.0 ink: 6.3.0(@types/react@19.2.14)(react@19.2.3) - inquirer: 12.3.0(@types/node@22.19.2) + inquirer: 12.3.0(@types/node@25.3.0) js-yaml: 4.1.0 mdast-util-mdx-jsx: 3.2.0 react: 19.2.3 @@ -12987,6 +13142,28 @@ snapshots: - supports-color - typescript + '@modelcontextprotocol/sdk@1.26.0(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.9(hono@4.12.1) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.2.1(express@5.2.1) + hono: 4.12.1 + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - supports-color + '@napi-rs/canvas-android-arm64@0.1.94': optional: true @@ -14093,7 +14270,7 @@ snapshots: '@stoplight/json-ref-readers@1.2.2': dependencies: - node-fetch: 2.6.7 + node-fetch: 2.7.0 tslib: 1.14.1 transitivePeerDependencies: - encoding @@ -14317,7 +14494,7 @@ snapshots: '@types/conventional-commits-parser@5.0.2': dependencies: - '@types/node': 22.19.2 + '@types/node': 25.3.0 '@types/cookie@0.4.1': {} @@ -14414,7 +14591,7 @@ snapshots: '@types/responselike@1.0.0': dependencies: - '@types/node': 22.19.2 + '@types/node': 25.3.0 '@types/supports-color@8.1.3': {} @@ -14430,7 +14607,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 22.19.2 + '@types/node': 25.3.0 '@types/yauzl@2.10.3': dependencies: @@ -15045,6 +15222,11 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.11.2): dependencies: acorn: 8.11.2 @@ -15486,6 +15668,20 @@ snapshots: transitivePeerDependencies: - supports-color + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3(supports-color@5.5.0) + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.0 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + boolbase@1.0.0: {} bottleneck@2.19.5: {} @@ -15606,7 +15802,7 @@ snapshots: bun-types@1.3.9: dependencies: - '@types/node': 22.19.2 + '@types/node': 25.3.0 bundle-name@4.1.0: dependencies: @@ -15971,6 +16167,8 @@ snapshots: dependencies: safe-buffer: 5.2.1 + content-disposition@1.0.1: {} + content-type@1.0.5: {} conventional-changelog-angular@7.0.0: @@ -16015,6 +16213,8 @@ snapshots: cookie-signature@1.0.7: {} + cookie-signature@1.2.2: {} + cookie@0.4.2: {} cookie@0.5.0: {} @@ -16941,6 +17141,12 @@ snapshots: events@3.3.0: {} + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + evp_bytestokey@1.0.3: dependencies: md5.js: 1.3.5 @@ -16993,6 +17199,11 @@ snapshots: express-rate-limit@5.5.1: {} + express-rate-limit@8.2.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.0.1 + express@4.18.2: dependencies: accepts: 1.3.8 @@ -17065,6 +17276,39 @@ snapshots: transitivePeerDependencies: - supports-color + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3(supports-color@5.5.0) + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + exsolve@1.0.8: {} extend@3.0.2: {} @@ -17174,6 +17418,17 @@ snapshots: transitivePeerDependencies: - supports-color + finalhandler@2.1.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-up-simple@1.0.1: {} find-up@2.1.0: @@ -17249,6 +17504,8 @@ snapshots: fresh@0.5.2: {} + fresh@2.0.0: {} + from2@2.3.0: dependencies: inherits: 2.0.4 @@ -17556,7 +17813,7 @@ snapshots: happy-dom@20.4.0: dependencies: - '@types/node': 22.19.2 + '@types/node': 25.3.0 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 entities: 4.5.0 @@ -17888,6 +18145,8 @@ snapshots: minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 + hono@4.12.1: {} + hook-std@4.0.0: {} hosted-git-info@2.8.9: {} @@ -18084,12 +18343,12 @@ snapshots: inline-style-parser@0.2.7: {} - inquirer@12.3.0(@types/node@22.19.2): + inquirer@12.3.0(@types/node@25.3.0): dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/prompts': 7.9.0(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) - '@types/node': 22.19.2 + '@inquirer/core': 10.3.2(@types/node@25.3.0) + '@inquirer/prompts': 7.9.0(@types/node@25.3.0) + '@inquirer/type': 3.0.10(@types/node@25.3.0) + '@types/node': 25.3.0 ansi-escapes: 4.3.2 mute-stream: 2.0.0 run-async: 3.0.0 @@ -18106,6 +18365,8 @@ snapshots: from2: 2.3.0 p-is-promise: 3.0.0 + ip-address@10.0.1: {} + ip-address@10.1.0: {} ip-regex@4.3.0: {} @@ -18270,6 +18531,8 @@ snapshots: is-promise@2.2.2: {} + is-promise@4.0.0: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -18409,6 +18672,8 @@ snapshots: jju@1.4.0: {} + jose@6.1.3: {} + joycon@3.1.1: {} js-beautify@1.15.4: @@ -18487,6 +18752,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} json-stable-stringify-without-jsonify@1.0.1: {} @@ -19227,6 +19494,8 @@ snapshots: media-typer@0.3.0: {} + media-typer@1.1.0: {} + meow@12.1.1: {} meow@13.2.0: {} @@ -19235,6 +19504,8 @@ snapshots: merge-descriptors@1.0.3: {} + merge-descriptors@2.0.0: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -19751,6 +20022,10 @@ snapshots: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mime@1.6.0: {} mime@3.0.0: {} @@ -19816,9 +20091,9 @@ snapshots: minipass: 3.3.6 yallist: 4.0.0 - mintlify@4.2.374(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(typescript@5.9.3): + mintlify@4.2.374(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@25.3.0)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(typescript@5.9.3): dependencies: - '@mintlify/cli': 4.0.977(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(typescript@5.9.3) + '@mintlify/cli': 4.0.977(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@25.3.0)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(typescript@5.9.3) transitivePeerDependencies: - '@radix-ui/react-popover' - '@types/node' @@ -19902,6 +20177,8 @@ snapshots: negotiator@0.6.4: {} + negotiator@1.0.0: {} + neo-async@2.6.2: {} neotraverse@0.6.18: {} @@ -20414,6 +20691,8 @@ snapshots: path-to-regexp@3.3.0: {} + path-to-regexp@8.3.0: {} + path-type@4.0.0: {} pathe@2.0.3: {} @@ -20493,6 +20772,8 @@ snapshots: pirates@4.0.7: {} + pkce-challenge@5.0.1: {} + pkg-conf@2.1.0: dependencies: find-up: 2.1.0 @@ -20856,6 +21137,13 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -21453,6 +21741,16 @@ snapshots: rope-sequence@1.3.4: {} + router@2.2.0: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + run-applescript@7.1.0: {} run-async@3.0.0: {} @@ -21626,6 +21924,22 @@ snapshots: transitivePeerDependencies: - supports-color + send@1.2.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + serialize-error@13.0.1: dependencies: non-error: 0.1.0 @@ -21659,6 +21973,15 @@ snapshots: transitivePeerDependencies: - supports-color + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + serve@14.2.5: dependencies: '@zeit/schemas': 2.36.0 @@ -22483,6 +22806,12 @@ snapshots: media-typer: 0.3.0 mime-types: 2.1.35 + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -23437,6 +23766,10 @@ snapshots: dependencies: zod: 3.24.0 + zod-to-json-schema@3.25.1(zod@4.3.6): + dependencies: + zod: 4.3.6 + zod-validation-error@4.0.2(zod@4.3.6): dependencies: zod: 4.3.6 From ab602222f7a5802e33cbc202b604d17130b5e79b Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 21 Feb 2026 17:38:50 -0300 Subject: [PATCH 2/7] feat(mcp): add track changes, comments, lists and fix find query --- apps/mcp/package.json | 3 +- apps/mcp/src/__tests__/protocol.test.ts | 11 ++ apps/mcp/src/tools/comments.ts | 134 +++++++++++++++++++++ apps/mcp/src/tools/create.ts | 11 +- apps/mcp/src/tools/format.ts | 14 ++- apps/mcp/src/tools/index.ts | 6 + apps/mcp/src/tools/lists.ts | 76 ++++++++++++ apps/mcp/src/tools/mutation.ts | 50 ++++++-- apps/mcp/src/tools/query.ts | 21 ++-- apps/mcp/src/tools/track-changes.ts | 148 ++++++++++++++++++++++++ 10 files changed, 449 insertions(+), 25 deletions(-) create mode 100644 apps/mcp/src/tools/comments.ts create mode 100644 apps/mcp/src/tools/lists.ts create mode 100644 apps/mcp/src/tools/track-changes.ts diff --git a/apps/mcp/package.json b/apps/mcp/package.json index 64c90de04..c36bc8ac9 100644 --- a/apps/mcp/package.json +++ b/apps/mcp/package.json @@ -6,8 +6,7 @@ "superdoc-mcp": "./dist/index.js" }, "files": [ - "dist", - "skill" + "dist" ], "scripts": { "dev": "bun run src/index.ts", diff --git a/apps/mcp/src/__tests__/protocol.test.ts b/apps/mcp/src/__tests__/protocol.test.ts index 75c30c79a..0d6100e72 100644 --- a/apps/mcp/src/__tests__/protocol.test.ts +++ b/apps/mcp/src/__tests__/protocol.test.ts @@ -19,6 +19,17 @@ const EXPECTED_TOOLS = [ 'superdoc_delete', 'superdoc_format', 'superdoc_create', + 'superdoc_list_changes', + 'superdoc_accept_change', + 'superdoc_reject_change', + 'superdoc_accept_all_changes', + 'superdoc_reject_all_changes', + 'superdoc_add_comment', + 'superdoc_list_comments', + 'superdoc_reply_comment', + 'superdoc_resolve_comment', + 'superdoc_insert_list', + 'superdoc_list_set_type', ]; function textContent(result: Awaited>): string { diff --git a/apps/mcp/src/tools/comments.ts b/apps/mcp/src/tools/comments.ts new file mode 100644 index 000000000..83bff4e1f --- /dev/null +++ b/apps/mcp/src/tools/comments.ts @@ -0,0 +1,134 @@ +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { SessionManager } from '../session-manager.js'; + +export function registerCommentTools(server: McpServer, sessions: SessionManager): void { + server.registerTool( + 'superdoc_add_comment', + { + title: 'Add Comment', + description: + 'Add a comment anchored to a text range in the document. Use superdoc_find with a text pattern first, then pass a TextAddress from context[].textRanges as the target.', + inputSchema: { + session_id: z.string().describe('Session ID from superdoc_open.'), + text: z.string().describe('The comment text (question, concern, or feedback).'), + target: z + .string() + .describe( + 'JSON-encoded TextAddress: {"kind":"text","blockId":"...","range":{"start":N,"end":N}}. Get this from superdoc_find context[].textRanges, NOT from matches[].', + ), + }, + annotations: { readOnlyHint: false }, + }, + async ({ session_id, text, target }) => { + try { + const { api } = sessions.get(session_id); + const parsed = JSON.parse(target); + const result = api.invoke({ + operationId: 'comments.add', + input: { text, target: parsed }, + }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Add comment failed: ${(err as Error).message}` }], + isError: true, + }; + } + }, + ); + + server.registerTool( + 'superdoc_list_comments', + { + title: 'List Comments', + description: + 'List all comments in the document. Returns comment text, author, status (open/resolved), and the text range each comment is anchored to.', + inputSchema: { + session_id: z.string().describe('Session ID from superdoc_open.'), + include_resolved: z.boolean().optional().describe('Include resolved comments. Defaults to true.'), + }, + annotations: { readOnlyHint: true }, + }, + async ({ session_id, include_resolved }) => { + try { + const { api } = sessions.get(session_id); + const input: Record = {}; + if (include_resolved != null) input.includeResolved = include_resolved; + + const result = api.invoke({ operationId: 'comments.list', input }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `List comments failed: ${(err as Error).message}` }], + isError: true, + }; + } + }, + ); + + server.registerTool( + 'superdoc_reply_comment', + { + title: 'Reply to Comment', + description: 'Reply to an existing comment thread. Use the comment ID from superdoc_list_comments.', + inputSchema: { + session_id: z.string().describe('Session ID from superdoc_open.'), + comment_id: z.string().describe('The parent comment ID to reply to (from superdoc_list_comments).'), + text: z.string().describe('The reply text.'), + }, + annotations: { readOnlyHint: false }, + }, + async ({ session_id, comment_id, text }) => { + try { + const { api } = sessions.get(session_id); + const result = api.invoke({ + operationId: 'comments.reply', + input: { parentCommentId: comment_id, text }, + }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Reply failed: ${(err as Error).message}` }], + isError: true, + }; + } + }, + ); + + server.registerTool( + 'superdoc_resolve_comment', + { + title: 'Resolve Comment', + description: 'Mark a comment as resolved. Use the comment ID from superdoc_list_comments.', + inputSchema: { + session_id: z.string().describe('Session ID from superdoc_open.'), + comment_id: z.string().describe('The comment ID to resolve (from superdoc_list_comments).'), + }, + annotations: { readOnlyHint: false }, + }, + async ({ session_id, comment_id }) => { + try { + const { api } = sessions.get(session_id); + const result = api.invoke({ + operationId: 'comments.resolve', + input: { commentId: comment_id }, + }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Resolve failed: ${(err as Error).message}` }], + isError: true, + }; + } + }, + ); +} diff --git a/apps/mcp/src/tools/create.ts b/apps/mcp/src/tools/create.ts index 3263ef78c..f623d3f90 100644 --- a/apps/mcp/src/tools/create.ts +++ b/apps/mcp/src/tools/create.ts @@ -10,7 +10,7 @@ export function registerCreateTools(server: McpServer, sessions: SessionManager) { title: 'Create Block', description: - 'Create a new block element in the document. Supports paragraphs and headings. Optionally specify text content and position.', + 'Create a new block element in the document. Supports paragraphs and headings. Optionally specify text content and position. Set suggest=true to create as a tracked change (suggestion).', inputSchema: { session_id: z.string().describe('Session ID from superdoc_open.'), type: z.enum(TYPES).describe('The type of block to create.'), @@ -20,10 +20,16 @@ export function registerCreateTools(server: McpServer, sessions: SessionManager) .string() .optional() .describe('JSON-encoded position specifying where to create the block. If omitted, appends to the end.'), + suggest: z + .boolean() + .optional() + .describe( + 'If true, create as a tracked change (suggestion) that can be accepted or rejected later. Defaults to false (direct edit).', + ), }, annotations: { readOnlyHint: false }, }, - async ({ session_id, type, text, level, at }) => { + async ({ session_id, type, text, level, at, suggest }) => { try { const { api } = sessions.get(session_id); const input: Record = {}; @@ -34,6 +40,7 @@ export function registerCreateTools(server: McpServer, sessions: SessionManager) const result = api.invoke({ operationId: `create.${type}`, input, + options: suggest ? { changeMode: 'tracked' as const } : undefined, }); return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], diff --git a/apps/mcp/src/tools/format.ts b/apps/mcp/src/tools/format.ts index 522cfeb1d..8911e3694 100644 --- a/apps/mcp/src/tools/format.ts +++ b/apps/mcp/src/tools/format.ts @@ -9,25 +9,33 @@ export function registerFormatTools(server: McpServer, sessions: SessionManager) 'superdoc_format', { title: 'Format Text', - description: 'Toggle a formatting style on a text range. Use superdoc_find to locate the target range first.', + description: + "Toggle a formatting style on a text range. Use superdoc_find with a text pattern first, then pass a TextAddress from the result's context[].textRanges as the target. Set suggest=true to format as a tracked change (suggestion).", inputSchema: { session_id: z.string().describe('Session ID from superdoc_open.'), style: z.enum(STYLES).describe('The formatting style to toggle.'), target: z .string() .describe( - 'JSON-encoded target address specifying the text range to format. Get this from superdoc_find results.', + 'JSON-encoded TextAddress: {"kind":"text","blockId":"...","range":{"start":N,"end":N}}. Get this from superdoc_find context[].textRanges, NOT from matches[].', + ), + suggest: z + .boolean() + .optional() + .describe( + 'If true, format as a tracked change (suggestion) that can be accepted or rejected later. Defaults to false (direct edit).', ), }, annotations: { readOnlyHint: false }, }, - async ({ session_id, style, target }) => { + async ({ session_id, style, target, suggest }) => { try { const { api } = sessions.get(session_id); const parsed = JSON.parse(target); const result = api.invoke({ operationId: `format.${style}`, input: { target: parsed }, + options: suggest ? { changeMode: 'tracked' as const } : undefined, }); return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], diff --git a/apps/mcp/src/tools/index.ts b/apps/mcp/src/tools/index.ts index 9a93de5d1..7c1773678 100644 --- a/apps/mcp/src/tools/index.ts +++ b/apps/mcp/src/tools/index.ts @@ -5,6 +5,9 @@ import { registerQueryTools } from './query.js'; import { registerMutationTools } from './mutation.js'; import { registerFormatTools } from './format.js'; import { registerCreateTools } from './create.js'; +import { registerTrackChangesTools } from './track-changes.js'; +import { registerCommentTools } from './comments.js'; +import { registerListTools } from './lists.js'; export function registerAllTools(server: McpServer, sessions: SessionManager): void { registerLifecycleTools(server, sessions); @@ -12,4 +15,7 @@ export function registerAllTools(server: McpServer, sessions: SessionManager): v registerMutationTools(server, sessions); registerFormatTools(server, sessions); registerCreateTools(server, sessions); + registerTrackChangesTools(server, sessions); + registerCommentTools(server, sessions); + registerListTools(server, sessions); } diff --git a/apps/mcp/src/tools/lists.ts b/apps/mcp/src/tools/lists.ts new file mode 100644 index 000000000..3facd55f0 --- /dev/null +++ b/apps/mcp/src/tools/lists.ts @@ -0,0 +1,76 @@ +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { SessionManager } from '../session-manager.js'; + +export function registerListTools(server: McpServer, sessions: SessionManager): void { + server.registerTool( + 'superdoc_insert_list', + { + title: 'Insert List Item', + description: + 'Insert a new list item before or after an existing one. To start a new list, use superdoc_create with type "paragraph" first, then convert it. Or use superdoc_find to locate an existing list item.', + inputSchema: { + session_id: z.string().describe('Session ID from superdoc_open.'), + target: z + .string() + .describe('JSON-encoded list item address from superdoc_find or superdoc_list_items results.'), + position: z.enum(['before', 'after']).describe('Insert before or after the target item.'), + text: z.string().optional().describe('Text content for the new list item.'), + }, + annotations: { readOnlyHint: false }, + }, + async ({ session_id, target, position, text }) => { + try { + const { api } = sessions.get(session_id); + const parsed = JSON.parse(target); + const input: Record = { target: parsed, position }; + if (text != null) input.text = text; + + const result = api.invoke({ + operationId: 'lists.insert', + input, + }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Insert list item failed: ${(err as Error).message}` }], + isError: true, + }; + } + }, + ); + + server.registerTool( + 'superdoc_list_set_type', + { + title: 'Set List Type', + description: 'Change a list between ordered (numbered) and bullet (unordered).', + inputSchema: { + session_id: z.string().describe('Session ID from superdoc_open.'), + target: z.string().describe('JSON-encoded list item address from superdoc_find results.'), + kind: z.enum(['ordered', 'bullet']).describe('The list type to set.'), + }, + annotations: { readOnlyHint: false }, + }, + async ({ session_id, target, kind }) => { + try { + const { api } = sessions.get(session_id); + const parsed = JSON.parse(target); + const result = api.invoke({ + operationId: 'lists.setType', + input: { target: parsed, kind }, + }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Set list type failed: ${(err as Error).message}` }], + isError: true, + }; + } + }, + ); +} diff --git a/apps/mcp/src/tools/mutation.ts b/apps/mcp/src/tools/mutation.ts index 312e51a9e..1ef415559 100644 --- a/apps/mcp/src/tools/mutation.ts +++ b/apps/mcp/src/tools/mutation.ts @@ -2,29 +2,42 @@ import { z } from 'zod'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { SessionManager } from '../session-manager.js'; +function mutationOptions(suggest?: boolean) { + return suggest ? { changeMode: 'tracked' as const } : undefined; +} + export function registerMutationTools(server: McpServer, sessions: SessionManager): void { server.registerTool( 'superdoc_insert', { title: 'Insert Text', description: - 'Insert text at a target position in the document. Use superdoc_find first to get valid target addresses.', + "Insert text at a target position in the document. Use superdoc_find first, then pass a TextAddress from the result's context[].textRanges as the target. Set suggest=true to insert as a tracked change (suggestion) instead of a direct edit.", inputSchema: { session_id: z.string().describe('Session ID from superdoc_open.'), text: z.string().describe('The text content to insert.'), target: z .string() - .describe('JSON-encoded target address specifying where to insert. Get this from superdoc_find results.'), + .describe( + 'JSON-encoded TextAddress: {"kind":"text","blockId":"...","range":{"start":N,"end":N}}. Get this from superdoc_find context[].textRanges, NOT from matches[].', + ), + suggest: z + .boolean() + .optional() + .describe( + 'If true, insert as a tracked change (suggestion) that can be accepted or rejected later. Defaults to false (direct edit).', + ), }, annotations: { readOnlyHint: false }, }, - async ({ session_id, text, target }) => { + async ({ session_id, text, target, suggest }) => { try { const { api } = sessions.get(session_id); const parsed = JSON.parse(target); const result = api.invoke({ operationId: 'insert', input: { text, target: parsed }, + options: mutationOptions(suggest), }); return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], @@ -43,23 +56,32 @@ export function registerMutationTools(server: McpServer, sessions: SessionManage { title: 'Replace Text', description: - 'Replace content at a target range with new text. Use superdoc_find to locate the target range first.', + "Replace content at a target range with new text. Use superdoc_find with a text pattern first, then pass a TextAddress from the result's context[].textRanges as the target. Set suggest=true to make the replacement a tracked change (suggestion).", inputSchema: { session_id: z.string().describe('Session ID from superdoc_open.'), text: z.string().describe('The replacement text.'), target: z .string() - .describe('JSON-encoded target address specifying what to replace. Get this from superdoc_find results.'), + .describe( + 'JSON-encoded TextAddress: {"kind":"text","blockId":"...","range":{"start":N,"end":N}}. Get this from superdoc_find context[].textRanges, NOT from matches[].', + ), + suggest: z + .boolean() + .optional() + .describe( + 'If true, replace as a tracked change (suggestion) that can be accepted or rejected later. Defaults to false (direct edit).', + ), }, annotations: { readOnlyHint: false }, }, - async ({ session_id, text, target }) => { + async ({ session_id, text, target, suggest }) => { try { const { api } = sessions.get(session_id); const parsed = JSON.parse(target); const result = api.invoke({ operationId: 'replace', input: { text, target: parsed }, + options: mutationOptions(suggest), }); return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], @@ -77,22 +99,32 @@ export function registerMutationTools(server: McpServer, sessions: SessionManage 'superdoc_delete', { title: 'Delete Content', - description: 'Delete content at a target range. Use superdoc_find to locate the target range first.', + description: + "Delete content at a target range. Use superdoc_find with a text pattern first, then pass a TextAddress from the result's context[].textRanges as the target. Set suggest=true to delete as a tracked change (suggestion).", inputSchema: { session_id: z.string().describe('Session ID from superdoc_open.'), target: z .string() - .describe('JSON-encoded target address specifying what to delete. Get this from superdoc_find results.'), + .describe( + 'JSON-encoded TextAddress: {"kind":"text","blockId":"...","range":{"start":N,"end":N}}. Get this from superdoc_find context[].textRanges, NOT from matches[].', + ), + suggest: z + .boolean() + .optional() + .describe( + 'If true, delete as a tracked change (suggestion) that can be accepted or rejected later. Defaults to false (direct edit).', + ), }, annotations: { readOnlyHint: false, destructiveHint: true }, }, - async ({ session_id, target }) => { + async ({ session_id, target, suggest }) => { try { const { api } = sessions.get(session_id); const parsed = JSON.parse(target); const result = api.invoke({ operationId: 'delete', input: { target: parsed }, + options: mutationOptions(suggest), }); return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], diff --git a/apps/mcp/src/tools/query.ts b/apps/mcp/src/tools/query.ts index 85d414ac1..c7f8927b6 100644 --- a/apps/mcp/src/tools/query.ts +++ b/apps/mcp/src/tools/query.ts @@ -8,7 +8,7 @@ export function registerQueryTools(server: McpServer, sessions: SessionManager): { title: 'Find in Document', description: - 'Search the document for nodes matching a type, text pattern, or both. Returns matching nodes with their addresses (use addresses in subsequent edit operations).', + 'Search the document for nodes matching a type, text pattern, or both. For text searches, the result includes context[].textRanges — these are the TextAddress objects you pass as "target" to replace/insert/delete/format tools. Do NOT use matches[] as mutation targets (those are block addresses).', inputSchema: { session_id: z.string().describe('Session ID from superdoc_open.'), type: z.string().optional().describe('Node type to filter by (e.g. "heading", "paragraph", "table", "image").'), @@ -21,18 +21,21 @@ export function registerQueryTools(server: McpServer, sessions: SessionManager): async ({ session_id, type, pattern, limit, offset }) => { try { const { api } = sessions.get(session_id); - const query: Record = {}; - if (type) { - query.select = { type }; - } + // Build a Selector or Query object directly — find accepts both. + // Selector: { type: 'text', pattern } or { type: 'node', nodeType } + // Query: { select: Selector, limit?, offset? } + let selector: Record; if (pattern) { - query.select = { ...(query.select as object), pattern, mode: 'contains' }; + selector = { type: 'text', pattern, mode: 'contains' }; + } else if (type) { + selector = { type: 'node', nodeType: type }; + } else { + selector = { type: 'node' }; } - const input: Record = { query }; - if (limit != null) input.limit = limit; - if (offset != null) input.offset = offset; + const input: Record = + limit != null || offset != null ? { select: selector, limit, offset } : selector; const result = api.invoke({ operationId: 'find', input }); return { diff --git a/apps/mcp/src/tools/track-changes.ts b/apps/mcp/src/tools/track-changes.ts new file mode 100644 index 000000000..c4fb604fb --- /dev/null +++ b/apps/mcp/src/tools/track-changes.ts @@ -0,0 +1,148 @@ +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { SessionManager } from '../session-manager.js'; + +export function registerTrackChangesTools(server: McpServer, sessions: SessionManager): void { + server.registerTool( + 'superdoc_list_changes', + { + title: 'List Tracked Changes', + description: + 'List all tracked changes (suggestions) in the document. Returns change type (insert/delete/format), author, date, and excerpt for each.', + inputSchema: { + session_id: z.string().describe('Session ID from superdoc_open.'), + type: z.enum(['insert', 'delete', 'format']).optional().describe('Filter by change type.'), + limit: z.number().optional().describe('Maximum number of results.'), + offset: z.number().optional().describe('Skip this many results (for pagination).'), + }, + annotations: { readOnlyHint: true }, + }, + async ({ session_id, type, limit, offset }) => { + try { + const { api } = sessions.get(session_id); + const input: Record = {}; + if (type != null) input.type = type; + if (limit != null) input.limit = limit; + if (offset != null) input.offset = offset; + + const result = api.invoke({ operationId: 'trackChanges.list', input }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `List changes failed: ${(err as Error).message}` }], + isError: true, + }; + } + }, + ); + + server.registerTool( + 'superdoc_accept_change', + { + title: 'Accept Tracked Change', + description: + 'Accept a single tracked change (suggestion), applying it to the document. Use the change ID from superdoc_list_changes.', + inputSchema: { + session_id: z.string().describe('Session ID from superdoc_open.'), + id: z.string().describe('The tracked change ID from superdoc_list_changes results.'), + }, + annotations: { readOnlyHint: false }, + }, + async ({ session_id, id }) => { + try { + const { api } = sessions.get(session_id); + const result = api.invoke({ operationId: 'trackChanges.accept', input: { id } }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Accept change failed: ${(err as Error).message}` }], + isError: true, + }; + } + }, + ); + + server.registerTool( + 'superdoc_reject_change', + { + title: 'Reject Tracked Change', + description: + 'Reject a single tracked change (suggestion), reverting it from the document. Use the change ID from superdoc_list_changes.', + inputSchema: { + session_id: z.string().describe('Session ID from superdoc_open.'), + id: z.string().describe('The tracked change ID from superdoc_list_changes results.'), + }, + annotations: { readOnlyHint: false }, + }, + async ({ session_id, id }) => { + try { + const { api } = sessions.get(session_id); + const result = api.invoke({ operationId: 'trackChanges.reject', input: { id } }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Reject change failed: ${(err as Error).message}` }], + isError: true, + }; + } + }, + ); + + server.registerTool( + 'superdoc_accept_all_changes', + { + title: 'Accept All Tracked Changes', + description: 'Accept all tracked changes (suggestions) in the document, applying them all.', + inputSchema: { + session_id: z.string().describe('Session ID from superdoc_open.'), + }, + annotations: { readOnlyHint: false }, + }, + async ({ session_id }) => { + try { + const { api } = sessions.get(session_id); + const result = api.invoke({ operationId: 'trackChanges.acceptAll', input: {} }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Accept all failed: ${(err as Error).message}` }], + isError: true, + }; + } + }, + ); + + server.registerTool( + 'superdoc_reject_all_changes', + { + title: 'Reject All Tracked Changes', + description: 'Reject all tracked changes (suggestions) in the document, reverting them all.', + inputSchema: { + session_id: z.string().describe('Session ID from superdoc_open.'), + }, + annotations: { readOnlyHint: false, destructiveHint: true }, + }, + async ({ session_id }) => { + try { + const { api } = sessions.get(session_id); + const result = api.invoke({ operationId: 'trackChanges.rejectAll', input: {} }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Reject all failed: ${(err as Error).message}` }], + isError: true, + }; + } + }, + ); +} From 9fdbd055bb1b08af1bc050547f3531b4acc5e5f1 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 21 Feb 2026 17:43:44 -0300 Subject: [PATCH 3/7] docs(mcp): update README and Mintlify docs for 23 tools Add track changes, comments, and lists tool groups. Document suggest mode for tracked changes. Remove example conversation. --- apps/docs/document-engine/mcp.mdx | 56 ++++++++++++++++---------- apps/mcp/README.md | 65 ++++++++++++++++++++++++++----- 2 files changed, 90 insertions(+), 31 deletions(-) diff --git a/apps/docs/document-engine/mcp.mdx b/apps/docs/document-engine/mcp.mdx index 7586eef39..06f3a8b73 100644 --- a/apps/docs/document-engine/mcp.mdx +++ b/apps/docs/document-engine/mcp.mdx @@ -2,7 +2,7 @@ title: MCP Server sidebarTitle: MCP Server description: Give AI agents direct access to .docx files through the Model Context Protocol -keywords: "mcp, model context protocol, ai agents, claude, cursor, windsurf, document automation, llm docx" +keywords: "mcp, model context protocol, ai agents, claude, cursor, windsurf, document automation, llm docx, track changes, comments" --- The SuperDoc MCP server lets AI agents open, read, edit, and save `.docx` files. It exposes the same operations as the [Document API](/document-api/overview) through the [Model Context Protocol](https://modelcontextprotocol.io) — the open standard for connecting AI tools to agents. @@ -81,7 +81,7 @@ superdoc_open → superdoc_find / superdoc_get_text → edit tools → superdoc_ ## Tools -13 tools in five groups. All tools take a `session_id` from `superdoc_open`. +23 tools in eight groups. All tools take a `session_id` from `superdoc_open`. ### Lifecycle @@ -102,45 +102,59 @@ superdoc_open → superdoc_find / superdoc_get_text → edit tools → superdoc_ ### Mutation +All mutation tools accept `suggest?` — set to `true` to make the edit a tracked change instead of a direct edit. + | Tool | Input | Description | | --- | --- | --- | -| `superdoc_insert` | `session_id`, `text`, `target` | Insert text at a target position | -| `superdoc_replace` | `session_id`, `text`, `target` | Replace content at a target range | -| `superdoc_delete` | `session_id`, `target` | Delete content at a target range | +| `superdoc_insert` | `session_id`, `text`, `target`, `suggest?` | Insert text at a target position | +| `superdoc_replace` | `session_id`, `text`, `target`, `suggest?` | Replace content at a target range | +| `superdoc_delete` | `session_id`, `target`, `suggest?` | Delete content at a target range | ### Format | Tool | Input | Description | | --- | --- | --- | -| `superdoc_format` | `session_id`, `style`, `target` | Toggle formatting on a text range. Styles: `bold`, `italic`, `underline`, `strikethrough` | +| `superdoc_format` | `session_id`, `style`, `target`, `suggest?` | Toggle formatting on a text range. Styles: `bold`, `italic`, `underline`, `strikethrough` | ### Create | Tool | Input | Description | | --- | --- | --- | -| `superdoc_create` | `session_id`, `type`, `text?`, `level?`, `at?` | Create a block element. Types: `paragraph`, `heading` (headings require `level` 1-6) | +| `superdoc_create` | `session_id`, `type`, `text?`, `level?`, `at?`, `suggest?` | Create a block element. Types: `paragraph`, `heading` (headings require `level` 1-6) | -## Example conversation +### Track changes -Here's what a typical agent interaction looks like: +Review and resolve tracked changes (suggestions) in the document. -``` -User: "Replace all mentions of 'Acme Corp' with 'NewCo Inc.' in contract.docx" +| Tool | Input | Description | +| --- | --- | --- | +| `superdoc_list_changes` | `session_id`, `type?`, `limit?`, `offset?` | List tracked changes with type, author, date, and excerpt | +| `superdoc_accept_change` | `session_id`, `id` | Accept a single change, applying it to the document | +| `superdoc_reject_change` | `session_id`, `id` | Reject a single change, reverting it | +| `superdoc_accept_all_changes` | `session_id` | Accept all tracked changes at once | +| `superdoc_reject_all_changes` | `session_id` | Reject all tracked changes at once | -Agent calls: superdoc_open({ path: "/path/to/contract.docx" }) - → { session_id: "contract-a1b2c3", filePath: "/path/to/contract.docx" } +### Comments -Agent calls: superdoc_find({ session_id: "contract-a1b2c3", pattern: "Acme Corp" }) - → { matches: [{ kind: "text", blockId: "p1", range: { start: 12, end: 21 } }, ...], total: 4 } +Add and manage comments anchored to text ranges. -Agent calls: superdoc_replace({ session_id: "contract-a1b2c3", text: "NewCo Inc.", target: "
" }) - → (repeats for each match) +| Tool | Input | Description | +| --- | --- | --- | +| `superdoc_add_comment` | `session_id`, `text`, `target` | Add a comment anchored to a text range | +| `superdoc_list_comments` | `session_id`, `include_resolved?` | List all comments with author, status, and anchored text | +| `superdoc_reply_comment` | `session_id`, `comment_id`, `text` | Reply to an existing comment thread | +| `superdoc_resolve_comment` | `session_id`, `comment_id` | Mark a comment as resolved | -Agent calls: superdoc_save({ session_id: "contract-a1b2c3" }) - → { path: "/path/to/contract.docx", byteLength: 24583 } +### Lists -Agent calls: superdoc_close({ session_id: "contract-a1b2c3" }) -``` +| Tool | Input | Description | +| --- | --- | --- | +| `superdoc_insert_list` | `session_id`, `target`, `position`, `text?` | Insert a list item before or after an existing one | +| `superdoc_list_set_type` | `session_id`, `target`, `kind` | Change a list between `ordered` (numbered) and `bullet` | + +## Suggesting mode + +Set `suggest=true` on any mutation, format, or create tool to make edits appear as tracked changes. The document stays unchanged until someone accepts the suggestions — in Word, in SuperDoc's browser editor, or programmatically via the track changes tools. ## How it works diff --git a/apps/mcp/README.md b/apps/mcp/README.md index 706f3e289..f6d30b0bc 100644 --- a/apps/mcp/README.md +++ b/apps/mcp/README.md @@ -50,46 +50,87 @@ Add to `~/.cursor/mcp.json`: } ``` +### Windsurf + +Add to `~/.codeium/windsurf/mcp_config.json`: + +```json +{ + "mcpServers": { + "superdoc": { + "command": "npx", + "args": ["@superdoc-dev/mcp"] + } + } +} +``` + ## Tools -13 tools, grouped by purpose: +23 tools in eight groups. All tools take a `session_id` from `superdoc_open`. ### Lifecycle | Tool | Description | | --- | --- | | `superdoc_open` | Open a `.docx` file and get a `session_id` | -| `superdoc_save` | Save the document to disk | +| `superdoc_save` | Save the document to disk (original path or custom `out` path) | | `superdoc_close` | Close the session and release memory | ### Query | Tool | Description | | --- | --- | -| `superdoc_find` | Search by text pattern, node type, or both | +| `superdoc_find` | Search by text pattern, node type, or both. Returns addresses for mutations | | `superdoc_get_node` | Get details about a specific node | -| `superdoc_info` | Get document metadata and structure | -| `superdoc_get_text` | Get the full plain text of the document | +| `superdoc_info` | Document metadata and structure | +| `superdoc_get_text` | Full plain text of the document | ### Mutation | Tool | Description | | --- | --- | -| `superdoc_insert` | Insert text at a position | -| `superdoc_replace` | Replace content at a range | -| `superdoc_delete` | Delete content at a range | +| `superdoc_insert` | Insert text at a position. Set `suggest=true` for tracked changes | +| `superdoc_replace` | Replace content at a range. Set `suggest=true` for tracked changes | +| `superdoc_delete` | Delete content at a range. Set `suggest=true` for tracked changes | ### Format | Tool | Description | | --- | --- | -| `superdoc_format` | Toggle formatting (`bold`, `italic`, `underline`, `strikethrough`) on a text range | +| `superdoc_format` | Toggle formatting (`bold`, `italic`, `underline`, `strikethrough`). Set `suggest=true` for tracked changes | ### Create | Tool | Description | | --- | --- | -| `superdoc_create` | Create a new block element (`paragraph`, `heading`) | +| `superdoc_create` | Create a block element (`paragraph`, `heading`). Set `suggest=true` for tracked changes | + +### Track changes + +| Tool | Description | +| --- | --- | +| `superdoc_list_changes` | List all tracked changes with type, author, and excerpt | +| `superdoc_accept_change` | Accept a single tracked change | +| `superdoc_reject_change` | Reject a single tracked change | +| `superdoc_accept_all_changes` | Accept all tracked changes | +| `superdoc_reject_all_changes` | Reject all tracked changes | + +### Comments + +| Tool | Description | +| --- | --- | +| `superdoc_add_comment` | Add a comment anchored to a text range | +| `superdoc_list_comments` | List all comments with author, status, and anchored text | +| `superdoc_reply_comment` | Reply to an existing comment thread | +| `superdoc_resolve_comment` | Mark a comment as resolved | + +### Lists + +| Tool | Description | +| --- | --- | +| `superdoc_insert_list` | Insert a list item before or after an existing one | +| `superdoc_list_set_type` | Change a list between ordered and bullet | ## Workflow @@ -105,6 +146,10 @@ open → read/edit → save → close 4. `superdoc_save` writes changes to disk 5. `superdoc_close` releases the session +### Suggesting mode + +Set `suggest=true` on any mutation, format, or create tool to make edits appear as tracked changes (suggestions) instead of direct edits. Use `superdoc_list_changes` to review them, and `superdoc_accept_change` / `superdoc_reject_change` to resolve them. + ## Development ```bash From e91f6f582014c3e7ade2d92fe7bc4534d05213e9 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 21 Feb 2026 17:46:18 -0300 Subject: [PATCH 4/7] docs: add NEW badge to MCP server nav entry --- apps/docs/docs.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/docs/docs.json b/apps/docs/docs.json index fe4867592..b5e881d67 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -97,7 +97,10 @@ "document-api/overview", "document-engine/sdks", "document-engine/cli", - "document-engine/mcp" + { + "page": "document-engine/mcp", + "tag": "NEW" + } ] }, { From 8f0bf485a105fbd1c6d87ec0e65b294ade59b000 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 21 Feb 2026 17:48:10 -0300 Subject: [PATCH 5/7] chore(mcp): add semantic release and CI workflow - Set version to 0.0.0 (semantic-release manages versions) - Add .releaserc.cjs with mcp-v tag format and npm publish - Add release-mcp.yml workflow triggered on apps/mcp/** changes --- .github/workflows/release-mcp.yml | 61 +++++++++++++++++++++++++++++++ apps/mcp/.releaserc.cjs | 45 +++++++++++++++++++++++ apps/mcp/package.json | 2 +- 3 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release-mcp.yml create mode 100644 apps/mcp/.releaserc.cjs diff --git a/.github/workflows/release-mcp.yml b/.github/workflows/release-mcp.yml new file mode 100644 index 000000000..83ad46628 --- /dev/null +++ b/.github/workflows/release-mcp.yml @@ -0,0 +1,61 @@ +# Auto-releases on push to main (@next channel) +# For stable (@latest): cherry-pick commits to stable branch, then manually dispatch this workflow +name: 📦 Release MCP + +on: + push: + branches: + - main + paths: + - 'apps/mcp/**' + - '!**/*.md' + workflow_dispatch: + +permissions: + contents: write + packages: write + +concurrency: + group: release-mcp-${{ github.ref }} + cancel-in-progress: true + +jobs: + release: + runs-on: ubuntu-24.04 + steps: + - name: Generate token + id: generate_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ steps.generate_token.outputs.token }} + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + registry-url: 'https://registry.npmjs.org' + + - uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: pnpm install + + - name: Build MCP server + run: pnpm --prefix apps/mcp run build + + - name: Release + env: + GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + LINEAR_TOKEN: ${{ secrets.LINEAR_TOKEN }} + working-directory: apps/mcp + run: pnpx semantic-release diff --git a/apps/mcp/.releaserc.cjs b/apps/mcp/.releaserc.cjs new file mode 100644 index 000000000..9b4f98cff --- /dev/null +++ b/apps/mcp/.releaserc.cjs @@ -0,0 +1,45 @@ +/* eslint-env node */ +const branch = process.env.GITHUB_REF_NAME || process.env.CI_COMMIT_BRANCH; + +const config = { + branches: [ + { name: 'stable', channel: 'latest' }, + { name: 'main', prerelease: 'next', channel: 'next' }, + ], + tagFormat: 'mcp-v${version}', + plugins: [ + 'semantic-release-commit-filter', + '@semantic-release/commit-analyzer', + '@semantic-release/release-notes-generator', + ['@semantic-release/npm'], + ], +}; + +const isPrerelease = config.branches.some((b) => typeof b === 'object' && b.name === branch && b.prerelease); + +if (!isPrerelease) { + config.plugins.push([ + '@semantic-release/git', + { + assets: ['package.json'], + message: 'chore(mcp): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}', + }, + ]); +} + +// Linear integration - labels issues with version on release +config.plugins.push(['semantic-release-linear-app', { + teamKeys: ['SD'], + addComment: true, + packageName: 'mcp', + commentTemplate: 'shipped in {package} {releaseLink} {channel}' +}]); + +config.plugins.push([ + '@semantic-release/github', + { + successComment: ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **@superdoc-dev/mcp** v${nextRelease.version}\n\nThe release is available on [GitHub release](${releases.find(release => release.pluginName === "@semantic-release/github").url})', + } +]); + +module.exports = config; diff --git a/apps/mcp/package.json b/apps/mcp/package.json index c36bc8ac9..882d0cc5b 100644 --- a/apps/mcp/package.json +++ b/apps/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/mcp", - "version": "1.0.0", + "version": "0.0.0", "type": "module", "bin": { "superdoc-mcp": "./dist/index.js" From ece91c1ad097b9c9403ac7acd8b536cc16f05823 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 21 Feb 2026 17:48:38 -0300 Subject: [PATCH 6/7] ci(mcp): add PR validation workflow Runs typecheck, build, and tests on PRs touching apps/mcp/. --- .github/workflows/ci-mcp.yml | 38 ++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/ci-mcp.yml diff --git a/.github/workflows/ci-mcp.yml b/.github/workflows/ci-mcp.yml new file mode 100644 index 000000000..bb94ea9cd --- /dev/null +++ b/.github/workflows/ci-mcp.yml @@ -0,0 +1,38 @@ +name: CI MCP + +permissions: + contents: read + +on: + pull_request: + paths: + - 'apps/mcp/**' + workflow_dispatch: + +concurrency: + group: ci-mcp-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + + - uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm --prefix apps/mcp run build + + - name: Test + run: pnpm --prefix apps/mcp run test From b26d5803dfaaac55fa0240271d85c41b0566a274 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 21 Feb 2026 17:54:40 -0300 Subject: [PATCH 7/7] fix(ci): build superdoc before MCP in CI workflows superdoc/super-editor is a subpath export that requires packages/superdoc to be built first. --- .github/workflows/ci-mcp.yml | 3 +++ .github/workflows/release-mcp.yml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/ci-mcp.yml b/.github/workflows/ci-mcp.yml index bb94ea9cd..4091c82ad 100644 --- a/.github/workflows/ci-mcp.yml +++ b/.github/workflows/ci-mcp.yml @@ -31,6 +31,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Build superdoc (dependency) + run: pnpm run build:superdoc + - name: Build run: pnpm --prefix apps/mcp run build diff --git a/.github/workflows/release-mcp.yml b/.github/workflows/release-mcp.yml index 83ad46628..e6ead9a52 100644 --- a/.github/workflows/release-mcp.yml +++ b/.github/workflows/release-mcp.yml @@ -48,6 +48,9 @@ jobs: - name: Install dependencies run: pnpm install + - name: Build superdoc (dependency) + run: pnpm run build:superdoc + - name: Build MCP server run: pnpm --prefix apps/mcp run build