Skip to content

Commit 71c9f03

Browse files
committed
feat(relay,mcp): verify_after_edit MCP tool + soft-recommend prompts (RFC 0002 §B2, §B3)
Registers domscribe.verify.afterEdit as the 13th active MCP tool. (NOTE: PE memo's '13 → 14' assumed e8d452d underscore grammar was merged; on this base it is 12 → 13. Will become 13 → 14 + alias layer when e8d452d lands.) Tool input: { annotationId, postEditComponentStyles?, postEditBoundingRect?, screenshotRef? }. Output: structured VerifyResult with a nextStep hint — match/partial points at updateStatus, no_change/regression points at retry with the deltas inlined into the hint string. SOFT-RECOMMENDED, no lifecycle gate: - process-next.prompt now lists verify_after_edit as step 5 with an explicit 'NOT a lifecycle gate' callout. - annotation_respond's description + nextStep recommend verify before updateStatus. - annotation_update_status accepts PROCESSED with or without verify (asserted via integration test in the previous commit). Cardinal-rule unit test: even with a long screenshotRef, the serialized tool output stays under 2 KB and never matches /base64/i or /data:image/i.
1 parent 4585ced commit 71c9f03

11 files changed

Lines changed: 304 additions & 7 deletions

packages/domscribe-relay/src/mcp/__test-utils__/mock-relay-client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export function createMockRelayClient(
2020
processAnnotation: vi.fn(),
2121
updateAnnotationStatus: vi.fn(),
2222
updateAnnotationResponse: vi.fn(),
23+
verifyAnnotation: vi.fn(),
2324
createAnnotation: vi.fn(),
2425
deleteAnnotation: vi.fn(),
2526
patchAnnotation: vi.fn(),

packages/domscribe-relay/src/mcp/mcp-adapter.spec.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ describe('McpAdapter', () => {
6060

6161
// Assert
6262
const server = getServer(adapter);
63-
expect(server.registeredTools.size).toBe(12);
63+
// RFC 0002 added the verify_after_edit tool — the active count is 13.
64+
expect(server.registeredTools.size).toBe(13);
6465
expect(server.registeredTools.has('domscribe.resolve')).toBe(true);
6566
expect(server.registeredTools.has('domscribe.resolve.batch')).toBe(true);
6667
expect(server.registeredTools.has('domscribe.manifest.stats')).toBe(true);
@@ -83,6 +84,9 @@ describe('McpAdapter', () => {
8384
);
8485
expect(server.registeredTools.has('domscribe.status')).toBe(true);
8586
expect(server.registeredTools.has('domscribe.query.bySource')).toBe(true);
87+
expect(server.registeredTools.has('domscribe.verify.afterEdit')).toBe(
88+
true,
89+
);
8690
});
8791

8892
it('should register all 4 prompts', () => {

packages/domscribe-relay/src/mcp/mcp-adapter.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { AnnotationsRespondTool } from './tools/annotation-respond.tool.js';
2525
import { AnnotationsSearchTool } from './tools/annotation-search.tool.js';
2626
import { StatusTool } from './tools/status.tool.js';
2727
import { QueryBySourceTool } from './tools/query-by-source.tool.js';
28+
import { VerifyAfterEditTool } from './tools/verify-after-edit.tool.js';
2829

2930
// Prompt classes
3031
import { ProcessNextPrompt } from './prompts/process-next.prompt.js';
@@ -113,6 +114,7 @@ export class McpAdapter {
113114
new AnnotationsSearchTool(relayHttpClient),
114115
new StatusTool(relayHttpClient),
115116
new QueryBySourceTool(relayHttpClient),
117+
new VerifyAfterEditTool(relayHttpClient),
116118
];
117119

118120
for (const tool of tools) {

packages/domscribe-relay/src/mcp/prompts/process-next.prompt.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ If an annotation is found:
3030
2. Navigate to the source file and understand the context
3131
3. Implement the requested change
3232
4. Use domscribe.annotation.respond to store your response
33-
5. Use domscribe.annotation.updateStatus to mark it as 'processed'
33+
5. RECOMMENDED: call domscribe.verify.afterEdit with your post-edit ComponentStyles / boundingRect (and a screenshotRef when the overlay supplied one). The tool grades your edit against the pre-edit baseline and returns a verdict (match | partial | no_change | regression) plus per-axis deltas. If the verdict is no_change or regression, reconcile the deltas and retry your edit before moving on.
34+
6. Use domscribe.annotation.updateStatus to mark the annotation 'processed'. (NOTE: updateStatus does NOT require verify — it is a soft-recommended diagnostic, not a lifecycle gate.)
3435
3536
If no annotation is found, inform the user that the queue is empty.`,
3637
},

packages/domscribe-relay/src/mcp/prompts/prompts.spec.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ describe('ProcessNextPrompt', () => {
2525
expect(messages[0].content.text).toContain(
2626
'domscribe.annotation.updateStatus',
2727
);
28+
// RFC 0002: prompt should recommend verify_after_edit between respond
29+
// and updateStatus, but NOT gate the lifecycle on it.
30+
expect(messages[0].content.text).toContain('domscribe.verify.afterEdit');
31+
expect(messages[0].content.text).toMatch(/RECOMMENDED/i);
32+
expect(messages[0].content.text).toMatch(/not a lifecycle gate/i);
2833
});
2934
});
3035

packages/domscribe-relay/src/mcp/tools/annotation-respond.tool.spec.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,15 @@ describe('AnnotationsRespondTool', () => {
2828
'ann_123',
2929
'Changed button color to blue',
3030
);
31-
expect(result.structuredContent).toEqual({
31+
expect(result.structuredContent).toMatchObject({
3232
success: true,
3333
annotationId: 'ann_123',
34-
nextStep:
35-
'Call domscribe.annotation.updateStatus with annotationId "ann_123" and status "processed" to complete the lifecycle.',
3634
});
35+
const structured = result.structuredContent as { nextStep: string };
36+
expect(structured.nextStep).toContain('domscribe.verify.afterEdit');
37+
expect(structured.nextStep).toContain(
38+
'domscribe.annotation.updateStatus',
39+
);
3740
});
3841

3942
it('should default message to empty string when not provided', async () => {

packages/domscribe-relay/src/mcp/tools/annotation-respond.tool.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ export class AnnotationsRespondTool implements McpToolDefinition<
4343
description =
4444
"Store the agent's response to an annotation including explanation message and code patches. " +
4545
'Use after implementing changes to record what was done so users can review in the overlay. ' +
46-
'IMPORTANT: After calling this, you MUST call domscribe.annotation.updateStatus with status "processed" (or "failed") to complete the lifecycle.';
46+
'RECOMMENDED next step: call domscribe.verify.afterEdit to grade your edit against the pre-edit baseline (verdict + per-axis deltas you can reconcile on retry). ' +
47+
'IMPORTANT: After verify (or directly, if you skip verify), call domscribe.annotation.updateStatus with status "processed" (or "failed") to complete the lifecycle.';
4748
inputSchema = AnnotationsRespondToolInputSchema;
4849
outputSchema = AnnotationsRespondToolOutputSchema;
4950

@@ -60,7 +61,7 @@ export class AnnotationsRespondTool implements McpToolDefinition<
6061
success: response.success,
6162
annotationId: response.annotation.metadata.id,
6263
nextStep: response.success
63-
? `Call domscribe.annotation.updateStatus with annotationId "${response.annotation.metadata.id}" and status "processed" to complete the lifecycle.`
64+
? `RECOMMENDED: call domscribe.verify.afterEdit with annotationId "${response.annotation.metadata.id}" and your post-edit ComponentStyles / boundingRect to grade the edit. Then call domscribe.annotation.updateStatus with status "processed" to complete the lifecycle.`
6465
: undefined,
6566
};
6667

packages/domscribe-relay/src/mcp/tools/tool.defs.spec.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ describe('tool.defs', () => {
1818
expect(MCP_TOOLS.ANNOTATION_RESPOND).toBe('domscribe.annotation.respond');
1919
expect(MCP_TOOLS.ANNOTATION_SEARCH).toBe('domscribe.annotation.search');
2020
expect(MCP_TOOLS.STATUS).toBe('domscribe.status');
21+
expect(MCP_TOOLS.VERIFY_AFTER_EDIT).toBe('domscribe.verify.afterEdit');
22+
});
23+
24+
it('declares 13 active tools (RFC 0002 added verify_after_edit)', () => {
25+
expect(Object.keys(MCP_TOOLS)).toHaveLength(13);
2126
});
2227
});
2328

packages/domscribe-relay/src/mcp/tools/tool.defs.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export const MCP_TOOLS = {
2727
QUERY_BY_SOURCE: 'domscribe.query.bySource',
2828
// System tools
2929
STATUS: 'domscribe.status',
30+
// Verify tools (RFC 0002)
31+
VERIFY_AFTER_EDIT: 'domscribe.verify.afterEdit',
3032
} as const;
3133

3234
export type McpToolName = (typeof MCP_TOOLS)[keyof typeof MCP_TOOLS];
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
2+
import { VerifyAfterEditTool } from './verify-after-edit.tool.js';
3+
import { createMockRelayClient } from '../__test-utils__/mock-relay-client.js';
4+
import { MCP_TOOLS } from './tool.defs.js';
5+
6+
const annotationId = 'ann_A7bCd9Ef_1700000000000';
7+
8+
describe('VerifyAfterEditTool', () => {
9+
it('declares the verify_after_edit canonical name', () => {
10+
const tool = new VerifyAfterEditTool(createMockRelayClient());
11+
expect(tool.name).toBe(MCP_TOOLS.VERIFY_AFTER_EDIT);
12+
expect(tool.name).toBe('domscribe.verify.afterEdit');
13+
});
14+
15+
it('forwards componentStyles, boundingRect, and screenshotRef to the relay', async () => {
16+
const verifyAnnotation = vi.fn().mockResolvedValue({
17+
success: true,
18+
annotationId,
19+
result: {
20+
annotationId,
21+
verdict: 'match',
22+
pixelDiffRatio: 0,
23+
pixelDiffPixels: 0,
24+
componentStylesDelta: {},
25+
boundingRectDelta: {},
26+
screenshotRef: 'blob://post-edit/abc',
27+
capturedAt: '2025-01-01T00:00:00.000Z',
28+
},
29+
});
30+
const mockClient = createMockRelayClient({ verifyAnnotation });
31+
const tool = new VerifyAfterEditTool(mockClient);
32+
33+
await tool.toolCallback({
34+
annotationId,
35+
postEditComponentStyles: { computed: { color: 'rgb(0, 0, 0)' } },
36+
postEditBoundingRect: {
37+
x: 0,
38+
y: 0,
39+
width: 10,
40+
height: 10,
41+
top: 0,
42+
right: 10,
43+
bottom: 10,
44+
left: 0,
45+
},
46+
screenshotRef: 'blob://post-edit/abc',
47+
});
48+
49+
expect(verifyAnnotation).toHaveBeenCalledWith(annotationId, {
50+
componentStyles: { computed: { color: 'rgb(0, 0, 0)' } },
51+
boundingRect: {
52+
x: 0,
53+
y: 0,
54+
width: 10,
55+
height: 10,
56+
top: 0,
57+
right: 10,
58+
bottom: 10,
59+
left: 0,
60+
},
61+
screenshotRef: 'blob://post-edit/abc',
62+
});
63+
});
64+
65+
it('returns the relay verdict as structured content', async () => {
66+
const verifyAnnotation = vi.fn().mockResolvedValue({
67+
success: true,
68+
annotationId,
69+
result: {
70+
annotationId,
71+
verdict: 'partial',
72+
pixelDiffRatio: 0.005,
73+
pixelDiffPixels: 250,
74+
componentStylesDelta: { color: ['red', 'blue'] },
75+
boundingRectDelta: {},
76+
capturedAt: '2025-01-01T00:00:00.000Z',
77+
},
78+
});
79+
const tool = new VerifyAfterEditTool(
80+
createMockRelayClient({ verifyAnnotation }),
81+
);
82+
83+
const result: CallToolResult = await tool.toolCallback({ annotationId });
84+
85+
expect(result.structuredContent).toMatchObject({
86+
success: true,
87+
result: { verdict: 'partial' },
88+
});
89+
});
90+
91+
it('hints at retry when the verdict is regression or no_change', async () => {
92+
const verifyAnnotation = vi.fn().mockResolvedValue({
93+
success: true,
94+
annotationId,
95+
result: {
96+
annotationId,
97+
verdict: 'no_change',
98+
pixelDiffRatio: 0,
99+
pixelDiffPixels: 0,
100+
componentStylesDelta: {},
101+
boundingRectDelta: {},
102+
capturedAt: '2025-01-01T00:00:00.000Z',
103+
reason: 'edit did not land',
104+
},
105+
});
106+
const tool = new VerifyAfterEditTool(
107+
createMockRelayClient({ verifyAnnotation }),
108+
);
109+
110+
const result: CallToolResult = await tool.toolCallback({ annotationId });
111+
const structured = result.structuredContent as { nextStep: string };
112+
113+
expect(structured.nextStep).toMatch(/retry/i);
114+
expect(structured.nextStep).toContain('edit did not land');
115+
});
116+
117+
it('returns an MCP error result when the relay call throws', async () => {
118+
const verifyAnnotation = vi.fn().mockRejectedValue(new Error('relay down'));
119+
const tool = new VerifyAfterEditTool(
120+
createMockRelayClient({ verifyAnnotation }),
121+
);
122+
123+
const result: CallToolResult = await tool.toolCallback({ annotationId });
124+
125+
expect(result.isError).toBe(true);
126+
});
127+
128+
it('NEVER inlines screenshot bytes — the serialized tool output stays small even with a long screenshotRef', async () => {
129+
const longRef = 'blob://post-edit/' + 'x'.repeat(64);
130+
const verifyAnnotation = vi.fn().mockResolvedValue({
131+
success: true,
132+
annotationId,
133+
result: {
134+
annotationId,
135+
verdict: 'match',
136+
pixelDiffRatio: 0,
137+
pixelDiffPixels: 0,
138+
componentStylesDelta: {},
139+
boundingRectDelta: {},
140+
screenshotRef: longRef,
141+
capturedAt: '2025-01-01T00:00:00.000Z',
142+
},
143+
});
144+
const tool = new VerifyAfterEditTool(
145+
createMockRelayClient({ verifyAnnotation }),
146+
);
147+
148+
const result: CallToolResult = await tool.toolCallback({
149+
annotationId,
150+
screenshotRef: longRef,
151+
});
152+
const serialized = JSON.stringify(result.structuredContent);
153+
154+
expect(serialized).not.toMatch(/base64/i);
155+
expect(serialized).not.toMatch(/data:image/i);
156+
expect(serialized).toContain(longRef);
157+
expect(serialized.length).toBeLessThan(2048);
158+
});
159+
});

0 commit comments

Comments
 (0)