Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/docx-mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
45 changes: 45 additions & 0 deletions packages/docx-mcp/src/app/app_tools.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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<string, unknown>;
}

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,
};
46 changes: 46 additions & 0 deletions packages/docx-mcp/src/app/get_document_view.ts
Original file line number Diff line number Diff line change
@@ -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<ToolResponse> {
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<string, unknown> = {};
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.');
}
}
87 changes: 87 additions & 0 deletions packages/docx-mcp/src/app/mcp-app-resources.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
) => Promise<Record<string, unknown>>;

type RegisterPreviewAppOptions = {
server: Server;
sessions: SessionManager;
coreTools: ReadonlyArray<Record<string, unknown>>;
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<string, unknown>;

const result =
name === GET_DOCUMENT_VIEW_TOOL.name
? await getDocumentView(sessions, args as Parameters<typeof getDocumentView>[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(),
},
],
};
});
}
14 changes: 14 additions & 0 deletions packages/docx-mcp/src/app/preview-html.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading