From 927857392f4391b4dc545cad5de77f0be8e7ed2f Mon Sep 17 00:00:00 2001 From: erKam Date: Sat, 10 Oct 2026 22:34:15 +0000 Subject: [PATCH 1/3] feat(mcp): let inspect-providers select environment injectors The selector argument only matched element injectors, so asking what the Root, Platform or a route injector provides returned "No element injector matches", and the only fallback was the whole tree cut off at 20,000 characters. A selector now also matches environment injectors by name, case insensitive (Root, Platform, Route: admin), or by id. The answer lists that injector's providers, what the services it already created inject with the supplying injector named, and its parent environment injectors with the tokens each provides. Element selectors behave as before. When nothing matches, the answer names both kinds and lists the environment injectors on the page so the agent can retry with a valid name. The tool description and the agent tools docs page describe the new selector values. --- apps/docs/src/content/agents/tools.md | 12 ++- .../src/__tests__/agent-tools.test.ts | 97 ++++++++++++++++++- packages/devtools/src/devframe.ts | 4 +- packages/devtools/src/rpc/injector-tools.ts | 88 +++++++++++++---- 4 files changed, 176 insertions(+), 25 deletions(-) diff --git a/apps/docs/src/content/agents/tools.md b/apps/docs/src/content/agents/tools.md index c1d2bd3d..93f38af5 100644 --- a/apps/docs/src/content/agents/tools.md +++ b/apps/docs/src/content/agents/tools.md @@ -139,11 +139,13 @@ With `root` or a route path, the tool switches the page's graph to the effects o The injectors a page reported. Element injectors list what each component injected and which injector supplied it. Environment injectors run from the platform down to the root and route injectors, and list what the services they already created inject. Reads: page. -| Argument | Required | Value | -| ---------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `selector` | no | A tag name, a component or directive class name, or an injector id. Returns only the matching element injectors, each with its lookup path resolved. | -| `token` | no | A token name, like `HttpClient`. Returns the injectors that provide it and the components or services that inject it. | -| `pageId` | no | Same as `page`. | +| Argument | Required | Value | +| ---------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `selector` | no | A tag name, a component or directive class name, an environment injector name like `Root`, `Platform` or `Route: admin`, or an injector id. Returns only the matching injectors. | +| `token` | no | A token name, like `HttpClient`. Returns the injectors that provide it and the components or services that inject it. | +| `pageId` | no | Same as `page`. | + +A `selector` that names an element injector returns it with its lookup path resolved to names and provided tokens. One that names an environment injector, in any case, returns that injector's providers, what the services it already created inject, and its parent injectors. When nothing matches, the answer lists the tags and environment injector names on the page to retry with. Without `selector` or `token`, the answer is the whole tree, cut off at 20,000 characters, and says which change detection mode the page runs. When the page has more than 2000 element injectors, the answer says that it holds only the first 2000. diff --git a/packages/devtools/src/__tests__/agent-tools.test.ts b/packages/devtools/src/__tests__/agent-tools.test.ts index 982b43fc..ac311d03 100644 --- a/packages/devtools/src/__tests__/agent-tools.test.ts +++ b/packages/devtools/src/__tests__/agent-tools.test.ts @@ -406,7 +406,9 @@ describe('agent tools', () => { ]); expect(matched[0].dependencies[0]).toMatchObject({ providedByName: 'Root' }); expect(bySelector).not.toMatch(/app-root/); - expect(await call('inspect-providers', 'app-none')).toMatch(/No element injector.*app-none/); + expect(await call('inspect-providers', 'app-none')).toMatch( + /No element or environment injector.*app-none/, + ); const byToken = await call('inspect-providers', '', { token: 'httpclient' }); const answer = JSON.parse(byToken.slice(byToken.indexOf('{"'))); @@ -419,6 +421,99 @@ describe('agent tools', () => { ); }); + it('matches environment injectors by name or id in inspect-providers', async () => { + const { push, call } = await boot(); + await push('push-injector-tree', { + pageId: 'p1', + roots: [ + { + injector: { + id: 'app-1', + type: 'element', + name: 'app-root', + component: 'App', + providerCount: 0, + path: ['app-1', 'root-1', 'platform-1', 'inj-null'], + }, + providers: [], + children: [], + dependencies: [{ from: 'App', token: 'Api', flags: [], providedBy: 'root-1' }], + }, + ], + environment: [ + { + injector: { id: 'platform-1', type: 'environment', name: 'Platform', providerCount: 1 }, + providers: [{ token: 'PlatformRef', type: 'class', isViewProvider: false }], + children: [ + { + injector: { id: 'root-1', type: 'environment', name: 'Root', providerCount: 2 }, + providers: [ + { token: 'Api', type: 'class', isViewProvider: false }, + { token: 'HttpClient', type: 'class', isViewProvider: false }, + ], + dependencies: [{ from: 'Api', token: 'HttpClient', flags: [], providedBy: 'root-1' }], + children: [ + { + injector: { + id: 'route-1', + type: 'environment', + name: 'Route: admin', + providerCount: 1, + }, + providers: [{ token: 'AdminStore', type: 'class', isViewProvider: false }], + dependencies: [ + { from: 'AdminStore', token: 'Api', flags: [], providedBy: 'root-1' }, + ], + children: [], + }, + ], + }, + ], + }, + ], + }); + const detail = (answer: string) => JSON.parse(answer.slice(answer.indexOf('[{'))); + + const route = await call('inspect-providers', 'route: ADMIN'); + expect(route).toMatch(/Environment injectors on page `p1` matching `route: ADMIN` \(1\)/); + expect(route).not.toMatch(/Element injectors/); + const [admin] = detail(route); + expect(detail(route)).toHaveLength(1); + expect(admin.injector).toMatchObject({ id: 'route-1', name: 'Route: admin' }); + expect(admin.providers).toEqual([ + { token: 'AdminStore', type: 'class', isViewProvider: false }, + ]); + expect(admin.dependencies).toEqual([ + { from: 'AdminStore', token: 'Api', flags: [], providedBy: 'root-1', providedByName: 'Root' }, + ]); + expect(admin.parents).toEqual([ + { id: 'root-1', name: 'Root', provides: ['Api', 'HttpClient'] }, + { id: 'platform-1', name: 'Platform', provides: ['PlatformRef'] }, + ]); + + const byId = detail(await call('inspect-providers', 'root-1')); + expect(byId.map((m: { injector: { id: string } }) => m.injector.id)).toEqual(['root-1']); + expect(detail(await call('inspect-providers', 'platform'))[0].injector.id).toBe('platform-1'); + + const element = await call('inspect-providers', 'App'); + expect(element).toMatch(/Element injectors on page `p1` matching `App` \(1\)/); + expect(element).not.toMatch(/Environment injectors/); + expect(detail(element)[0].lookupPath.map((step: { name: string }) => step.name)).toEqual([ + 'app-root', + 'Root', + 'Platform', + 'Null injector', + ]); + + const scoped = await call('inspect-providers', 'Route: admin', { token: 'Api' }); + const answer = JSON.parse(scoped.slice(scoped.indexOf('{"providedBy"'))); + expect(answer.injectedBy.map((hit: { id: string }) => hit.id)).toEqual(['route-1']); + + expect(await call('inspect-providers', 'Route: missing')).toMatch( + /No element or environment injector on page `p1` matches `Route: missing`.*Tags on the page: `app-root`\. Environment injectors: `Platform`, `Root`, `Route: admin`\./, + ); + }); + it('caps the whole injector tree and says how to narrow it', async () => { const { push, call } = await boot(); const roots = Array.from({ length: 400 }, (_, i) => ({ diff --git a/packages/devtools/src/devframe.ts b/packages/devtools/src/devframe.ts index 3b2801d3..46fce00b 100644 --- a/packages/devtools/src/devframe.ts +++ b/packages/devtools/src/devframe.ts @@ -1820,7 +1820,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:inspect-providers', description: - 'Get the DI injectors a running page reported. With no arguments, returns the whole tree (element and environment injectors with their providers, and what the services each environment injector already created inject), cut off at 20,000 characters. `selector` returns only the matching element injectors, each with what it injects and its lookup path resolved to names and provided tokens. `token` returns which injectors provide that token and which components or services inject it. Each open tab reports its own tree; `page` picks one and defaults to the most recent. Says so when the page reported only part of a large tree.', + 'Get the DI injectors a running page reported. With no arguments, returns the whole tree (element and environment injectors with their providers, and what the services each environment injector already created inject), cut off at 20,000 characters. `selector` returns only the matching injectors: element injectors with what they inject and their lookup path resolved to names and provided tokens, or environment injectors (Root, Platform, Route: admin) with their providers, what their created services inject, and their parent injectors. When nothing matches, the answer lists the tags and environment injector names on the page. `token` returns which injectors provide that token and which components or services inject it. Each open tab reports its own tree; `page` picks one and defaults to the most recent. Says so when the page reported only part of a large tree.', safety: 'read', inputSchema: { type: 'object', @@ -1828,7 +1828,7 @@ const pangular = defineDevframe({ selector: { type: 'string', description: - 'Optional. A tag name (app-card), a component or directive class name (CardComponent), or an injector id. Returns only the matching element injectors.', + 'Optional. A tag name (app-card), a component or directive class name (CardComponent), an environment injector name (Root, Platform, Route: admin; case-insensitive), or an injector id. Returns only the matching injectors.', }, token: { type: 'string', diff --git a/packages/devtools/src/rpc/injector-tools.ts b/packages/devtools/src/rpc/injector-tools.ts index bef5961f..1291cec4 100644 --- a/packages/devtools/src/rpc/injector-tools.ts +++ b/packages/devtools/src/rpc/injector-tools.ts @@ -27,6 +27,33 @@ function capped(text: string): string { : text; } +/** Lower case with runs of spaces collapsed, so `route: Admin` finds `Route: admin`. */ +function loose(text: string): string { + return text.trim().replace(/\s+/g, ' ').toLowerCase(); +} + +/** Environment injectors match by id or by name (`Root`, `Platform`, `Route: admin`). */ +function matchesEnvironment(node: InjectorTreeNode, selector: string): boolean { + const { injector } = node; + return ( + injector.type === 'environment' && + (injector.id === selector || loose(injector.name) === loose(selector)) + ); +} + +/** Each environment injector's ancestors, nearest first, from the nesting of the report. */ +function environmentParents( + nodes: InjectorTreeNode[], + above: string[] = [], + out = new Map(), +): Map { + for (const node of nodes) { + out.set(node.injector.id, above); + environmentParents(node.children, [node.injector.id, ...above], out); + } + return out; +} + function matchesSelector(node: InjectorTreeNode, selector: string): boolean { const { injector } = node; if (injector.type !== 'element') return false; @@ -41,8 +68,8 @@ function matchesSelector(node: InjectorTreeNode, selector: string): boolean { /** * Answers `inspect-providers`: the whole tree when nothing narrows it, the - * element injectors a selector matches with their lookup paths resolved, or - * where a token is provided and injected. + * element or environment injectors a selector matches with their lookup paths + * resolved, or where a token is provided and injected. */ export function inspectProvidersText( page: InjectorPage | undefined, @@ -51,7 +78,8 @@ export function inspectProvidersText( if (!page?.roots.length) return NO_INJECTORS; const { pageId, roots, environment } = page; const elements = flatten(roots); - const all = [...elements, ...flatten(environment)]; + const environments = flatten(environment); + const all = [...elements, ...environments]; const byId = new Map(all.map((node) => [node.injector.id, node])); const nameOf = (id: string) => id === NULL_INJECTOR_ID @@ -71,31 +99,57 @@ export function inspectProvidersText( ); } - const matched = selector ? elements.filter((node) => matchesSelector(node, selector)) : elements; + const matchedElements = selector + ? elements.filter((node) => matchesSelector(node, selector)) + : []; + const matchedEnvironments = selector + ? environments.filter((node) => matchesEnvironment(node, selector)) + : []; + const matched = [...matchedElements, ...matchedEnvironments]; if (selector && !matched.length) { - const names = [...new Set(elements.map((node) => node.injector.name))].slice(0, 20); - return `No element injector on page \`${pageId}\` matches \`${selector}\`. Pass a tag name, a component or directive class name, or an injector id. Tags on the page: ${names.map((name) => `\`${name}\``).join(', ')}.${notes}`; + const list = (names: string[]) => names.map((name) => `\`${name}\``).join(', '); + const tags = [...new Set(elements.map((node) => node.injector.name))].slice(0, 20); + const envNames = [...new Set(environments.map((node) => node.injector.name))].slice(0, 10); + return `No element or environment injector on page \`${pageId}\` matches \`${selector}\`. Pass a tag name, a component or directive class name, an environment injector name, or an injector id. Tags on the page: ${list(tags)}. Environment injectors: ${envNames.length ? list(envNames) : 'none reported'}.${notes}`; } const sections: string[] = []; - if (selector) { - const detail = matched.map((node) => { + const namedDependencies = (node: InjectorTreeNode) => + (node.dependencies ?? []).map((dep) => + dep.providedBy ? { ...dep, providedByName: nameOf(dep.providedBy) } : dep, + ); + const resolve = (ids: string[]) => + ids.map((id) => ({ + id, + name: nameOf(id), + provides: byId.get(id)?.providers.map((provider) => provider.token) ?? [], + })); + + if (matchedElements.length) { + const detail = matchedElements.map((node) => { const { path, ...injector } = node.injector; return { injector, providers: node.providers, - dependencies: (node.dependencies ?? []).map((dep) => - dep.providedBy ? { ...dep, providedByName: nameOf(dep.providedBy) } : dep, - ), - lookupPath: (path ?? []).map((id) => ({ - id, - name: nameOf(id), - provides: byId.get(id)?.providers.map((provider) => provider.token) ?? [], - })), + dependencies: namedDependencies(node), + lookupPath: resolve(path ?? []), }; }); sections.push( - `Element injectors on page \`${pageId}\` matching \`${selector}\` (${matched.length}). \`lookupPath\` lists the injectors Angular asks, in order, with the tokens each one provides.\n\n${JSON.stringify(detail)}`, + `Element injectors on page \`${pageId}\` matching \`${selector}\` (${matchedElements.length}). \`lookupPath\` lists the injectors Angular asks, in order, with the tokens each one provides.\n\n${JSON.stringify(detail)}`, + ); + } + + if (matchedEnvironments.length) { + const parents = environmentParents(environment); + const detail = matchedEnvironments.map((node) => ({ + injector: node.injector, + providers: node.providers, + dependencies: namedDependencies(node), + parents: resolve(parents.get(node.injector.id) ?? []), + })); + sections.push( + `Environment injectors on page \`${pageId}\` matching \`${selector}\` (${matchedEnvironments.length}). \`dependencies\` lists what the services this injector already created inject (\`from\` is the service, \`providedBy\` the injector that supplied it). \`parents\` lists the environment injectors Angular asks next, nearest first, with the tokens each one provides.\n\n${JSON.stringify(detail)}`, ); } From 9f2d0031b312da440168876637f2f2807d147c35 Mon Sep 17 00:00:00 2001 From: erKam Date: Sat, 10 Oct 2026 22:41:49 +0000 Subject: [PATCH 2/3] fix(mcp): inspect environment injectors when a page reports no elements inspect-providers answered "No injector data available" whenever the page reported no element injectors, so a report with only environment injectors could not be inspected even with an environment selector. Return that answer only when both trees are empty. --- .../devtools/src/__tests__/agent-tools.test.ts | 18 ++++++++++++++++++ packages/devtools/src/rpc/injector-tools.ts | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/devtools/src/__tests__/agent-tools.test.ts b/packages/devtools/src/__tests__/agent-tools.test.ts index ac311d03..c1f888dc 100644 --- a/packages/devtools/src/__tests__/agent-tools.test.ts +++ b/packages/devtools/src/__tests__/agent-tools.test.ts @@ -421,6 +421,24 @@ describe('agent tools', () => { ); }); + it('inspects environment injectors on a page that reports no element injectors', async () => { + const { push, call } = await boot(); + await push('push-injector-tree', { + pageId: 'p1', + roots: [], + environment: [ + { + injector: { id: 'root-1', type: 'environment', name: 'Root', providerCount: 1 }, + providers: [{ token: 'HttpClient', type: 'class', isViewProvider: false }], + children: [], + }, + ], + }); + const text = await call('inspect-providers', 'Root'); + expect(text).toContain('HttpClient'); + expect(text).not.toContain('No injector data available'); + }); + it('matches environment injectors by name or id in inspect-providers', async () => { const { push, call } = await boot(); await push('push-injector-tree', { diff --git a/packages/devtools/src/rpc/injector-tools.ts b/packages/devtools/src/rpc/injector-tools.ts index 1291cec4..cb4b5e9b 100644 --- a/packages/devtools/src/rpc/injector-tools.ts +++ b/packages/devtools/src/rpc/injector-tools.ts @@ -75,7 +75,7 @@ export function inspectProvidersText( page: InjectorPage | undefined, args: InspectProvidersArgs, ): string { - if (!page?.roots.length) return NO_INJECTORS; + if (!page || (!page.roots.length && !page.environment.length)) return NO_INJECTORS; const { pageId, roots, environment } = page; const elements = flatten(roots); const environments = flatten(environment); From d0e66f6685cb143cda1d0ebdc37fd1a6c40695d5 Mon Sep 17 00:00:00 2001 From: erKam Date: Sun, 11 Oct 2026 01:47:57 +0000 Subject: [PATCH 3/3] docs(mcp): say inspect-providers selects environment injectors too --- apps/docs/src/content/inspectors/injectors.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/docs/src/content/inspectors/injectors.md b/apps/docs/src/content/inspectors/injectors.md index bae64ed0..ed658116 100644 --- a/apps/docs/src/content/inspectors/injectors.md +++ b/apps/docs/src/content/inspectors/injectors.md @@ -111,11 +111,11 @@ Arrow keys, Home and End move the selection through the tree. The right arrow ex ## Agent tools -| Tool or resource | Kind | What it does | -| ---------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `pangular:get-providers` | tool | DI providers from source: `@Injectable` services, `inject()` calls, constructor parameters and `providers` arrays. | -| `pangular:inspect-providers` | tool | The injector tree a page reported, with what components and created services inject. `selector` narrows it to matching element injectors, `token` to where a token is provided and injected. | -| `pangular:injector-tree` | resource | The live tree last reported by a page. | +| Tool or resource | Kind | What it does | +| ---------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `pangular:get-providers` | tool | DI providers from source: `@Injectable` services, `inject()` calls, constructor parameters and `providers` arrays. | +| `pangular:inspect-providers` | tool | The injector tree a page reported, with what components and created services inject. `selector` narrows it to matching element or environment injectors (`Root`, `Platform`, `Route: admin`), `token` to where a token is provided and injected. | +| `pangular:injector-tree` | resource | The live tree last reported by a page. | See [Tools](../agents/tools.md) and [Resources](../agents/resources.md).