Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export class FindAnnotationsPrompt implements McpPromptDefinition<
type: 'text',
text: `Search for Domscribe annotations.

Use the domscribe.annotations.search tool with these filters:
Use the domscribe.annotation.search tool with these filters:
${args.query ? `- query: "${args.query}"` : ''}
${args.file ? `- file: "${args.file}"` : ''}
${args.entryId ? `- entryId: "${args.entryId}"` : ''}
Expand Down
12 changes: 6 additions & 6 deletions packages/domscribe-relay/src/mcp/prompts/process-next.prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import {

const ProcessNextArgsSchema = {};

export class ProcessNextPrompt
implements McpPromptDefinition<typeof ProcessNextArgsSchema>
{
export class ProcessNextPrompt implements McpPromptDefinition<
typeof ProcessNextArgsSchema
> {
name = MCP_PROMPTS.PROCESS_NEXT;
description =
'Process the next queued UI annotation. Claims and processes one annotation from the queue.';
Expand All @@ -23,14 +23,14 @@ export class ProcessNextPrompt
type: 'text',
text: `Process the next queued Domscribe annotation.

Use the domscribe.annotations.process tool to claim the next annotation.
Use the domscribe.annotation.process tool to claim the next annotation.

If an annotation is found:
1. Read the userIntent and sourceLocation
2. Navigate to the source file and understand the context
3. Implement the requested change
4. Use domscribe.annotations.respond to store your response
5. Use domscribe.annotations.updateStatus to mark it as 'processed'
4. Use domscribe.annotation.respond to store your response
5. Use domscribe.annotation.updateStatus to mark it as 'processed'

