Skip to content
Open
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
40 changes: 40 additions & 0 deletions apps/mcp/src/__tests__/intent-param-renames.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'bun:test';
import { applyParamRenames } from '../tools/param-renames.js';

describe('applyParamRenames', () => {
it('renames id → commentId for comments.delete', () => {
expect(applyParamRenames('comments.delete', { id: 'c1' })).toEqual({ commentId: 'c1' });
});

it('renames id → commentId for comments.get', () => {
expect(applyParamRenames('comments.get', { id: 'c1' })).toEqual({ commentId: 'c1' });
});

it('renames id → commentId for comments.patch and preserves other fields', () => {
expect(applyParamRenames('comments.patch', { id: 'c1', text: 'updated', isInternal: true })).toEqual({
commentId: 'c1',
text: 'updated',
isInternal: true,
});
});

it('renames parentId → parentCommentId for comments.create', () => {
expect(applyParamRenames('comments.create', { text: 'reply', parentId: 'c1' })).toEqual({
text: 'reply',
parentCommentId: 'c1',
});
});

it('renames id → nodeId for getNodeById', () => {
expect(applyParamRenames('getNodeById', { id: 'n1' })).toEqual({ nodeId: 'n1' });
});

it('passes through unchanged for operations with no renames', () => {
expect(applyParamRenames('comments.list', { limit: 10 })).toEqual({ limit: 10 });
expect(applyParamRenames('getText', {})).toEqual({});
});

it('does not strip non-renamed keys when a rename map exists', () => {
expect(applyParamRenames('comments.create', { text: 'top-level' })).toEqual({ text: 'top-level' });
});
});
3 changes: 2 additions & 1 deletion apps/mcp/src/tools/intent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { SessionManager } from '../session-manager.js';
import type { DocumentApi, DynamicInvokeRequest } from '@superdoc/document-api';
import { MCP_TOOL_CATALOG } from '../generated/catalog.js';
import { dispatchIntentTool } from '../generated/intent-dispatch.generated.js';
import { applyParamRenames } from './param-renames.js';

// ---------------------------------------------------------------------------
// Types for the generated catalog
Expand Down Expand Up @@ -121,7 +122,7 @@ function buildZodSchema(tool: CatalogTool): Record<string, z.ZodTypeAny> {
function executeOperation(api: DocumentApi, operationId: string, input: Record<string, unknown>): unknown {
// Generated dispatch uses 'doc.' prefix (e.g. 'doc.query.match'); strip it for DocumentApi.invoke()
const opId = operationId.startsWith('doc.') ? operationId.slice(4) : operationId;
return api.invoke({ operationId: opId, input } as DynamicInvokeRequest);
return api.invoke({ operationId: opId, input: applyParamRenames(opId, input) } as DynamicInvokeRequest);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

input gets renamed (idcommentId) but the response goes back as-is, so MCP clients see commentId in the body even though their input uses id. probably fine since the agent reads it as text, but is the asymmetry intentional? worth a line in the PR body, or a follow-up if it should be inverted on the way out too.

}

// ---------------------------------------------------------------------------
Expand Down
37 changes: 37 additions & 0 deletions apps/mcp/src/tools/param-renames.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Wire → contract field-name renames for intent tool dispatch.
*
* The MCP wire schema (apps/mcp/src/generated/catalog.ts) is generated from
* apps/cli/src/cli/operation-params.ts, where PARAM_FLAG_OVERRIDES rewrites
* a handful of contract field names to shorter CLI flag names (e.g.
* `commentId` → `id`, `parentCommentId` → `parentId`). The same renamed
* names end up in the MCP wire schema. Without an inverse translation at
* dispatch time, the contract validator rejects the input because it expects
* the canonical field names.
*
* The CLI applies the inverse rename in extractInvokeInput()
* (apps/cli/src/lib/invoke-input.ts PARAM_RENAMES). This module is the MCP
* mirror — kept in lockstep by hand. Three operations are affected today
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

count's off (map has 5 entries) and the em-dash trips our style rule.

Suggested change
* mirror kept in lockstep by hand. Three operations are affected today
* mirror, kept in lockstep by hand. Five operations are affected today
* (comments.delete, comments.get, comments.patch, comments.create's
* parentId, and getNodeById's id), so duplication is small.

* (comments.delete, comments.get, comments.patch, plus comments.create's
* parentId and getNodeById's id), so duplication is small.
*
* Keys are bare operation IDs (no `doc.` prefix) to match the form
* executeOperation passes to api.invoke().
*/
const PARAM_RENAMES: Record<string, Record<string, string>> = {
getNodeById: { id: 'nodeId' },
'comments.create': { parentId: 'parentCommentId' },
'comments.patch': { id: 'commentId' },
'comments.delete': { id: 'commentId' },
'comments.get': { id: 'commentId' },
};

export function applyParamRenames(opId: string, input: Record<string, unknown>): Record<string, unknown> {
const renames = PARAM_RENAMES[opId];
if (!renames) return input;
const renamed: Record<string, unknown> = {};
for (const [key, value] of Object.entries(input)) {
renamed[renames[key] ?? key] = value;
}
return renamed;
}