diff --git a/packages/docx-mcp/package.json b/packages/docx-mcp/package.json index 1efa220..3d8f310 100644 --- a/packages/docx-mcp/package.json +++ b/packages/docx-mcp/package.json @@ -11,7 +11,7 @@ }, "scripts": { "clean:dist": "node -e \"const { rmSync } = require('node:fs'); rmSync('dist', { recursive: true, force: true });\"", - "build": "npm run build -w @usejunior/docx-core &&npm run clean:dist && tsc -p tsconfig.build.json", + "build": "npm run build -w @usejunior/docx-core &&npm run clean:dist && tsc -p tsconfig.build.json && if [ -d src/app ]; then mkdir -p dist/app && cp src/app/*.html dist/app/; fi", "dev": "tsx watch src/cli.ts", "check:spec-coverage": "node scripts/validate_openspec_coverage.mjs", "conformance:discover": "tsx scripts/discover_docx_fixtures.ts --out conformance/discovery.report.json", diff --git a/packages/docx-mcp/src/app/app_tools.ts b/packages/docx-mcp/src/app/app_tools.ts new file mode 100644 index 0000000..29a7952 --- /dev/null +++ b/packages/docx-mcp/src/app/app_tools.ts @@ -0,0 +1,45 @@ +import { z } from 'zod'; + +type ToolMeta = { + ui?: { + resourceUri: string; + visibility?: Array<'model' | 'app'>; + }; +}; + +const SESSION_OR_FILE_FIELDS = { + session_id: z.string().optional(), + file_path: z.string().optional(), +}; + +const GET_DOCUMENT_VIEW_ENTRY = { + name: 'get_document_view' as const, + description: + 'Get full document view as structured nodes with styles, formatting, and metadata. Returns DocumentViewNode[] for the interactive preview app. Hidden from LLM — only callable by the preview app.', + input: z.object({ + ...SESSION_OR_FILE_FIELDS, + }), + annotations: { readOnlyHint: true, destructiveHint: false }, + _meta: { + ui: { + resourceUri: 'ui://safe-docx/preview', + visibility: ['app'], + }, + } satisfies ToolMeta, +}; + +function toJsonObjectSchema(schema: z.ZodTypeAny, name: string): Record { + const jsonSchema = z.toJSONSchema(schema); + if (typeof jsonSchema !== 'object' || Array.isArray(jsonSchema) || jsonSchema === null) { + throw new Error(`Expected JSON schema object for tool '${name}'.`); + } + return jsonSchema as Record; +} + +export const GET_DOCUMENT_VIEW_TOOL = { + name: GET_DOCUMENT_VIEW_ENTRY.name, + description: GET_DOCUMENT_VIEW_ENTRY.description, + inputSchema: toJsonObjectSchema(GET_DOCUMENT_VIEW_ENTRY.input, GET_DOCUMENT_VIEW_ENTRY.name), + annotations: GET_DOCUMENT_VIEW_ENTRY.annotations, + _meta: GET_DOCUMENT_VIEW_ENTRY._meta, +}; diff --git a/packages/docx-mcp/src/app/get_document_view.ts b/packages/docx-mcp/src/app/get_document_view.ts new file mode 100644 index 0000000..d49139c --- /dev/null +++ b/packages/docx-mcp/src/app/get_document_view.ts @@ -0,0 +1,46 @@ +import { SessionManager } from '../session/manager.js'; +import { errorMessage } from '../error_utils.js'; +import { err, ok, type ToolResponse } from '../tools/types.js'; +import { mergeSessionResolutionMetadata, resolveSessionForTool } from '../tools/session_resolution.js'; + +export async function getDocumentView( + manager: SessionManager, + params: { session_id?: string; file_path?: string }, +): Promise { + try { + const resolved = await resolveSessionForTool(manager, params, { toolName: 'get_document_view' }); + if (!resolved.ok) return resolved.response; + const { session, metadata } = resolved; + + const { nodes, styles } = session.doc.buildDocumentView({ + includeSemanticTags: true, + showFormatting: true, + }); + + const stylesObj: Record = {}; + for (const [id, info] of styles.styles) { + stylesObj[id] = { + style_id: info.style_id, + display_name: info.display_name, + fingerprint: info.fingerprint, + count: info.count, + dominant_alignment: info.dominant_alignment, + }; + } + + return ok( + mergeSessionResolutionMetadata( + { + session_id: session.sessionId, + edit_revision: session.editRevision, + nodes, + styles: stylesObj, + }, + metadata, + ), + ); + } catch (e: unknown) { + const msg = errorMessage(e); + return err('VIEW_ERROR', msg, 'Check session status and try again.'); + } +} diff --git a/packages/docx-mcp/src/app/mcp-app-resources.ts b/packages/docx-mcp/src/app/mcp-app-resources.ts new file mode 100644 index 0000000..1bc764d --- /dev/null +++ b/packages/docx-mcp/src/app/mcp-app-resources.ts @@ -0,0 +1,87 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + ListResourcesRequestSchema, + ReadResourceRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; + +import { SessionManager } from '../session/manager.js'; +import { getPreviewHtml } from './preview-html.js'; +import { getDocumentView } from './get_document_view.js'; +import { GET_DOCUMENT_VIEW_TOOL } from './app_tools.js'; + +export const PREVIEW_RESOURCE_URI = 'ui://safe-docx/preview'; +export const PREVIEW_MIME_TYPE = 'text/html;profile=mcp-app'; + +type DispatchFn = ( + sessions: SessionManager, + name: string, + args: Record, +) => Promise>; + +type RegisterPreviewAppOptions = { + server: Server; + sessions: SessionManager; + coreTools: ReadonlyArray>; + coreDispatch: DispatchFn; +}; + +export function registerPreviewApp({ + server, + sessions, + coreTools, + coreDispatch, +}: RegisterPreviewAppOptions): void { + // Register resource capability (removed when this module is removed) + server.registerCapabilities({ resources: {} }); + + // Override ListTools to include the preview-only tool + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [...coreTools, GET_DOCUMENT_VIEW_TOOL], + })); + + // Override CallTool to handle get_document_view, delegating everything else + server.setRequestHandler(CallToolRequestSchema, async (req) => { + const { name } = req.params; + const args = (req.params.arguments ?? {}) as Record; + + const result = + name === GET_DOCUMENT_VIEW_TOOL.name + ? await getDocumentView(sessions, args as Parameters[1]) + : await coreDispatch(sessions, name, args); + + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }; + }); + + // ListResources handler + server.setRequestHandler(ListResourcesRequestSchema, async () => ({ + resources: [ + { + uri: PREVIEW_RESOURCE_URI, + name: 'Document Preview', + description: 'Interactive Word-like document preview with inline editing', + mimeType: PREVIEW_MIME_TYPE, + }, + ], + })); + + // ReadResource handler + server.setRequestHandler(ReadResourceRequestSchema, async (req) => { + const { uri } = req.params; + if (uri !== PREVIEW_RESOURCE_URI) { + throw new Error(`Unknown resource: ${uri}`); + } + return { + contents: [ + { + uri: PREVIEW_RESOURCE_URI, + mimeType: PREVIEW_MIME_TYPE, + text: getPreviewHtml(), + }, + ], + }; + }); +} diff --git a/packages/docx-mcp/src/app/preview-html.ts b/packages/docx-mcp/src/app/preview-html.ts new file mode 100644 index 0000000..b2028c7 --- /dev/null +++ b/packages/docx-mcp/src/app/preview-html.ts @@ -0,0 +1,14 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +let _cached: string | null = null; + +export function getPreviewHtml(): string { + if (_cached) return _cached; + _cached = readFileSync(join(__dirname, 'preview.html'), 'utf-8'); + return _cached; +} diff --git a/packages/docx-mcp/src/app/preview.html b/packages/docx-mcp/src/app/preview.html new file mode 100644 index 0000000..caa432e --- /dev/null +++ b/packages/docx-mcp/src/app/preview.html @@ -0,0 +1,501 @@ + + + + + +Document Preview + + + + +
+ Document Preview + Loading... + +
+ +
+
Loading document...
+
+ + + + diff --git a/packages/docx-mcp/src/app/preview.registration.test.ts b/packages/docx-mcp/src/app/preview.registration.test.ts new file mode 100644 index 0000000..9cfbf32 --- /dev/null +++ b/packages/docx-mcp/src/app/preview.registration.test.ts @@ -0,0 +1,134 @@ +import { describe, expect } from 'vitest'; + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; + +import { SessionManager } from '../session/manager.js'; +import { SAFE_DOCX_MCP_TOOLS } from '../tool_catalog.js'; +import { dispatchToolCall } from '../server.js'; +import { registerPreviewApp, PREVIEW_RESOURCE_URI, PREVIEW_MIME_TYPE } from './mcp-app-resources.js'; +import { testAllure, allureStep } from '../testing/allure-test.js'; +import { registerCleanup, openSession } from '../testing/session-test-utils.js'; + +async function createConnectedPair(opts: { withPreview: boolean }) { + const server = new Server( + { name: 'test-preview', version: '1.0.0' }, + { capabilities: { tools: {} } }, + ); + const sessions = new SessionManager(); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: SAFE_DOCX_MCP_TOOLS, + })); + + server.setRequestHandler(CallToolRequestSchema, async (req) => { + const { name } = req.params; + const args = (req.params.arguments ?? {}) as Record; + const result = await dispatchToolCall(sessions, name, args); + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + }); + + if (opts.withPreview) { + registerPreviewApp({ + server, + sessions, + coreTools: SAFE_DOCX_MCP_TOOLS, + coreDispatch: dispatchToolCall, + }); + } + + const client = new Client({ name: 'test-client', version: '1.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + return { server, client, sessions }; +} + +describe('registerPreviewApp integration', () => { + const test = testAllure.epic('Document Reading').withLabels({ feature: 'Preview Registration' }); + registerCleanup(); + + test('tools/list includes get_document_view when preview is registered', async () => { + const { client } = await createConnectedPair({ withPreview: true }); + + const { tools } = await allureStep('When listing tools via client', () => + client.listTools(), + ); + + await allureStep('Then get_document_view is present with _meta.ui', () => { + const names = tools.map((t) => t.name); + expect(names).toContain('get_document_view'); + + const tool = tools.find((t) => t.name === 'get_document_view')!; + expect(tool.annotations?.readOnlyHint).toBe(true); + }); + }); + + test('tools/list excludes get_document_view without preview', async () => { + const { client } = await createConnectedPair({ withPreview: false }); + + const { tools } = await allureStep('When listing tools via client', () => + client.listTools(), + ); + + await allureStep('Then get_document_view is absent', () => { + const names = tools.map((t) => t.name); + expect(names).not.toContain('get_document_view'); + }); + }); + + test('tools/call get_document_view succeeds for a real session', async () => { + const { client, sessions } = await createConnectedPair({ withPreview: true }); + const { sessionId } = await openSession(['Hello integration'], { mgr: sessions }); + + const result = await allureStep('When calling get_document_view via client', () => + client.callTool({ name: 'get_document_view', arguments: { session_id: sessionId } }), + ); + + await allureStep('Then result contains document nodes', () => { + const text = (result.content as Array<{ type: string; text: string }>)[0]!.text; + const parsed = JSON.parse(text); + expect(parsed.success).toBe(true); + expect(Array.isArray(parsed.nodes)).toBe(true); + expect(parsed.nodes.length).toBe(1); + expect(parsed.nodes[0].clean_text).toBe('Hello integration'); + }); + }); + + test('resources/list includes preview resource', async () => { + const { client } = await createConnectedPair({ withPreview: true }); + + const { resources } = await allureStep('When listing resources via client', () => + client.listResources(), + ); + + await allureStep('Then preview resource is present', () => { + expect(resources.length).toBe(1); + expect(resources[0]!.uri).toBe(PREVIEW_RESOURCE_URI); + expect(resources[0]!.mimeType).toBe(PREVIEW_MIME_TYPE); + }); + }); + + test('resources/read returns preview HTML', async () => { + const { client } = await createConnectedPair({ withPreview: true }); + + const result = await allureStep('When reading preview resource via client', () => + client.readResource({ uri: PREVIEW_RESOURCE_URI }), + ); + + await allureStep('Then HTML content is returned', () => { + expect(result.contents.length).toBe(1); + const content = result.contents[0]!; + expect(content.uri).toBe(PREVIEW_RESOURCE_URI); + expect(content.mimeType).toBe(PREVIEW_MIME_TYPE); + expect('text' in content).toBe(true); + const text = (content as { text: string }).text; + expect(text).toContain(''); + }); + }); +}); diff --git a/packages/docx-mcp/src/app/preview.test.ts b/packages/docx-mcp/src/app/preview.test.ts new file mode 100644 index 0000000..e2e01ed --- /dev/null +++ b/packages/docx-mcp/src/app/preview.test.ts @@ -0,0 +1,90 @@ +import { describe, expect } from 'vitest'; + +import { getDocumentView } from './get_document_view.js'; +import { PREVIEW_RESOURCE_URI, PREVIEW_MIME_TYPE } from './mcp-app-resources.js'; +import { GET_DOCUMENT_VIEW_TOOL } from './app_tools.js'; +import { testAllure, allureStep, allureJsonAttachment } from '../testing/allure-test.js'; +import { + assertSuccess, + openSession, + registerCleanup, +} from '../testing/session-test-utils.js'; +import { getPreviewHtml } from './preview-html.js'; + +describe('MCP App Preview', () => { + const test = testAllure.epic('Document Reading').withLabels({ feature: 'Document Preview' }); + registerCleanup(); + + test('get_document_view returns nodes, styles, session_id, and edit_revision', async () => { + const { mgr, sessionId } = await openSession(['Hello World', 'Second paragraph']); + + const result = await allureStep('When get_document_view is called', () => + getDocumentView(mgr, { session_id: sessionId }), + ); + assertSuccess(result, 'get_document_view'); + await allureJsonAttachment('result', result); + + await allureStep('Then response contains structured document view data', () => { + expect(result.session_id).toBe(sessionId); + expect(result.edit_revision).toBe(0); + expect(Array.isArray(result.nodes)).toBe(true); + expect(typeof result.styles).toBe('object'); + + const nodes = result.nodes as Array>; + expect(nodes.length).toBe(2); + expect(nodes[0]!.id).toBeTruthy(); + expect(nodes[0]!.clean_text).toBe('Hello World'); + expect(nodes[0]!.tagged_text).toBeTruthy(); + expect(nodes[1]!.clean_text).toBe('Second paragraph'); + }); + }); + + test('get_document_view works with file_path (auto-open)', async () => { + const { mgr, inputPath } = await openSession(['Test content']); + + const result = await allureStep('When get_document_view is called with file_path', () => + getDocumentView(mgr, { file_path: inputPath }), + ); + assertSuccess(result, 'get_document_view'); + + await allureStep('Then nodes contain the document content', () => { + const nodes = result.nodes as Array>; + expect(nodes.length).toBeGreaterThan(0); + expect(result.session_id).toBeTruthy(); + }); + }); + + test('get_document_view tool is registered with _meta.ui', async () => { + await allureStep('Then get_document_view tool has _meta.ui in catalog', () => { + expect(GET_DOCUMENT_VIEW_TOOL).toBeTruthy(); + expect(GET_DOCUMENT_VIEW_TOOL.annotations.readOnlyHint).toBe(true); + + const meta = GET_DOCUMENT_VIEW_TOOL._meta; + expect(meta).toBeTruthy(); + expect(meta.ui).toBeTruthy(); + expect(meta.ui.resourceUri).toBe('ui://safe-docx/preview'); + expect(meta.ui.visibility).toEqual(['app']); + }); + }); + + test('ListResources includes preview resource with correct URI and MIME type', async () => { + await allureStep('Then preview resource constants are correctly defined', () => { + expect(PREVIEW_RESOURCE_URI).toBe('ui://safe-docx/preview'); + expect(PREVIEW_MIME_TYPE).toBe('text/html;profile=mcp-app'); + }); + }); + + test('preview HTML resource returns valid HTML with mcp-app content', async () => { + const html = await allureStep('When getPreviewHtml is called', () => getPreviewHtml()); + + await allureStep('Then returned HTML is valid', () => { + expect(html).toBeTruthy(); + expect(html).toContain(''); + expect(html).toContain('Document Preview'); + expect(html).toContain('contentEditable'); + expect(html).toContain('callServerTool'); + expect(html).toContain('get_document_view'); + expect(html).toContain('replace_text'); + }); + }); +}); diff --git a/packages/docx-mcp/src/server.ts b/packages/docx-mcp/src/server.ts index ba84461..b9f37c2 100644 --- a/packages/docx-mcp/src/server.ts +++ b/packages/docx-mcp/src/server.ts @@ -27,6 +27,7 @@ import { deleteFootnote } from './tools/delete_footnote.js'; import { compareDocuments_tool } from './tools/compare_documents.js'; import { extractRevisions_tool } from './tools/extract_revisions.js'; import { clearFormatting } from './tools/clear_formatting.js'; +import { registerPreviewApp } from './app/mcp-app-resources.js'; export const MCP_TRANSPORT = 'stdio' as const; @@ -98,7 +99,7 @@ export async function dispatchToolCall( export async function runServer(): Promise { const server = new Server( - { name: 'safe-docx', version: '0.2.0' }, + { name: 'safe-docx', version: '0.3.0' }, { capabilities: { tools: {}, @@ -123,6 +124,9 @@ export async function runServer(): Promise { }; }); + // MCP Apps: preview app (delete src/app/ + these 2 lines to remove) + registerPreviewApp({ server, sessions, coreTools: MCP_TOOLS, coreDispatch: dispatchToolCall }); + const transport = new StdioServerTransport(); await server.connect(transport); }