If no annotation is found, inform the user that the queue is empty.`,
},
Expand Down
6 changes: 3 additions & 3 deletions packages/domscribe-relay/src/mcp/prompts/prompts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ describe('ProcessNextPrompt', () => {
expect(messages).toHaveLength(1);
expect(messages[0].role).toBe('user');
expect(messages[0].content.type).toBe('text');
expect(messages[0].content.text).toContain('domscribe.annotations.process');
expect(messages[0].content.text).toContain('domscribe.annotations.respond');
expect(messages[0].content.text).toContain('domscribe.annotation.process');
expect(messages[0].content.text).toContain('domscribe.annotation.respond');
expect(messages[0].content.text).toContain(
'domscribe.annotations.updateStatus',
'domscribe.annotation.updateStatus',
);
});
});
Expand Down
17 changes: 7 additions & 10 deletions packages/domscribe-relay/src/mcp/tools/annotation-list.tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,15 @@ type AnnotationsListToolOutput = z.infer<
typeof AnnotationsListToolOutputSchema
>;

export class AnnotationsListTool
implements
McpToolDefinition<
typeof AnnotationsListToolInputSchema,
typeof AnnotationsListToolOutputSchema
>
{
export class AnnotationsListTool implements McpToolDefinition<
typeof AnnotationsListToolInputSchema,
typeof AnnotationsListToolOutputSchema
> {
name = MCP_TOOLS.ANNOTATION_LIST;
description =
'List Domscribe annotations with optional status filtering and pagination. ' +
'Annotations are user-captured UI interactions awaiting or completed by agent processing. ' +
'Use to see pending work (status: queued), in-progress items, or review completed/failed tasks.';
'List Domscribe annotations for monitoring and review purposes. ' +
'To process the NEXT queued annotation, use domscribe.annotation.process instead — it atomically claims and returns full context in one call. ' +
'Use this tool only to browse queue state, check counts, or review history by status.';
inputSchema = AnnotationsListToolInputSchema;
outputSchema = AnnotationsListToolOutputSchema;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,13 @@ describe('AnnotationsProcessTool', () => {

// Assert
expect(mockClient.processAnnotation).toHaveBeenCalled();
expect(result.structuredContent).toEqual(processResponse);
expect(result.structuredContent).toEqual({
...processResponse,
nextStep:
'Implement the change described in userIntent. ' +
'Then call domscribe.query.bySource with the same file and line to verify your changes in the live browser. ' +
'Then call domscribe.annotation.respond with your summary, then domscribe.annotation.updateStatus with status "processed".',
});
});

it('should handle empty queue', async () => {
Expand Down
25 changes: 16 additions & 9 deletions packages/domscribe-relay/src/mcp/tools/annotation-process.tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,24 +63,26 @@ const AnnotationsProcessToolOutputSchema = McpToolOutputSchema.extend({
.unknown()
.optional()
.describe('Full annotation for advanced use cases'),
nextStep: z
.string()
.optional()
.describe('Workflow hint — what to do after this tool call'),
});

type AnnotationsProcessToolOutput = z.infer<
typeof AnnotationsProcessToolOutputSchema
>;

export class AnnotationsProcessTool
implements
McpToolDefinition<
typeof AnnotationsProcessToolInputSchema,
typeof AnnotationsProcessToolOutputSchema
>
{
export class AnnotationsProcessTool implements McpToolDefinition<
typeof AnnotationsProcessToolInputSchema,
typeof AnnotationsProcessToolOutputSchema
> {
name = MCP_TOOLS.ANNOTATION_PROCESS;
description =
'Atomically claim the next queued annotation for processing. ' +
'Claim the next queued annotation for processing (atomic — no annotation ID needed). ' +
'This is the correct tool for picking up work. Do NOT use annotation.list to manually pick annotations. ' +
'Returns the oldest queued annotation with full context including resolved source location. ' +
'Use when ready to process the next user request from the queue.';
'After implementing the change, call annotation.respond then annotation.updateStatus to complete the lifecycle.';
inputSchema = AnnotationsProcessToolInputSchema;
outputSchema = AnnotationsProcessToolOutputSchema;

Expand All @@ -100,6 +102,11 @@ export class AnnotationsProcessTool
sourceLocation: response.sourceLocation,
runtimeContext: response.runtimeContext,
fullAnnotation: response.fullAnnotation,
nextStep: response.found
? 'Implement the change described in userIntent. ' +
'Then call domscribe.query.bySource with the same file and line to verify your changes in the live browser. ' +
'Then call domscribe.annotation.respond with your summary, then domscribe.annotation.updateStatus with status "processed".'
: undefined,
};

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ describe('AnnotationsRespondTool', () => {
expect(result.structuredContent).toEqual({
success: true,
annotationId: 'ann_123',
nextStep:
'Call domscribe.annotation.updateStatus with annotationId "ann_123" and status "processed" to complete the lifecycle.',
});
});

Expand Down
21 changes: 13 additions & 8 deletions packages/domscribe-relay/src/mcp/tools/annotation-respond.tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,23 +25,25 @@ const AnnotationsRespondToolOutputSchema = McpToolOutputSchema.extend({
.string()
.optional()
.describe('The annotation ID that received the response'),
nextStep: z
.string()
.optional()
.describe('Workflow hint — what to do after this tool call'),
});

type AnnotationsRespondToolOutput = z.infer<
typeof AnnotationsRespondToolOutputSchema
>;

export class AnnotationsRespondTool
implements
McpToolDefinition<
typeof AnnotationsRespondToolInputSchema,
typeof AnnotationsRespondToolOutputSchema
>
{
export class AnnotationsRespondTool implements McpToolDefinition<
typeof AnnotationsRespondToolInputSchema,
typeof AnnotationsRespondToolOutputSchema
> {
name = MCP_TOOLS.ANNOTATION_RESPOND;
description =
"Store the agent's response to an annotation including explanation message and code patches. " +
'Use after implementing changes to record what was done so users can review in the overlay.';
'Use after implementing changes to record what was done so users can review in the overlay. ' +
'IMPORTANT: After calling this, you MUST call domscribe.annotation.updateStatus with status "processed" (or "failed") to complete the lifecycle.';
inputSchema = AnnotationsRespondToolInputSchema;
outputSchema = AnnotationsRespondToolOutputSchema;

Expand All @@ -57,6 +59,9 @@ export class AnnotationsRespondTool
const output: AnnotationsRespondToolOutput = {
success: response.success,
annotationId: response.annotation.metadata.id,
nextStep: response.success
? `Call domscribe.annotation.updateStatus with annotationId "${response.annotation.metadata.id}" and status "processed" to complete the lifecycle.`
: undefined,
};

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,15 @@ type AnnotationsUpdateStatusToolOutput = z.infer<
typeof AnnotationsUpdateStatusToolOutputSchema
>;

export class AnnotationsUpdateStatusTool
implements
McpToolDefinition<
typeof AnnotationsUpdateStatusToolInputSchema,
typeof AnnotationsUpdateStatusToolOutputSchema
>
{
export class AnnotationsUpdateStatusTool implements McpToolDefinition<
typeof AnnotationsUpdateStatusToolInputSchema,
typeof AnnotationsUpdateStatusToolOutputSchema
> {
name = MCP_TOOLS.ANNOTATION_UPDATE_STATUS;
description =
"Update an annotation's status in its lifecycle. " +
'Final step in the annotation lifecycle — call this AFTER domscribe.annotation.respond. ' +
'Valid transitions: queued→processing, processing→processed/failed, any→archived. ' +
'Use to mark work as complete (processed), failed with error details, or archived.';
'Mark as "processed" when done, "failed" with errorDetails if unable to implement, or "archived" to remove from queue.';
inputSchema = AnnotationsUpdateStatusToolInputSchema;
outputSchema = AnnotationsUpdateStatusToolOutputSchema;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ describe('QueryBySourceTool', () => {
},
browserConnected: true,
error: undefined,
hint: undefined,
});
expect(JSON.parse(getResultText(result))).toEqual(
result.structuredContent,
Expand Down Expand Up @@ -100,6 +101,10 @@ describe('QueryBySourceTool', () => {
runtime: undefined,
browserConnected: undefined,
error: undefined,
hint:
'No manifest entry found for this source location. ' +
'Try domscribe.manifest.query with the file path to discover which lines have entries, ' +
'or use tolerance > 0 to widen the search.',
});
});

Expand Down Expand Up @@ -129,6 +134,61 @@ describe('QueryBySourceTool', () => {
});
});

it('should return browser-not-connected hint when browserConnected is false', async () => {
// Arrange
const mockClient = createMockRelayClient({
queryBySource: vi.fn().mockResolvedValue({
found: true,
entryId: 'aB3dEf7h',
sourceLocation: {
file: 'src/components/Button.tsx',
start: { line: 10, column: 4 },
},
browserConnected: false,
}),
});
const tool = new QueryBySourceTool(mockClient);

// Act
const result: CallToolResult = await tool.toolCallback({
file: 'src/components/Button.tsx',
line: 10,
});

// Assert
const structured = result.structuredContent as Record<string, unknown>;
expect(structured['hint']).toContain('No browser is connected');
expect(structured['hint']).toContain('Ask the user');
});

it('should return not-rendered hint when element is not rendered', async () => {
// Arrange
const mockClient = createMockRelayClient({
queryBySource: vi.fn().mockResolvedValue({
found: true,
entryId: 'aB3dEf7h',
sourceLocation: {
file: 'src/components/Button.tsx',
start: { line: 10, column: 4 },
},
runtime: { rendered: false },
browserConnected: true,
}),
});
const tool = new QueryBySourceTool(mockClient);

// Act
const result: CallToolResult = await tool.toolCallback({
file: 'src/components/Button.tsx',
line: 10,
});

// Assert
const structured = result.structuredContent as Record<string, unknown>;
expect(structured['hint']).toContain('not currently rendered');
expect(structured['hint']).toContain('Ask the user to navigate');
});

it('should return MCP error result on exception', async () => {
// Arrange
const mockClient = createMockRelayClient({
Expand All @@ -155,7 +215,7 @@ describe('QueryBySourceTool', () => {
const tool = new QueryBySourceTool(createMockRelayClient());

expect(tool.name).toBe(MCP_TOOLS.QUERY_BY_SOURCE);
expect(tool.description).toContain('source file');
expect(tool.description).toContain('source location');
expect(tool.inputSchema).toBeDefined();
expect(tool.outputSchema).toBeDefined();
});
Expand Down
41 changes: 37 additions & 4 deletions packages/domscribe-relay/src/mcp/tools/query-by-source.tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ const QueryBySourceToolOutputSchema = McpToolOutputSchema.extend({
})
.optional(),
browserConnected: z.boolean().optional(),
hint: z
.string()
.optional()
.describe('Actionable guidance based on the result'),
});

type QueryBySourceToolOutput = z.infer<typeof QueryBySourceToolOutputSchema>;
Expand All @@ -75,15 +79,43 @@ export class QueryBySourceTool implements McpToolDefinition<
> {
name = MCP_TOOLS.QUERY_BY_SOURCE;
description =
"Query a live dev server by source file and position to get the element's " +
'manifest entry and live runtime context (props, state, DOM info). ' +
'Use when you have a source location and want to understand what the element ' +
'looks like at runtime. Returns manifest data even if the browser is not connected.';
'Get live DOM snapshot, component props, and state for a source location (file + line). ' +
'Call this BEFORE editing a UI component to understand its current rendered state, and AFTER editing to verify changes — ' +
'replaces the need for curl, Playwright, or browser screenshots. ' +
'If browserConnected is false, ask the user to navigate to the page in their browser and retry. ' +
'Returns manifest data even when the browser is not connected.';
inputSchema = QueryBySourceToolInputSchema;
outputSchema = QueryBySourceToolOutputSchema;

constructor(private readonly relayHttpClient: RelayHttpClient) {}

private buildHint(result: {
found: boolean;
browserConnected?: boolean;
runtime?: { rendered?: boolean };
}): string | undefined {
if (!result.found) {
return (
'No manifest entry found for this source location. ' +
'Try domscribe.manifest.query with the file path to discover which lines have entries, ' +
'or use tolerance > 0 to widen the search.'
);
}
if (result.browserConnected === false) {
return (
'No browser is connected — runtime data is unavailable. ' +
'Ask the user to open the page containing this component in their browser, then retry to get live props, state, and DOM snapshot.'
);
}
if (result.runtime && !result.runtime.rendered) {
return (
'The element exists in the manifest but is not currently rendered in the browser. ' +
'Ask the user to navigate to a page that renders this component, then retry.'
);
}
return undefined;
}

async toolCallback(input: QueryBySourceToolInput) {
try {
const result = await this.relayHttpClient.queryBySource(input);
Expand All @@ -95,6 +127,7 @@ export class QueryBySourceTool implements McpToolDefinition<
runtime: result.runtime,
browserConnected: result.browserConnected,
error: result.error,
hint: this.buildHint(result),
};

return {
Expand Down
Loading
Loading