From 4968ed9761e3079caced99a7376a8c7a77daa09f Mon Sep 17 00:00:00 2001 From: erKam Date: Thu, 15 Oct 2026 20:06:16 +0000 Subject: [PATCH 1/2] feat(mcp): add titles, inspector tags and structured output to agent tools MCP clients showed raw ids such as pangular:list-pages in their tool lists and approval prompts, and had to parse the text to read an answer. Give every built-in agent tool a short title, sent as the MCP title and annotations.title. Tag each tool with its inspector key from AGENT_INSPECTOR in the registration wrappers, so devframe's agent manifest can group them (devframe 1.2.5 does not send tags over MCP). Give the list-* and inspect-* tools and the Analog list tools an object outputSchema for their { markdown } answer, so devframe also sends it as structuredContent. The text content stays exactly as before. Source scan tools that return arrays and screenshot-component keep no output schema. --- apps/docs/src/content/agents/mcp-server.md | 14 + apps/docs/src/content/agents/tools.md | 2 + .../src/__tests__/tool-metadata.test.ts | 277 ++++++++++++++++++ packages/devtools/src/config.ts | 12 + packages/devtools/src/devframe.ts | 75 ++++- packages/devtools/src/rpc/agent-output.ts | 28 ++ packages/devtools/src/rpc/analog-register.ts | 17 ++ packages/devtools/src/rpc/audit-register.ts | 1 + packages/devtools/src/rpc/component-edits.ts | 2 + .../devtools/src/rpc/component-screenshots.ts | 1 + packages/devtools/src/rpc/errors-register.ts | 6 + .../devtools/src/rpc/injection-explain.ts | 1 + packages/devtools/src/rpc/leaks-register.ts | 2 + .../devtools/src/rpc/ngrx-session-tools.ts | 7 + packages/devtools/src/rpc/plugins-register.ts | 5 + packages/devtools/src/rpc/provider-values.ts | 1 + packages/devtools/src/rpc/query-register.ts | 6 + .../devtools/src/rpc/redux-stores-register.ts | 3 + .../devtools/src/rpc/ssr-override-tools.ts | 7 + packages/devtools/src/rpc/startup-register.ts | 1 + 20 files changed, 466 insertions(+), 2 deletions(-) create mode 100644 packages/devtools/src/__tests__/tool-metadata.test.ts create mode 100644 packages/devtools/src/rpc/agent-output.ts diff --git a/apps/docs/src/content/agents/mcp-server.md b/apps/docs/src/content/agents/mcp-server.md index 62b23e95..8adb5f84 100644 --- a/apps/docs/src/content/agents/mcp-server.md +++ b/apps/docs/src/content/agents/mcp-server.md @@ -245,6 +245,20 @@ The stdio server sends none. The HTTP endpoint needs devframe to pass the instru The server registers tools with a colon, as `pangular:get-routes`. MCP clients see them with an underscore, as `pangular_get-routes`. Calls with either form work. +### Titles and structured output + +Each tool also has a short title, such as **List connected pages** for `pangular_list-pages`. The server sends it as the tool's `title` and `annotations.title`, and clients show it in their tool lists and approval prompts. + +A tool answers with a JSON object, sent as text. Most tools answer `{ "markdown": "..." }`. The tools below also declare an `outputSchema` and send the same object as `structuredContent`, so a client can read the answer without parsing the text. The text is the same either way. + +| Tools | `structuredContent` | +| ----------------------------------------------------------------------------------------------------------- | ------------------------------ | +| Every tool whose name starts with `list-` or `inspect-` | `{ "markdown": "..." }` | +| `analog-routes`, `analog-api-routes`, `analog-server-functions`, `analog-server-calls` and `analog-content` | `{ "markdown": "..." }` | +| `build-meta` | The project and version fields | + +The other source scan tools answer with a JSON array, and `screenshot-component` with an image, so they send text and images only. + ### Read and action tools Each tool has a safety tier: `read`, `action` or `destructive`. The server marks `read` tools as read-only for your client, and `destructive` tools as destructive. Twenty tools act on the app, so the server does not mark them read-only: diff --git a/apps/docs/src/content/agents/tools.md b/apps/docs/src/content/agents/tools.md index 9e21fb01..d875f9c9 100644 --- a/apps/docs/src/content/agents/tools.md +++ b/apps/docs/src/content/agents/tools.md @@ -17,6 +17,8 @@ This page lists every tool the [MCP server](./mcp-server.md) exposes. Each group Tool ids use a colon, as `pangular:get-routes`. MCP clients see them with an underscore, as `pangular_get-routes`. The tables below drop the `pangular:` prefix. The `read` tools are also registered on the page under the same names, for [browser agents](./browser-agents.md). +Each tool has a short title that clients show in their tool lists, and the `list-` and `inspect-` tools also send their answer as structured content. See [Titles and structured output](./mcp-server.md#titles-and-structured-output). + ### Source and live tools Each tool reads from one of three places. The stdio server registers only the tools that read your source, because no page reaches it. diff --git a/packages/devtools/src/__tests__/tool-metadata.test.ts b/packages/devtools/src/__tests__/tool-metadata.test.ts new file mode 100644 index 00000000..ad54bfaa --- /dev/null +++ b/packages/devtools/src/__tests__/tool-metadata.test.ts @@ -0,0 +1,277 @@ +import { createHostContext } from 'devframe/node'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { PANGULAR_INSPECTORS, agentInspector, agentTags } from '../config.ts'; +import pangular from '../devframe.ts'; +import { initPangularHub } from '../hub.ts'; +import { MARKDOWN_OUTPUT_SCHEMA } from '../rpc/agent-output.ts'; +import { makeProject } from './analog-fixture.ts'; + +const closers: (() => Promise)[] = []; + +afterEach(async () => { + vi.restoreAllMocks(); + for (const close of closers.splice(0)) await close(); + delete (globalThis as { __PANGULAR_HUB__?: unknown }).__PANGULAR_HUB__; +}); + +async function manifest() { + const host = { + mountStatic: () => {}, + resolveOrigin: () => 'http://localhost', + getStorageDir: () => '', + }; + const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: host as never }); + await pangular.setup(ctx as never); + return ctx.agent.list().tools.filter((tool) => tool.id.startsWith('pangular:')); +} + +interface McpTool { + name: string; + title?: string; + outputSchema?: Record; + annotations?: { title?: string }; +} + +interface McpCallResult { + content: { type: string; text: string }[]; + structuredContent?: unknown; + isError?: boolean; +} + +/** A hub with its MCP endpoint, and a JSON-RPC client for it. */ +async function bootMcp() { + vi.spyOn(console, 'log').mockImplementation(() => {}); + const hub = initPangularHub({ + cwd: makeProject({ 'package.json': '{}' }), + ws: false, + auth: false, + allowedOrigins: false, + }); + closers.push(() => hub.close()); + await hub.ready; + const ctx = await hub.context; + let id = 0; + const call = async (method: string, params: object = {}): Promise => { + const response = await hub.handler( + new Request('http://localhost/__devframes/__mcp', { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + origin: 'http://localhost:4000', + }, + body: JSON.stringify({ jsonrpc: '2.0', id: ++id, method, params }), + }), + ); + const text = await response.text(); + const json = text.startsWith('{') ? text : /^data: (.*)$/m.exec(text)?.[1]; + return (JSON.parse(json ?? '{}') as { result: T }).result; + }; + await call('initialize', { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'test', version: '0' }, + }); + const tools = (await call<{ tools: McpTool[] }>('tools/list')).tools.filter((tool) => + tool.name.startsWith('pangular_'), + ); + const invoke = (name: string, args: Record = {}) => + call('tools/call', { name, arguments: args }); + const push = (name: string, payload: unknown) => + ctx.rpc.invokeLocal(`pangular:${name}` as never, ...([payload] as never)); + return { tools, invoke, push }; +} + +/** Checks a value against the JSON Schema keywords the output schemas use. */ +function schemaErrors(schema: Record, value: unknown, at = '$'): string[] { + const errors: string[] = []; + const type = schema['type']; + const actual = Array.isArray(value) + ? 'array' + : value === null + ? 'null' + : Number.isInteger(value) + ? 'integer' + : typeof value; + const types = Array.isArray(type) ? type : type === undefined ? [] : [type]; + if ( + types.length && + !types.includes(actual) && + !(actual === 'integer' && types.includes('number')) + ) { + return [`${at} is ${actual}, expected ${types.join(' or ')}`]; + } + if (Array.isArray(schema['enum']) && !schema['enum'].includes(value)) { + errors.push(`${at} is not one of the enum values`); + } + if (Array.isArray(schema['anyOf'])) { + const options = schema['anyOf'] as Record[]; + if (!options.some((option) => schemaErrors(option, value, at).length === 0)) { + errors.push(`${at} matches no anyOf option`); + } + } + if (actual === 'object' && value) { + const record = value as Record; + const properties = (schema['properties'] ?? {}) as Record>; + for (const key of (schema['required'] ?? []) as string[]) { + if (!(key in record)) errors.push(`${at}.${key} is missing`); + } + for (const [key, item] of Object.entries(record)) { + if (properties[key]) errors.push(...schemaErrors(properties[key], item, `${at}.${key}`)); + else if (schema['additionalProperties'] === false) errors.push(`${at}.${key} is not allowed`); + } + } + if (actual === 'array' && schema['items']) { + (value as unknown[]).forEach((item, index) => + errors.push( + ...schemaErrors(schema['items'] as Record, item, `${at}[${index}]`), + ), + ); + } + return errors; +} + +/** What `tools/call` sent as text before the tools had titles and output schemas. */ +const TEXT_BEFORE = { + 'pangular_list-pages': JSON.stringify( + { + markdown: + 'No page is reporting. Live data needs a page: connect through the MCP endpoint of the server that runs the app, with the app open in a browser.', + }, + null, + 2, + ), + 'pangular_list-components': JSON.stringify( + { + markdown: + 'No component tree has been reported. This is what a page that has never connected reports, and also what a connected page reports when its components are not readable. Live data needs a page: connect through the MCP endpoint of the server that runs the app, with the app open in a browser. The stdio server has no page attached and only ever reports this.', + }, + null, + 2, + ), + 'pangular_get-routes': '[]', +}; + +describe('agent tool titles', () => { + it('gives every built-in tool a short title of its own', async () => { + const tools = (await manifest()).filter((tool) => !tool.id.startsWith('pangular:plugin:')); + expect(tools.length).toBeGreaterThan(80); + const seen = new Map(); + for (const tool of tools) { + expect(tool.title, `${tool.id} has no title`).not.toBe(tool.id); + expect(tool.title.length, `${tool.id}: "${tool.title}"`).toBeLessThanOrEqual(40); + expect(tool.title, tool.id).not.toMatch(/pangular/i); + expect(tool.title, tool.id).toMatch(/^[A-Z]/); + expect(seen.get(tool.title), `"${tool.title}" is used twice`).toBeUndefined(); + seen.set(tool.title, tool.id); + } + }); + + it('sends the title as the MCP title and the title annotation', async () => { + const { tools } = await bootMcp(); + for (const tool of tools) { + expect(tool.title, tool.name).toBeTruthy(); + expect(tool.title, tool.name).not.toMatch(/^pangular[:_]/); + expect(tool.annotations?.title, tool.name).toBe(tool.title); + } + }); +}); + +describe('agent tool tags', () => { + it('tags each tool with its inspector and leaves shared tools untagged', async () => { + for (const tool of await manifest()) { + const inspector = agentInspector(tool.id); + if (tool.id === 'pangular:clear-recordings') { + expect(tool.tags).toEqual(['http', 'router', 'analog']); + } else if (inspector) { + expect(tool.tags, tool.id).toEqual([inspector]); + } else { + expect(tool.tags, tool.id).toBeUndefined(); + } + for (const tag of tool.tags ?? []) expect(PANGULAR_INSPECTORS).toContain(tag); + } + }); + + it('tags plugin tools as plugins', () => { + expect(agentTags('pangular:plugin:cart:totals')).toEqual(['plugins']); + expect(agentTags('pangular:list-pages')).toBeUndefined(); + }); +}); + +describe('agent tool output schemas', () => { + it('advertises only object output schemas', async () => { + const { tools } = await bootMcp(); + const withSchema = tools.filter((tool) => tool.outputSchema); + // The set the MCP server docs list under "Titles and structured output". + const documented = (name: string) => + /^pangular_(list|inspect)-/.test(name) || + [ + 'pangular_analog-routes', + 'pangular_analog-api-routes', + 'pangular_analog-server-functions', + 'pangular_analog-server-calls', + 'pangular_analog-content', + 'pangular_build-meta', + ].includes(name); + expect(withSchema.map((tool) => tool.name).sort()).toEqual( + tools + .map((tool) => tool.name) + .filter(documented) + .sort(), + ); + expect(withSchema.length).toBeGreaterThan(25); + for (const tool of withSchema) expect(tool.outputSchema?.['type'], tool.name).toBe('object'); + // Array answers and image answers carry no structured content, so they advertise no schema. + for (const name of [ + 'pangular_get-routes', + 'pangular_get-components', + 'pangular_screenshot-component', + ]) { + expect(tools.find((tool) => tool.name === name)?.outputSchema, name).toBeUndefined(); + } + }); + + it('returns structured content that matches the schema and the text', async () => { + const { tools, invoke, push } = await bootMcp(); + const withSchema = tools.filter((tool) => tool.outputSchema); + const check = async (tool: McpTool, args: Record) => { + const result = await invoke(tool.name, args); + expect(result.isError, `${tool.name} ${JSON.stringify(args)}`).toBeFalsy(); + expect(result.structuredContent, tool.name).toBeDefined(); + expect(schemaErrors(tool.outputSchema!, result.structuredContent), tool.name).toEqual([]); + expect(result.content).toHaveLength(1); + expect(JSON.parse(result.content[0]!.text), tool.name).toEqual(result.structuredContent); + }; + + // No page connected. + for (const tool of withSchema) await check(tool, {}); + + // One page reporting, asked about it and about a page that is not there. + await push('push-component-tree', { + pageId: 'p1', + roots: [{ id: 'c1', name: 'App', tag: 'app-root', children: [] }], + count: 1, + detail: null, + }); + for (const tool of withSchema) { + await check(tool, { selector: 'App', page: 'p1' }); + await check(tool, { selector: 'App', page: 'gone' }); + } + }); + + it('keeps the markdown schema to one string field', () => { + expect(MARKDOWN_OUTPUT_SCHEMA).toMatchObject({ + type: 'object', + properties: { markdown: { type: 'string' } }, + required: ['markdown'], + }); + }); + + it('leaves the text content as it was', async () => { + const { invoke } = await bootMcp(); + for (const [name, text] of Object.entries(TEXT_BEFORE)) { + const result = await invoke(name); + expect(result.content, name).toEqual([{ type: 'text', text }]); + } + }); +}); diff --git a/packages/devtools/src/config.ts b/packages/devtools/src/config.ts index d0cac836..b8acedd9 100644 --- a/packages/devtools/src/config.ts +++ b/packages/devtools/src/config.ts @@ -1402,6 +1402,18 @@ export function agentInspector(id: string): PangularInspector | undefined { ); } +/** + * The tags of an agent tool: the inspector it belongs to, by the same key as + * `AGENT_INSPECTOR`. `clear-recordings` erases what the HTTP, router and + * Analog inspectors record, so it carries all three. Shared tools have none. + * Devframe keeps them in its agent manifest; MCP `tools/list` does not send them. + */ +export function agentTags(id: string): readonly PangularInspector[] | undefined { + if (agentName(id) === 'clear-recordings') return ['http', 'router', 'analog']; + const inspector = agentInspector(id); + return inspector ? [inspector] : undefined; +} + export function rpcAllowed(name: string, config: ResolvedPangularConfig): boolean { const inspector = RPC_INSPECTOR[name]; return !inspector || config.inspectors[inspector]; diff --git a/packages/devtools/src/devframe.ts b/packages/devtools/src/devframe.ts index 1e9caf41..c51fd5fc 100644 --- a/packages/devtools/src/devframe.ts +++ b/packages/devtools/src/devframe.ts @@ -257,6 +257,7 @@ import { actionBlockedMessage, agentAllowed, agentSafety, + agentTags, angularUnavailableText, enabledPanelTabs, isPageAgentEntry, @@ -295,6 +296,7 @@ import { FORMS_SHAPE, INJECTOR_TREE_SHAPE, } from './push-delta.ts'; +import { MARKDOWN_OUTPUT_SCHEMA } from './rpc/agent-output.ts'; type CapacitorToolArgs = { page?: string; @@ -347,18 +349,22 @@ const pangular = defineDevframe({ const register: typeof my.rpc.register = (definition) => { if (!rpcAllowed(definition.name, config)) return; const exposed = !definition.agent || agentAllowed({ id: definition.name }, config); - my.rpc.register(exposed ? definition : { ...definition, agent: undefined }); + if (!exposed) return my.rpc.register({ ...definition, agent: undefined }); + const agent = definition.agent && { ...definition.agent, tags: agentTags(definition.name) }; + my.rpc.register(agent ? { ...definition, agent } : definition); }; // Agent writes, NG errors and hydration mismatches, posted to the hub's messages. const feed = createHubFeed(ctx, config); const agent = { registerTool: (tool: Parameters[0]) => { - // The tier comes from AGENT_SAFETY, so the config is the one place that sets it. + // The tier comes from AGENT_SAFETY and the tags from AGENT_INSPECTOR, so + // the config is the one place that sets them. if (agentExposed(tool)) ctx.agent.registerTool( feed.wrapTool({ ...tool, safety: agentSafety(tool.id, config), + tags: agentTags(tool.id), handler: leased(() => interest, tool.handler), }), ); @@ -840,6 +846,7 @@ const pangular = defineDevframe({ feed.wrapTool({ ...(tool as Parameters[0]), safety, + tags: agentTags(String(tool['id'])), handler: leased(() => interest, tool['handler']), }), ), @@ -1352,6 +1359,8 @@ const pangular = defineDevframe({ const httpSnapshot = () => httpState.value() as HttpState; agent.registerTool({ id: 'pangular:list-ssr-requests', + title: 'List SSR requests', + outputSchema: MARKDOWN_OUTPUT_SCHEMA, description: 'Recent document requests that the Angular SSR server answered, traced by `devtools.ssrMiddleware` or `withPangularSsr()`: id, URL, status, render mode read from the HTML (Server, Prerender, Client), render time, how many server HttpClient calls the render made, and whether a connected page reported loading that response. Empty when neither is installed.', inputSchema: { @@ -1368,6 +1377,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:explain-ssr-request', + title: 'Explain an SSR request', description: 'One SSR request from start to finish: status, render mode, render and fetch times, kept response headers, each server HttpClient call, then the browser page that loaded the response with its hydration result and any call the browser made again instead of reading the transfer cache. Picks the request by `id` (from list-ssr-requests), else the newest one for `url`, else the newest one.', inputSchema: { @@ -1390,6 +1400,8 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:list-http-calls', + title: 'List HTTP calls', + outputSchema: MARKDOWN_OUTPUT_SCHEMA, description: "The app's HttpClient calls recorded by `withPangular()`, newest first: side (SSR or client), method, URL, status, duration and flags (mocked or faulted by a rule, cancelled, transfer cache hit or skip, the matched rule). Without `page` it lists every reporting tab and the server; with `page` only that tab and the server render that served it. Secrets in URLs, errors, previews and headers are masked; `headers` adds each call's request and response headers. Empty when no page uses the interceptor.", inputSchema: { @@ -1427,6 +1439,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:http-waterfall', + title: 'Show an HTTP waterfall', description: "One page's client HttpClient calls as a waterfall, grouped by navigation (a new group each time the page URL changes), newest navigation first: each call's start offset in its navigation, duration, status and a text bar. With `headers`, each call's request headers (as they reached `withPangular()`) and the response headers the app could read; `Authorization`, cookies, secret-looking names, JWTs and bearer tokens are masked. Empty when no page uses the interceptor.", inputSchema: { @@ -1450,6 +1463,8 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:list-capacitor-calls', + title: 'List Capacitor plugin calls', + outputSchema: MARKDOWN_OUTPUT_SCHEMA, description: 'List Capacitor plugin calls made by the app, newest first: plugin, method, bridge target (native vs web), arguments, result/error, and duration. Also includes active listeners and event counts. Filter by `plugin`, `failed` calls, or `slowMs`. Calls a web implementation answers are logged only when `@pangular-inspector/devtools/capacitor-hook` loads before @capacitor/core. Arguments, results, and errors are masked for privacy. Empty when no Capacitor app reports.', inputSchema: { @@ -1472,6 +1487,8 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:list-capacitor-plugins', + title: 'List Capacitor plugins', + outputSchema: MARKDOWN_OUTPUT_SCHEMA, description: 'The Capacitor environment of the app and its plugins: platform, server URL, whether CapacitorHttp and native logging are on, the WebView user agent, and each plugin with whether it is available on this platform, imported by the app, installed natively, and its native methods. Flags plugins the app imports that are not available (run `npx cap sync`) and native plugins the app does not import. Empty when no Capacitor app reports.', inputSchema: { type: 'object', properties: { page: PAGE_ARGUMENT } }, @@ -1512,6 +1529,8 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:list-http-rules', + title: 'List HTTP mock rules', + outputSchema: MARKDOWN_OUTPUT_SCHEMA, description: 'The HTTP fault and mock rules the panel or an agent set, in match order: id, on or off, method, URL pattern, whether it applies to browser calls, SSR calls or both, and its effect (fail with a status, answer with a status and mock body, delay). Rules apply to every connected tab and to the server. Patterns and bodies are masked like recorded calls. Empty when no rule is set.', inputSchema: { type: 'object', properties: {} }, @@ -1522,6 +1541,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:set-http-rule', + title: 'Set an HTTP mock rule', description: "Adds an HTTP fault or mock rule, or replaces the rule with `id`, the same way the panel's Fault injection form does (development only). `pattern` plus at least one of `status`, `delayMs` or `body`. The rule applies to every connected tab right away and to SSR on the next page load. Returns the saved rule list. Needs the HTTP write action (actions.http).", inputSchema: { type: 'object', properties: ruleInput, required: ['pattern'] }, @@ -1534,6 +1554,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:remove-http-rule', + title: 'Remove an HTTP mock rule', description: 'Removes the HTTP fault or mock rule with `id` (from list-http-rules) and returns the rules left. Needs the HTTP write action (actions.http).', inputSchema: { @@ -1547,6 +1568,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:clear-recordings', + title: 'Clear recordings', description: 'Clears recorded HTTP calls, router navigations, or Analog server calls between reproduction runs (development only). Specify `target` as "http", "router", "analog", or "all". An optional `page` clears only the navigations of that specific page.', inputSchema: { @@ -2216,6 +2238,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:watch-signal', + title: 'Watch a signal', description: 'Watch a signal on the running page and toggle write stack recording. Watched signals log changes to the console; pangular:inspect-signals includes them in `watch`. When write stacks are enabled, exact history entries include the top app call frames. Pass `node` (id or label) to watch, `watch: false` to unwatch, or `stacks: true|false` to toggle stack traces. Resets on reload. Uses `page` or the most recent page with a signal graph.', inputSchema: { @@ -2598,6 +2621,8 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:inspect-signal-store', + title: 'Inspect a signal store', + outputSchema: MARKDOWN_OUTPUT_SCHEMA, description: INSPECT_SIGNAL_STORE_DESCRIPTION, inputSchema: { type: 'object', @@ -2619,6 +2644,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:signal-store-history', + title: 'Show signal store history', description: SIGNAL_STORE_HISTORY_DESCRIPTION, inputSchema: { type: 'object', @@ -2669,6 +2695,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:highlight', + title: 'Highlight a component', description: 'Highlight a component in the running Angular app and select it: the pangular:component-tree resource then carries its live `detail` and pangular:inspect-signals targets it. Pass an instance id from the pangular:component-tree resource (targets that exact instance, e.g. the second card of a list), a class name, a host tag, or any CSS selector. A class name or tag resolves on the most recent page unless `page` names another tab. When it matches several instances, it picks the first and lists the ids of all of them.', inputSchema: { @@ -2733,6 +2760,8 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:inspect-component', + title: 'Inspect a component', + outputSchema: MARKDOWN_OUTPUT_SCHEMA, description: 'Get live details of a component instance: inputs, outputs, properties (signals unwrapped), listeners, host directives, DI dependencies, change detection, and source location. Pass `selector` (instance id, class name, or host tag); without `page` it searches every page. It selects the instance on its page (the panel follows); several matches answer for the first and list the other ids. Fields carry `edit` (for pangular:set-component-property or pangular:set-signal) or `locked`. `path` reads a nested value without changing the selection. Secrets are redacted; cut past 20,000 characters.', inputSchema: { @@ -2832,6 +2861,8 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:list-components', + title: 'List live components', + outputSchema: MARKDOWN_OUTPUT_SCHEMA, description: LIST_COMPONENTS_DESCRIPTION, inputSchema: { type: 'object', @@ -2874,6 +2905,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:defer-blocks', + title: 'List @defer blocks', description: 'List live `@defer` blocks rendered on the page: owning component, state (placeholder, loading, complete, error), triggers, and incremental hydration status. Flags blocks needing attention (failed loads, stuck placeholders, dehydrated). Lists every page unless `page` names one. Requires an Angular development build with debug APIs enabled.', inputSchema: { @@ -2903,6 +2935,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:for-blocks', + title: 'List @for blocks', description: 'List live `@for` blocks rendered on the running page (Angular 22+ dev builds): owning component, item count, `track` expression target (`$index`, item, or expression), and `@empty` block status. Flags empty blocks lacking `@empty`. Lists every page unless `page` names one. Returns up to 50 blocks per page; results cut at 20,000 chars.', inputSchema: { @@ -2933,6 +2966,7 @@ const pangular = defineDevframe({ const hydrationOverlayAllowed = !config.agent.readOnly; agent.registerTool({ id: 'pangular:hydration', + title: 'Check hydration', description: `${HYDRATION_DESCRIPTION}${ hydrationOverlayAllowed ? HYDRATION_OVERLAY_DESCRIPTION @@ -2990,6 +3024,7 @@ const pangular = defineDevframe({ : 'Recording is off until the panel starts it: under agent.readOnly this tool cannot record or turn on views.'; agent.registerTool({ id: 'pangular:change-detection', + title: 'Record change detection', description: `Inspect change detection cycles from Angular's profiler (Angular 20+, dev build): slowest components, check counts, cycle durations, hooks and outputs, and the mode (zoneless or zone.js). ${cdActionDesc} \`export: "json"\` returns the panel file, \`"trace"\` a Perfetto trace (only counts past 100,000 characters). Uses \`page\` or the page that is recording.`, inputSchema: { type: 'object', @@ -3125,6 +3160,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:diagnose-change-detection', + title: 'Diagnose change detection', description: 'Why change detection ran and what it wasted, from the recording pangular:change-detection or the panel started (Angular 20+, dev build). Lists what started each cycle (DOM event, signal write, HTTP response, timer, markForCheck, setInput, effect, an app `ApplicationRef.tick()`, or a zone.js tick nothing asked for), components that change only in zone.js ticks, checks that changed nothing, inputs mutated in place or given an equal new reference, and OnPush hints. `limit` sets rows per list. Uses `page` or the page that is recording. Only reads. Empty until a recording ran.', inputSchema: { @@ -3156,6 +3192,8 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:inspect-signals', + title: 'Inspect signals', + outputSchema: MARKDOWN_OUTPUT_SCHEMA, description: "Get the signal graph the running page last reported: signal nodes (signal, computed, linkedSignal, effect), dependency edges, resources, and value history. Only signals a template or an effect has read appear. `selector` names a component (tag, class, id), `root` for root service effects, or a route path. The page reports one component graph: for another component, call pangular:highlight first, or you get the graph available. Use `node` for a single node with its direct producers and consumers; `framework: false` excludes Angular's own nodes; `history: false` omits history.", inputSchema: { @@ -3267,6 +3305,8 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:inspect-providers', + title: 'Inspect injectors and providers', + outputSchema: MARKDOWN_OUTPUT_SCHEMA, description: 'Get dependency injection injectors from a running page. Without arguments, returns the injector tree (element and environment injectors with providers). Use `selector` to filter by component tag, class, or environment injector (e.g. Root, Platform, Route: admin). Use `token` to find where a service or token is provided and injected. Supports `page` selection across multiple tabs.', inputSchema: { @@ -3337,6 +3377,8 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:inspect-route', + title: 'Inspect the active route', + outputSchema: MARKDOWN_OUTPUT_SCHEMA, description: 'Inspect current route state on the running page: URL, query params, fragment, document title, in-flight navigation, active route tree (component, params, data, guards, resolvers), and outlet tree. Pass `selector` (component class, tag, or link text) to inspect the route for a specific component or check if a link is active. Secret values are redacted.', inputSchema: { @@ -3359,6 +3401,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:explain-navigation', + title: 'Explain a navigation', description: 'Detailed breakdown of recent navigations, newest first: source trigger, redirect chains, redirect loops, phase timing, guards, resolvers, lazy loads, HTTP calls, and cancellation/error explanations with NG0 error details. Filter with `url` or `id`. Set `perf: true` to summarize slowest navigations and preloads.', inputSchema: { @@ -3397,6 +3440,8 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:list-routes', + title: 'List live routes', + outputSchema: MARKDOWN_OUTPUT_SCHEMA, description: "The router's live route config (not just the active route): every route with its full path, component or redirect, lazy state, outlet, guards, resolvers, title, the source file it is declared in and an example URL, with the active routes marked. Pass `match` to predict which route a URL matches (or the nearest routes when it matches none), `audit` for the guards protecting each page, or `filter` to narrow by path or component.", inputSchema: { @@ -3434,6 +3479,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:lint-routes', + title: 'Lint routes', description: "Checks the live route config for common issues: routes after '**', shadowed param routes, missing pathMatch 'full' on empty paths, runtime redirect loops, deprecated guards, title issues, and security flags (e.g. emails or open redirects). Findings indicate severity (throws, warns, or silent) and remediation steps.", inputSchema: { type: 'object', properties: { page: pageProperty } }, @@ -3446,6 +3492,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:router-config', + title: 'Show the router setup', description: 'How the router is set up on the running page: provideRouter, forRoot or other, or unknown when the build cannot tell, Angular version, effective options with which are set and which are defaults (onSameUrlNavigation, paramsInheritanceStrategy, urlUpdateStrategy, canceledNavigationResolution, scrolling, initial navigation), enabled features (input binding, view transitions, error handler, preloading strategy, scroller, resources), strategies (location, title, reuse, URL handling), base href, hydration and whether per-guard instrumentation is on.', inputSchema: { type: 'object', properties: { page: pageProperty } }, @@ -3458,6 +3505,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:export-navigation', + title: 'Export a navigation', description: 'A markdown repro for one navigation (default: the latest that did not succeed): Angular version, router options and features, how it started, the full redirect chain with every detail from explain-navigation, any redirect loop it is part of, and the relevant slice of the route config. Secret-looking values stay redacted.', inputSchema: { @@ -3473,6 +3521,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:explain-render-mode', + title: "Explain a URL's render mode", description: "Which ServerRoute (from the workspace's *.routes.server.ts) and render mode (Server, Client, Prerender) a URL gets, plus server entries that match no client route and the render mode of every client route.", inputSchema: { @@ -3496,6 +3545,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:navigate', + title: 'Navigate the app', description: 'Performs router actions in the running app (dev only). Actions: `navigate` (navigates to `url` or `pattern` with `params`), `abort` (cancels in-flight navigation), `replay` (re-runs navigation by `id`), `probe` (runs the matcher without navigating; runs canMatch guards and may load lazy chunks), `instrument` (toggles guard/resolver logging), and `resolve-lazy` (inspects lazy routes). Actions `navigate`, `abort`, `replay`, and `probe` require `actions.router` permission.', inputSchema: { @@ -3581,6 +3631,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:dispatch-ngrx-action', + title: 'Dispatch an NgRx action', description: 'Dispatches an action to the @ngrx/store Store in the running app (dev only) and returns the action log entry with origin and state diff. Pass `type` with optional `payload`, or `seq` to redispatch an action from the log. Without `page` it uses the one page with a Store; with several, it dispatches nothing and lists them. Requires `actions.ngrx` write permission.', inputSchema: { @@ -3657,6 +3708,8 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:inspect-forms', + title: 'Inspect forms', + outputSchema: MARKDOWN_OUTPUT_SCHEMA, description: 'Inspect forms on the running page (Signal Forms, reactive, and template-driven). Without arguments, lists all forms with status and error counts. Pass `form` for the full field tree (values, status, touched/dirty, errors, webMcp tool status). Filter using `path`, `onlyInvalid`, or `includeValues`. Secret values are redacted; large results are truncated past 20,000 characters.', inputSchema: { @@ -3694,6 +3747,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:explain-form-invalid', + title: 'Explain invalid forms', description: 'Explain why forms on the running page are invalid: each failing field with its current value, the validator that failed, its message and whether it was touched, plus fields waiting on async validators and disabled reasons. Without `form` it covers every form that is invalid or waiting on async validation. Lists at most 100 problems per form; pass `path` to explain one group.', inputSchema: { @@ -3742,6 +3796,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:explain-field', + title: 'Explain a form field', description: 'Explain one form field: value, flags, every error with where it comes from (validator, template attribute, cross-field rule and which ancestor, async, parse, server/submission, setErrors), why validation is skipped (hidden, disabled, readonly), inherited disabled reasons, uncommitted or debounced input, stale validity, rules and validator names, the binding (accessor or [formField]) and DOM facts (label, visible error text, drift). Pass `selector` instead of form/path to start from a CSS selector.', inputSchema: { @@ -3781,6 +3836,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:explain-submit', + title: 'Explain a form submit', description: 'Explain what submitting a form will do and why it might do nothing: Signal Forms submit() dry-run (action present, ignoreValidators, already submitting), ngSubmit semantics for reactive and template forms, DOM reasons (no submit button, type="button", disabled button, directive not on a
, native validation), blocking and pending fields, and recent submits with their outcome (ran, blocked, threw).', inputSchema: { type: 'object', properties: { form: formProperty, page: formPageProperty } }, @@ -3789,6 +3845,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:form-payload', + title: 'Show a form payload', description: 'Show what a form would send: form.value vs getRawValue() with the disabled fields form.value drops (reactive), or the hidden/disabled/readonly fields Signal Forms keeps in the value without validating them, plus which fields the user changed. Each value is cut at 8,000 characters; pass `path` for one group.', inputSchema: { @@ -3800,6 +3857,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:form-history', + title: 'Show form history', description: 'Timeline of form changes: value (with previous value and repeat count), status, submit (ran, blocked, threw), added and removed fields, each tagged with its origin (user, code, devtools, binding, or agent for a WebMCP tool call). Filter by form, path, type, origin or `since` (a marker from an earlier call). Returns the current marker.', inputSchema: { @@ -3833,6 +3891,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:form-diff', + title: 'Diff form values', description: 'Net change of a form since a marker: each field whose value or status ended different, with from → to and how many changes happened in between. Get a marker from form-history, inspect-forms or a form-action result, act, then call this.', inputSchema: { @@ -3851,6 +3910,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:lint-forms', + title: 'Lint forms', description: 'Deterministic checks on live forms: stale validity after validator changes, stuck PENDING, unreachable submit, missing submission action, hidden fields still rendered, view out of sync with the model, [disabled] on reactive controls, required-but-unbound fields, NG01xxx setup errors, and model-aware accessibility (missing label, aria-invalid desync, required not exposed, error text not shown or not linked, no focus after invalid submit).', inputSchema: { type: 'object', properties: { form: formProperty, page: formPageProperty } }, @@ -3865,6 +3925,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:lint-pipes', + title: 'Lint pipes', description: 'Deterministic checks on pipes found in source: an impure pipe used inside an @for block (runs every check, potentially once per row), `| json` left in a template (a debugging aid), a pure pipe whose transform() reads a signal (its memoization only tracks its own arguments, not signals it reads; zero-argument calls on injected services are reported at info level), and a method call piped to `| async`, like `getData() | async` (a new Observable per check makes AsyncPipe resubscribe every time).', inputSchema: { type: 'object', properties: {} }, @@ -3873,6 +3934,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:explain-pipe', + title: 'Explain a pipe', description: 'Explain one pipe by name: where it is declared or used, whether it is pure, live instance/call counts, time spent in transform, errors thrown and last input/output when instrumentation is on, an experimental stale-value warning, `| async` usages that resubscribe on every check or subscribe to the same source as another usage (for `async`), and any lint findings. Use this to answer "why is this pipe slow or stale?"', inputSchema: { @@ -3891,6 +3953,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:record-pipes', + title: 'Record pipe calls', description: "Start or stop recording pipe calls on the running page, the same switch as Record calls in the Pipes panel. Recording wraps each pipe's transform() to count and time calls, count errors and keep the last input, output and caller, and turns on the experimental stale-argument check; pangular:explain-pipe then shows them. `start` records on the page `page` names, or on the most recent page; `stop` stops that page, or every page without `page`. Calls recorded so far stay after a stop.", inputSchema: { @@ -3911,6 +3974,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:explain-custom-control', + title: 'Explain a custom form control', description: 'Explain how a field is bound to its element (built-in accessor, custom ControlValueAccessor, custom control, [formField]) and what is wrong with it: value drift, missing setDisabledState, touched never set, captured NG01xxx setup errors.', inputSchema: { @@ -3922,6 +3986,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:export-form', + title: 'Export a form', description: 'Export a form as a JSON snapshot (tree, status, raw value) or as a test fixture (setValue / signal model plus the expected status) with a repro header. Secret values stay [redacted]. A snapshot keeps as many fields as fit in 20,000 characters (at most 200) and notes what was cut.', inputSchema: { @@ -3937,6 +4002,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:wait-for-form', + title: 'Wait for a form', description: 'Wait until a form is settled (no pending async validation, debounce or submit in flight), valid, not pending, or submitted after a marker. Resolves as soon as the condition holds, or reports the state on timeout.', inputSchema: { @@ -4004,6 +4070,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:form-action', + title: 'Run a form action', description: 'Perform actions on a live form in dev mode. Actions include `set-value` (modes: code/user), `mark-touched`, `mark-untouched`, `mark-dirty`, `mark-pristine`, `touch-all`, `revalidate`, `reset`, `enable`, `disable`, `submit`, `focus`, `focus-first-invalid`, `store-as-global`, `snapshot`, `restore`, and `instrument`. Actions `reset`, `submit`, and `restore` require `confirm: true`. Secret, hidden and readonly fields are never written; disabled reactive fields require `force: true`.', inputSchema: { @@ -4054,6 +4121,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:fill-form', + title: 'Fill a form', description: 'Fill several fields at once, by dotted path, through the inputs like a user would (so parsing, dirty and touched run for real). Reports written and skipped fields (secret, hidden, readonly, disabled, missing, or a value must equal the value of one of its options ([ngValue] or value); a multiple select takes an array. Optionally submits afterwards (needs confirm: true).', inputSchema: { @@ -4086,6 +4154,8 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:list-pages', + title: 'List connected pages', + outputSchema: MARKDOWN_OUTPUT_SCHEMA, description: 'List connected browser tabs, Angular Native apps, and Capacitor apps reporting live data, newest first: page id, URL, title, platform, Angular version, recency, background status, and reporting inspectors. Pass a page id as `page` to the live tools to target a specific tab.', inputSchema: { type: 'object', properties: {} }, @@ -4137,6 +4207,7 @@ const pangular = defineDevframe({ )?.base; agent.registerTool({ id: 'pangular:open-in-panel', + title: 'Open in the devtools panel', description: OPEN_IN_PANEL_DESCRIPTION, inputSchema: { type: 'object', diff --git a/packages/devtools/src/rpc/agent-output.ts b/packages/devtools/src/rpc/agent-output.ts new file mode 100644 index 00000000..6f63da1e --- /dev/null +++ b/packages/devtools/src/rpc/agent-output.ts @@ -0,0 +1,28 @@ +import { toJsonSchema } from '@valibot/to-json-schema'; +import * as v from 'valibot'; + +/** What a markdown agent tool answers: the text an agent reads. */ +export interface MarkdownAnswer { + markdown: string; +} + +/** The valibot schema of {@link MarkdownAnswer}, the source of {@link MARKDOWN_OUTPUT_SCHEMA}. */ +export const MarkdownAnswerSchema = v.object({ + markdown: v.pipe(v.string(), v.description('The answer as markdown.')), +}); + +/** + * The MCP `outputSchema` of a tool whose every answer is `{ markdown }`. + * + * Devframe sends a tool's result as the text content (its JSON) and, when the + * tool has an output schema, the same object as `structuredContent`. So the + * text a client reads stays exactly what it was, and a client that reads + * `structuredContent` gets the markdown without parsing the text. + * + * Give it only to tools that always answer `{ markdown: string }` and nothing + * else: MCP clients check `structuredContent` against it. A tool that returns + * an array or a string, or sends its own `mcpContent` blocks, must not have one. + */ +export const MARKDOWN_OUTPUT_SCHEMA = toJsonSchema(MarkdownAnswerSchema, { + target: 'draft-2020-12', +}); diff --git a/packages/devtools/src/rpc/analog-register.ts b/packages/devtools/src/rpc/analog-register.ts index 93a6d350..b05d54f4 100644 --- a/packages/devtools/src/rpc/analog-register.ts +++ b/packages/devtools/src/rpc/analog-register.ts @@ -42,6 +42,7 @@ import { } from './analog-tools.ts'; import type { PageOwners } from './page-owners.ts'; import { fixedTtl, PAGE_TTL_MS, type PageTtl } from './page-ttl.ts'; +import { MARKDOWN_OUTPUT_SCHEMA } from './agent-output.ts'; type AnyRecord = Record; @@ -386,6 +387,8 @@ export async function registerAnalog( ctx.agent.registerTool({ id: 'pangular:analog-routes', + title: 'List Analog routes', + outputSchema: MARKDOWN_OUTPUT_SCHEMA, description: 'List the Analog file-based routes in match order: URL pattern, page or layout file, route groups, [param] and catch-all segments, sibling .server.ts (load/action), routeMeta keys and titles. Pass `filter` to narrow by path or file.', inputSchema: { type: 'object', properties: { filter: { type: 'string' } } }, @@ -393,6 +396,7 @@ export async function registerAnalog( }); ctx.agent.registerTool({ id: 'pangular:analog-explain-url', + title: 'Explain an Analog URL', description: 'Explain which Analog files render a URL (layout chain, page, .server.ts load and its endpoint), the params, or why nothing matches with the closest candidates.', inputSchema: { type: 'object', required: ['url'], properties: { url: { type: 'string' } } }, @@ -405,6 +409,7 @@ export async function registerAnalog( }); ctx.agent.registerTool({ id: 'pangular:analog-current-page', + title: 'Show the current Analog page', description: 'The Analog page open in the browser: its files (layouts first), load() data it received, server rendering and hydration state, hydration errors, and page files the running router does not know yet (restart needed).', inputSchema: { type: 'object', properties: { page } }, @@ -413,6 +418,8 @@ export async function registerAnalog( }); ctx.agent.registerTool({ id: 'pangular:analog-server-calls', + title: 'List Analog server calls', + outputSchema: MARKDOWN_OUTPUT_SCHEMA, description: 'Recent server calls seen by the dev server: page renders (with render mode), load() fetches (GET /_analog/pages), form action submissions (other methods on /_analog/pages, with success, redirect or validation error outcome), server functions by name (including reads that ran during server rendering, seen through their TransferState seed) and API routes, with status, time, size, who called (ssr or browser) and a redacted response preview. Flags load() fetched twice and seeded server function reads called again in the browser.', inputSchema: { @@ -428,6 +435,8 @@ export async function registerAnalog( }); ctx.agent.registerTool({ id: 'pangular:analog-server-functions', + title: 'List Analog server functions', + outputSchema: MARKDOWN_OUTPUT_SCHEMA, description: 'List Analog server functions (serverFn exports in src/**/*.server.ts) with name, HTTP method, file and the id Analog routes them under (/_analog/fn/), how often each was called over HTTP and during server rendering, and reads called again in the browser right after hydration. Empty when no .server.ts file exports serverFn.', inputSchema: { type: 'object', properties: {} }, @@ -435,6 +444,8 @@ export async function registerAnalog( }); ctx.agent.registerTool({ id: 'pangular:analog-api-routes', + title: 'List Analog API routes', + outputSchema: MARKDOWN_OUTPUT_SCHEMA, description: 'List Analog/Nitro server routes under src/server/routes with method, URL and file, plus server middleware.', inputSchema: { type: 'object', properties: {} }, @@ -442,6 +453,7 @@ export async function registerAnalog( }); ctx.agent.registerTool({ id: 'pangular:analog-call-api', + title: 'Call an Analog API route', description: 'Send a request to a route on the running dev server (for example GET /api/v1/hello) and return status, time and body. Methods other than GET, HEAD and OPTIONS need confirm: true.', inputSchema: { @@ -477,6 +489,7 @@ export async function registerAnalog( }); ctx.agent.registerTool({ id: 'pangular:analog-render-modes', + title: 'Show Analog render modes', description: 'For each Analog page: how it is rendered (server rendered per request, prerendered, cached, redirected or client only) with the routeRules entry, prerender.routes or build output that decides it, and what the last request to that page (including dynamic and catch-all URLs) actually did.', inputSchema: { type: 'object', properties: {} }, @@ -484,6 +497,7 @@ export async function registerAnalog( }); ctx.agent.registerTool({ id: 'pangular:analog-prerender-plan', + title: 'Check the Analog prerender plan', description: 'Compare prerender.routes with the page files and the build output: static pages left out, dynamic pages that need explicit entries, and listed routes missing from dist.', inputSchema: { type: 'object', properties: {} }, @@ -491,6 +505,8 @@ export async function registerAnalog( }); ctx.agent.registerTool({ id: 'pangular:analog-content', + title: 'List Analog content files', + outputSchema: MARKDOWN_OUTPUT_SCHEMA, description: 'List markdown content files with slug, frontmatter, the route that serves them and parse errors. Pass `filter` to narrow.', inputSchema: { type: 'object', properties: { filter: { type: 'string' } } }, @@ -498,6 +514,7 @@ export async function registerAnalog( }); ctx.agent.registerTool({ id: 'pangular:analog-lint', + title: 'Lint the Analog project', description: 'Analog checks: two files for one URL, sibling [param] files, missing default export, layout without router-outlet, .server.ts without load, action or server functions, or without a page, redirect mistakes, bad API method suffix, duplicate API routes, routes outside the API prefix, prerender entries that match nothing, frontmatter errors, duplicate slugs, plus live problems (load fetched twice, seeded server function read called again, hydration errors, restart needed, API 404/405). Hydration errors and restart hints come from one page: `page`, or the most recent one.', inputSchema: { type: 'object', properties: { page } }, diff --git a/packages/devtools/src/rpc/audit-register.ts b/packages/devtools/src/rpc/audit-register.ts index bee95b58..9a0c47cc 100644 --- a/packages/devtools/src/rpc/audit-register.ts +++ b/packages/devtools/src/rpc/audit-register.ts @@ -375,6 +375,7 @@ export async function registerAudit(host: AuditHost): Promise host.agent.registerTool({ id: 'pangular:audit', + title: 'Audit the running page', description: `One ranked list of what is wrong on the running page: change detection (needs a pangular:change-detection recording), leaks, errors, hydration, route and form lint, failed HTTP calls and transfer cache misses, pipe lint and axe-core violations. Each finding has severity, area, target, why and the tool for detail, then key numbers. ${ host.agentAxe ? '`axe: true` runs a fresh axe pass first. ' : '' }\`session\`: \`before\` saves a baseline, \`after\` compares, \`clear\` drops it; kept per page 30 minutes.${ diff --git a/packages/devtools/src/rpc/component-edits.ts b/packages/devtools/src/rpc/component-edits.ts index ed41d4aa..39a31efb 100644 --- a/packages/devtools/src/rpc/component-edits.ts +++ b/packages/devtools/src/rpc/component-edits.ts @@ -29,6 +29,7 @@ interface Deps { export interface ComponentEditTool { id: string; + title: string; description: string; inputSchema: Record; handler: (args: Record) => Promise<{ markdown: string }>; @@ -126,6 +127,7 @@ export function createComponentEdits(deps: Deps) { const field = kind === 'signal' ? 'signal' : 'property'; return { id: kind === 'signal' ? 'pangular:set-signal' : 'pangular:set-component-property', + title: kind === 'signal' ? 'Set a signal' : 'Set a component property', description: kind === 'signal' ? 'Sets a writable signal of a live component instance via `.set()` (dev only). Returns updated redacted value. Pass property name or path within signal value (e.g. `filters.query`). Supports signal(), linkedSignal(), model(); rejects inputs, computed, readonly signals, non-JSON values and redacted names. Specify an instance id, or a class name or host tag matching one instance. Multi-tab setups require `page`. Requires `actions.components` write permission.' diff --git a/packages/devtools/src/rpc/component-screenshots.ts b/packages/devtools/src/rpc/component-screenshots.ts index bdd390fe..70a77ff5 100644 --- a/packages/devtools/src/rpc/component-screenshots.ts +++ b/packages/devtools/src/rpc/component-screenshots.ts @@ -137,6 +137,7 @@ export function createComponentScreenshots(deps: Deps) { const tool = { id: 'pangular:screenshot-component', + title: 'Screenshot a component', description: 'An image (PNG, or JPEG when large) of one component\'s host element as rendered now. Pass `selector`: an instance id, class name, host tag or CSS selector. Without `page`, ids, class names and tags are searched on every page, newest first; a CSS selector uses the newest page. The page draws a copy from computed styles, so it works off screen and never changes the app. Password and secret fields, `data-private`, `.sentry-mask`, `.rr-mask` and `data-pangular="mask"` elements, and tokens in text are masked. Cross-origin images without CORS, iframes and web fonts are not drawn. At most 2000 px.', inputSchema: { diff --git a/packages/devtools/src/rpc/errors-register.ts b/packages/devtools/src/rpc/errors-register.ts index a3b2e63a..1d1d8f48 100644 --- a/packages/devtools/src/rpc/errors-register.ts +++ b/packages/devtools/src/rpc/errors-register.ts @@ -11,6 +11,7 @@ import { import { PAGE_ARGUMENT, pageArgument } from './pages.ts'; import { trackPageSessions } from './page-sessions.ts'; import type { PageTtl } from './page-ttl.ts'; +import { MARKDOWN_OUTPUT_SCHEMA } from './agent-output.ts'; type AnyRecord = Record; @@ -94,6 +95,8 @@ export async function registerErrors(host: ErrorsHost): Promise ({ markdown: explainInjectionText(run(raw)) }), diff --git a/packages/devtools/src/rpc/leaks-register.ts b/packages/devtools/src/rpc/leaks-register.ts index 6ba384fe..ac123d25 100644 --- a/packages/devtools/src/rpc/leaks-register.ts +++ b/packages/devtools/src/rpc/leaks-register.ts @@ -125,6 +125,7 @@ export async function registerLeaks(host: LeaksHost): Promise host.agent.registerTool({ id: 'pangular:find-leaks', + title: 'Find memory leaks', description: 'Leak hints for one page (`page`, or the most recent that reports leaks): destroyed components still in memory after 2 navigations and 10 seconds (class, instance id, route, age), detached views apart; subscriptions still open after destroy, only while pangular:track-subscriptions is on; and live effects and subscriptions per component (Angular 20.1+). GC timing is up to the browser, so retained rows are hints to check in the Chrome Memory panel. Dev builds of Angular 16.2+ keep destroyed components in DI debug data, so retention is not judged there. Names and routes are masked.', inputSchema: { @@ -149,6 +150,7 @@ export async function registerLeaks(host: LeaksHost): Promise host.agent.registerTool({ id: 'pangular:track-subscriptions', + title: 'Track subscriptions', description: 'Start or stop tracking RxJS subscriptions on the running page (`action`: `start` or `stop`). While on, the overlay wraps `Observable.prototype.subscribe` to record which component made each subscription, so pangular:find-leaks can count open ones per component and flag those still open after destroy; stopping restores `subscribe`. Subscriptions made before tracking started are not seen, and the choice survives a reload. `start` tracks the page `page` names, or the most recent page; `stop` stops that page, or every page without `page`.', inputSchema: { diff --git a/packages/devtools/src/rpc/ngrx-session-tools.ts b/packages/devtools/src/rpc/ngrx-session-tools.ts index 7afa9314..282c347c 100644 --- a/packages/devtools/src/rpc/ngrx-session-tools.ts +++ b/packages/devtools/src/rpc/ngrx-session-tools.ts @@ -19,6 +19,7 @@ import { untrusted } from './forms-tools.ts'; import type { NgrxPageRecord } from './ngrx-tools.ts'; import { callerPage } from './page-scope.ts'; import { pageArgument, unknownPageText } from './pages.ts'; +import { MARKDOWN_OUTPUT_SCHEMA } from './agent-output.ts'; /** * The NgRx history on the server: the State, Diff and Action of a past entry @@ -208,6 +209,8 @@ export function createNgrxHistory(deps: Deps) { const tools = [ { id: 'pangular:inspect-ngrx-entry', + title: 'Inspect an NgRx log entry', + outputSchema: MARKDOWN_OUTPUT_SCHEMA, description: INSPECT_NGRX_ENTRY_DESCRIPTION, inputSchema: NGRX_HISTORY_SCHEMAS.entry, handler: async (args: Record) => { @@ -228,6 +231,7 @@ export function createNgrxHistory(deps: Deps) { }, { id: 'pangular:export-ngrx-session', + title: 'Export an NgRx session', description: EXPORT_NGRX_SESSION_DESCRIPTION, inputSchema: NGRX_HISTORY_SCHEMAS.export, handler: async (args: Record) => ({ @@ -246,6 +250,7 @@ export function createNgrxHistory(deps: Deps) { }, { id: 'pangular:import-ngrx-session', + title: 'Import an NgRx session', description: IMPORT_NGRX_SESSION_DESCRIPTION, inputSchema: NGRX_HISTORY_SCHEMAS.import, handler: async (args: Record) => ({ @@ -254,6 +259,7 @@ export function createNgrxHistory(deps: Deps) { }, { id: 'pangular:generate-ngrx-test', + title: 'Generate an NgRx test', description: GENERATE_NGRX_TEST_DESCRIPTION, inputSchema: NGRX_HISTORY_SCHEMAS.test, handler: async (args: Record) => { @@ -277,6 +283,7 @@ export function createNgrxHistory(deps: Deps) { }, { id: 'pangular:restore-ngrx-state', + title: 'Restore NgRx state', description: RESTORE_NGRX_STATE_DESCRIPTION, inputSchema: NGRX_HISTORY_SCHEMAS.restore, handler: async (args: Record) => { diff --git a/packages/devtools/src/rpc/plugins-register.ts b/packages/devtools/src/rpc/plugins-register.ts index a73cb276..060ebcbf 100644 --- a/packages/devtools/src/rpc/plugins-register.ts +++ b/packages/devtools/src/rpc/plugins-register.ts @@ -33,6 +33,7 @@ import { code, untrusted } from './forms-tools.ts'; import { PAGE_ARGUMENT, byRecency, pageArgument, unknownPageText } from './pages.ts'; import { trackPageSessions } from './page-sessions.ts'; import type { PageTtl } from './page-ttl.ts'; +import { MARKDOWN_OUTPUT_SCHEMA } from './agent-output.ts'; type AnyRecord = Record; @@ -558,6 +559,8 @@ export async function registerPlugins(host: PluginsHost): Promise:`, with their tier). Page plugins are libraries that registered a view with `registerPangularPlugin()`; read one with pangular:inspect-plugin. Plugins whose tools the devtools config turns off (`plugins..tools: false`) are left out. Empty when no page runs a plugin and no server plugin is set.', inputSchema: { type: 'object', properties: {} }, @@ -566,6 +569,8 @@ export async function registerPlugins(host: PluginsHost): Promise.tools: false`) reads as not reported.", inputSchema: { diff --git a/packages/devtools/src/rpc/provider-values.ts b/packages/devtools/src/rpc/provider-values.ts index 13df6969..3048e38b 100644 --- a/packages/devtools/src/rpc/provider-values.ts +++ b/packages/devtools/src/rpc/provider-values.ts @@ -77,6 +77,7 @@ export function createProviderValues(deps: Deps) { /** The read-provider-value agent tool. */ const tool = { id: 'pangular:read-provider-value', + title: 'Read a provider value', description: READ_PROVIDER_VALUE_DESCRIPTION, inputSchema: { type: 'object', diff --git a/packages/devtools/src/rpc/query-register.ts b/packages/devtools/src/rpc/query-register.ts index ab47bf1d..6be3335f 100644 --- a/packages/devtools/src/rpc/query-register.ts +++ b/packages/devtools/src/rpc/query-register.ts @@ -17,6 +17,7 @@ import { staleQueryPages, toQueryReport, } from './query-tools.ts'; +import { MARKDOWN_OUTPUT_SCHEMA } from './agent-output.ts'; type AnyRecord = Record; @@ -162,6 +163,8 @@ export async function registerTanstackQuery(host: QueryHost): Promise; @@ -86,6 +87,8 @@ export async function registerReduxStores(host: ReduxStoresHost): Promise Date: Thu, 15 Oct 2026 20:11:49 +0000 Subject: [PATCH 2/2] fix(mcp): ask for a selector when inspect-component or highlight gets none Called without selector while a page reports, both tools threw a TypeError that reached the agent as a raw tool error. They now answer with what to pass. --- .../devtools/src/__tests__/tool-metadata.test.ts | 15 +++++++++++++++ packages/devtools/src/devframe.ts | 6 ++++++ 2 files changed, 21 insertions(+) diff --git a/packages/devtools/src/__tests__/tool-metadata.test.ts b/packages/devtools/src/__tests__/tool-metadata.test.ts index ad54bfaa..7163db5d 100644 --- a/packages/devtools/src/__tests__/tool-metadata.test.ts +++ b/packages/devtools/src/__tests__/tool-metadata.test.ts @@ -259,6 +259,21 @@ describe('agent tool output schemas', () => { } }); + it('asks for a selector instead of failing when it is missing', async () => { + const { invoke, push } = await bootMcp(); + await push('push-component-tree', { + pageId: 'p1', + roots: [{ id: 'c1', name: 'App', tag: 'app-root', children: [] }], + count: 1, + detail: null, + }); + for (const name of ['pangular_inspect-component', 'pangular_highlight']) { + const result = await invoke(name, {}); + expect(result.isError, name).toBeFalsy(); + expect(result.content[0]!.text, name).toContain('Pass `selector`'); + } + }); + it('keeps the markdown schema to one string field', () => { expect(MARKDOWN_OUTPUT_SCHEMA).toMatchObject({ type: 'object', diff --git a/packages/devtools/src/devframe.ts b/packages/devtools/src/devframe.ts index c51fd5fc..6b484a59 100644 --- a/packages/devtools/src/devframe.ts +++ b/packages/devtools/src/devframe.ts @@ -2723,6 +2723,9 @@ const pangular = defineDevframe({ markdown: noComponentTree(', so nothing was highlighted'), }; } + if (typeof args.selector !== 'string' || !args.selector.trim()) { + return { markdown: 'Pass `selector`: an instance id, class name or host tag.' }; + } const hits = componentHits(args.selector, page); const [hit] = hits; const target = hit @@ -2810,6 +2813,9 @@ const pangular = defineDevframe({ if (![...componentPages.values()].some((entry) => entry.roots.length)) { return { markdown: noComponentTree('') }; } + if (typeof args.selector !== 'string' || !args.selector.trim()) { + return { markdown: 'Pass `selector`: an instance id, class name or host tag.' }; + } const hits = componentHits(args.selector, page); const [hit] = hits; if (!hit) {