diff --git a/apps/docs/src/content/agents/mcp-server.md b/apps/docs/src/content/agents/mcp-server.md index 03ff7323..6100577c 100644 --- a/apps/docs/src/content/agents/mcp-server.md +++ b/apps/docs/src/content/agents/mcp-server.md @@ -259,6 +259,10 @@ The argument is your own text. The server puts it on one line, cuts it at 200 ch The stdio server offers no prompts. Like the instructions, the prompts need a devframe patch that devframe 1.2.5 does not ship, so an installed package offers none until devframe ships it. +### Resource templates + +The HTTP endpoint also lists a resource template for each page-scoped resource, such as `pangular://page/{pageId}/component-tree`, and sends `notifications/resources/list_changed` when a page starts reporting or expires. See [Read one page](./resources.md#read-one-page). + ### Tool names 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. diff --git a/apps/docs/src/content/agents/resources.md b/apps/docs/src/content/agents/resources.md index 1190bb7b..6de566c4 100644 --- a/apps/docs/src/content/agents/resources.md +++ b/apps/docs/src/content/agents/resources.md @@ -78,6 +78,37 @@ Every form the page reported (Signal Forms, reactive and template-driven), with The active route tree (params, data, guards, resolvers) and recent navigations of each page. When the data is too large, the resource returns the URL and recent navigations of each page, and points to `inspect-route`, `explain-navigation` and `list-routes`. +## Read one page + +The six resources above hold every connected page. When several tabs or apps are connected, read one page through its own resource instead. + +### Page resource templates + +The HTTP endpoint lists one *MCP resource template per resource in `resources/templates/list`. Put a page id from [`list-pages`](./tools.md#list-pages) in the URI: + +| Template | Same data as | +| ----------------------------------------- | ------------------------- | +| `pangular://page/{pageId}/component-tree` | `pangular:component-tree` | +| `pangular://page/{pageId}/signal-graph` | `pangular:signal-graph` | +| `pangular://page/{pageId}/injector-tree` | `pangular:injector-tree` | +| `pangular://page/{pageId}/ngrx-store` | `pangular:ngrx-store` | +| `pangular://page/{pageId}/forms` | `pangular:forms` | +| `pangular://page/{pageId}/router` | `pangular:router` | + +Each one returns the JSON of its resource with only that page in it, with the same `_untrusted` field and the same summary when it is too large. The selection made in the panel (`selectedId` and the like) is left out, since it belongs to no page. If no page with that id reports the data, the answer is a markdown note that lists the pages that do. + +A template follows its resource: it is left out when the inspector is off through `inspectors` or `agent.tools`. + +### Per-page entries in the list + +Some clients don't read templates. For them, `resources/list` also names one resource per reporting page, such as `pangular://page//component-tree`. + +### List changes + +When a page starts reporting or expires, the server sends `notifications/resources/list_changed`, so a client can list the resources again. Changes settle for half a second first, and a report from a page that is already listed sends nothing. + +Like the prompts, the templates need a devframe patch that devframe 1.2.5 does not ship. The stdio server has no templates, since no page connects to it. + ## Shared state The devtools keep their live data in shared-state keys. Every key is also listed as a resource, at `devframe://state/` with the key encoded. diff --git a/apps/docs/src/content/contributing/development.md b/apps/docs/src/content/contributing/development.md index 7ca46ae9..3ce2902f 100644 --- a/apps/docs/src/content/contributing/development.md +++ b/apps/docs/src/content/contributing/development.md @@ -45,15 +45,16 @@ pnpm install `patches/` holds `pnpm patch` changes to the built files of `devframe`, `@devframes/agentic` and `@devframes/hub` 1.2.5, listed under `patchedDependencies` in `pnpm-workspace.yaml`. They add what devframe doesn't ship: -| Patch | What it adds | Used by | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | -| `instructions` | An MCP route option, sent as `instructions` in the `initialize` result. Patched in `devframe`, `@devframes/hub` and `@devframes/agentic`. | `pangularMcp()` in `packages/devtools/src/agent-mcp.ts` | -| `prompts` | An MCP route option with prompt templates. `@devframes/agentic` declares the `prompts` capability and answers `prompts/list` and `prompts/get` (invalid params for an unknown prompt or a missing required argument). Patched in `devframe`, `@devframes/hub` and `@devframes/agentic`. | `pangularMcpPrompts()` in `packages/devtools/src/agent-prompts.ts`, through `pangularMcp()` | -| `mcpContent` answers | `@devframes/agentic` sends a tool result's `mcpContent` blocks (`text` and `image`) as the MCP content, instead of the result as JSON. | `screenshot-component`, through `withMcpContent()` in `rpc/component-screenshots.ts` | +| Patch | What it adds | Used by | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `instructions` | An MCP route option, sent as `instructions` in the `initialize` result. Patched in `devframe`, `@devframes/hub` and `@devframes/agentic`. | `pangularMcp()` in `packages/devtools/src/agent-mcp.ts` | +| `prompts` | An MCP route option with prompt templates. `@devframes/agentic` declares the `prompts` capability and answers `prompts/list` and `prompts/get` (invalid params for an unknown prompt or a missing required argument). Patched in `devframe`, `@devframes/hub` and `@devframes/agentic`. | `pangularMcpPrompts()` in `packages/devtools/src/agent-prompts.ts`, through `pangularMcp()` | +| `resourceTemplates` | MCP route options for resource templates and a host hook for resource list changes. `@devframes/agentic` answers `resources/templates/list`, reads a URI through the first template that matches it (`{name}` variables only, one URI segment each), adds each template's `list()` entries to `resources/list`, and sends `notifications/resources/list_changed` when `onResourcesChanged` calls back. Patched in `devframe`, `@devframes/hub` and `@devframes/agentic`. | `pangularMcpResourceTemplates()` and `pangularMcpPagesChanged()` in `packages/devtools/src/agent-page-resources.ts`, through `pangularMcp()` | +| `mcpContent` answers | `@devframes/agentic` sends a tool result's `mcpContent` blocks (`text` and `image`) as the MCP content, instead of the result as JSON. | `screenshot-component`, through `withMcpContent()` in `rpc/component-screenshots.ts` | The `exposeSharedState` filter, which hides the shared-state keys of an inspector with `agent.tools.: false`, is part of devframe since 1.2.4 and needs no patch. -The patches apply only inside this repository. An app that installs the published package gets no server `instructions` and no prompts, and `screenshot-component` answers with its text note only, until devframe ships them. Each patch names an exact version and file names with build hashes, so a devframe upgrade needs the patches redone with `pnpm patch`, or removed once devframe has the feature. +The patches apply only inside this repository. An app that installs the published package gets no server `instructions`, no prompts and no page resource templates, and `screenshot-component` answers with its text note only, until devframe ships them. Each patch names an exact version and file names with build hashes, so a devframe upgrade needs the patches redone with `pnpm patch`, or removed once devframe has the feature. ## Project structure diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md index bdae2bd1..b6a071de 100644 --- a/docs/CONTEXT.md +++ b/docs/CONTEXT.md @@ -93,7 +93,7 @@ An MCP prompt: a ready request for one workflow, such as `debug-error` or `fix-f _Avoid_: template, command, skill (a skill is a `SKILL.md` folder the agent reads) **Resource**: -Live state an agent reads as JSON over MCP, such as `pangular:component-tree`. It holds what the connected pages reported, so it is empty when no page is connected. +Live state an agent reads as JSON over MCP, such as `pangular:component-tree`. It holds what the connected pages reported, so it is empty when no page is connected. Each page-scoped resource also has a per-page resource template, `pangular://page/{pageId}/`, that holds one page (`packages/devtools/src/agent-page-resources.ts`). _Avoid_: snapshot, feed, state dump **Source scan**: diff --git a/packages/devtools/src/__tests__/agent-metadata.test.ts b/packages/devtools/src/__tests__/agent-metadata.test.ts index f57fe7c0..d49d4b71 100644 --- a/packages/devtools/src/__tests__/agent-metadata.test.ts +++ b/packages/devtools/src/__tests__/agent-metadata.test.ts @@ -16,6 +16,7 @@ import { type AgentEntryKind, type PangularConfig, } from '../config.ts'; +import { PAGE_RESOURCE_NAMES } from '../agent-page-resources.ts'; import { createPangular, type PangularHostOptions } from '../devframe.ts'; import { travelServerPlugin } from '../../../../src/app/devtools/travel-plugin.server.ts'; @@ -112,6 +113,18 @@ describe('agent metadata', () => { expect(Object.keys(AGENT_ENTRIES).filter((name) => !kinds.has(name))).toEqual(page); }); + // Per-page MCP resource templates are route options, not agent registrations, + // so they have no entry of their own: each one follows its page resource's entry. + it('maps each per-page resource template to a page resource entry', () => { + for (const name of PAGE_RESOURCE_NAMES) { + expect(AGENT_ENTRIES[name], name).toMatchObject({ kind: 'resource', page: true }); + } + const pageResources = Object.keys(AGENT_ENTRIES).filter( + (name) => AGENT_ENTRIES[name]?.kind === 'resource' && AGENT_ENTRIES[name]?.page, + ); + expect(sorted(pageResources)).toEqual(sorted(PAGE_RESOURCE_NAMES)); + }); + it('keeps each entry consistent', () => { for (const [name, entry] of Object.entries(AGENT_ENTRIES)) { expect(name, name).not.toMatch(/^(pangular|plugin):/); diff --git a/packages/devtools/src/__tests__/agent-page-resources.test.ts b/packages/devtools/src/__tests__/agent-page-resources.test.ts new file mode 100644 index 00000000..53aada6d --- /dev/null +++ b/packages/devtools/src/__tests__/agent-page-resources.test.ts @@ -0,0 +1,439 @@ +import { createDevServer } from 'devframe/adapters/dev'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { pangularMcp } from '../agent-mcp.ts'; +import { + PAGE_RESOURCE_NAMES, + createPageResourceSource, + pageResourceText, + pangularMcpPagesChanged, + pangularMcpResourceTemplates, + type PageResourceName, +} from '../agent-page-resources.ts'; +import { agentResourceOffered, resolvePangularConfig, type PangularConfig } from '../config.ts'; +import { createPangular } from '../devframe.ts'; +import { initPangularHub, type PangularHubOptions } from '../hub.ts'; +import type { LiveComponentNode } from '../types.ts'; +import { makeProject } from './analog-fixture.ts'; + +const closers: (() => Promise)[] = []; + +afterEach(async () => { + vi.useRealTimers(); + vi.restoreAllMocks(); + for (const close of closers.splice(0)) await close(); + delete (globalThis as { __PANGULAR_HUB__?: unknown }).__PANGULAR_HUB__; +}); + +type Send = (request: Request) => Promise; +type RpcAnswer = { result?: T; error?: { code: number; message: string } }; + +/** A JSON-RPC client for one stateless MCP endpoint. */ +function mcpClient(url: string, send: Send) { + let id = 0; + return async (method: string, params: object = {}): Promise> => { + const response = await send( + new Request(url, { + 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 }), + }), + ); + expect(response.status).toBe(200); + const text = await response.text(); + const json = text.startsWith('{') ? text : /^data: (.*)$/m.exec(text)?.[1]; + return JSON.parse(json ?? '{}'); + }; +} + +const INITIALIZE = { + protocolVersion: '2025-03-26', + capabilities: {}, + clientInfo: { name: 'test', version: '0' }, +}; + +const HUB_MCP = 'http://localhost/__devframes/__mcp'; + +async function bootHub(options: PangularHubOptions = {}) { + vi.spyOn(console, 'log').mockImplementation(() => {}); + const hub = initPangularHub({ + cwd: makeProject({ 'package.json': '{}' }), + ws: false, + auth: false, + allowedOrigins: false, + ...options, + }); + closers.push(() => hub.close()); + await hub.ready; + const ctx = await hub.context; + const push = (name: string, payload: unknown) => + ctx.rpc.invokeLocal(`pangular:${name}` as never, ...([payload] as never)); + return { hub, ctx, push, call: mcpClient(HUB_MCP, (r) => hub.handler(r)) }; +} + +type Templates = { + resourceTemplates: { uriTemplate: string; name: string; title?: string; mimeType?: string }[]; +}; +type Resources = { resources: { uri: string; name: string; mimeType?: string }[] }; +type Contents = { contents: { uri: string; mimeType: string; text: string }[] }; + +const tree = (name: string): LiveComponentNode[] => [ + { id: 'c1', name, tag: 'app-root', children: [] }, +]; + +function templateNames(config: PangularConfig = {}) { + return pangularMcpResourceTemplates(resolvePangularConfig(config)).map((t) => t.uriTemplate); +} + +describe('pangularMcpResourceTemplates', () => { + it('offers one page template per page-scoped resource', () => { + expect(templateNames()).toEqual( + PAGE_RESOURCE_NAMES.map((name) => `pangular://page/{pageId}/${name}`), + ); + }); + + it('follows its resource when an inspector or its agent tools are off', () => { + expect(templateNames({ inspectors: { forms: false } })).not.toContain( + 'pangular://page/{pageId}/forms', + ); + expect(templateNames({ agent: { tools: { router: false } } })).not.toContain( + 'pangular://page/{pageId}/router', + ); + expect(templateNames({ agent: { tools: { router: false } } })).toContain( + 'pangular://page/{pageId}/forms', + ); + // agent.readOnly keeps every read resource. + expect(templateNames({ agent: { readOnly: true } })).toHaveLength(PAGE_RESOURCE_NAMES.length); + const config = resolvePangularConfig(); + expect(agentResourceOffered('list-pages', config)).toBe(false); + expect(agentResourceOffered('no-such-resource', config)).toBe(false); + }); +}); + +describe('pageResourceText', () => { + it('keeps only the forms, events and errors of the page', () => { + const form = (id: string) => ({ id, label: id, kind: 'reactive', root: { status: 'VALID' } }); + const state = { + forms: [form('Login.form@p1'), form('Cart.form@p2')], + events: [ + { formId: 'Login.form@p1', path: '', type: 'value', timestamp: 1 }, + { formId: 'Cart.form@p2', path: '', type: 'value', timestamp: 2 }, + ], + reportedAt: 1, + pagesReportedAt: { p1: 10, p2: 20 }, + setupErrors: [ + { pageId: 'p1', message: 'one' }, + { pageId: 'p2', message: 'two' }, + ], + instrumented: ['p2'], + dropped: { p2: 3 }, + }; + const json = JSON.parse(pageResourceText('forms', state, 'p2')!); + expect(json._untrusted).toBeTruthy(); + expect(json.forms.map((f: { id: string }) => f.id)).toEqual(['Cart.form@p2']); + expect(json.events).toHaveLength(1); + expect(json).toMatchObject({ + reportedAt: 20, + pagesReportedAt: { p2: 20 }, + setupErrors: [{ pageId: 'p2', message: 'two' }], + instrumented: ['p2'], + dropped: { p2: 3 }, + }); + expect(pageResourceText('forms', state, 'p3')).toBeUndefined(); + }); + + it('keeps one page of the array states', () => { + const state = { + pages: [ + { pageId: 'p1', reportedAt: 1, navigations: [] }, + { pageId: 'p2', reportedAt: 2, navigations: [] }, + ], + }; + for (const name of ['router', 'ngrx-store'] as const) { + const json = JSON.parse(pageResourceText(name, state, 'p1')!); + expect( + json.pages.map((p: { pageId: string }) => p.pageId), + name, + ).toEqual(['p1']); + expect(pageResourceText(name, state, 'nope'), name).toBeUndefined(); + } + }); + + it('drops the panel selection from the record states', () => { + const state = { + pages: { p1: { pageId: 'p1', roots: tree('One'), environment: [], reportedAt: 1 } }, + selectedId: 'c9', + selectedNodeId: 'n1', + selectedInjectorId: 'i1', + }; + for (const name of ['component-tree', 'signal-graph', 'injector-tree'] as const) { + const json = JSON.parse(pageResourceText(name, state, 'p1')!); + expect(Object.keys(json.pages), name).toEqual(['p1']); + expect(json, name).not.toHaveProperty('selectedId'); + expect(json, name).not.toHaveProperty('selectedNodeId'); + expect(json, name).not.toHaveProperty('selectedInjectorId'); + } + }); +}); + +/** A shared state stand-in: `set` replaces the value and fires `updated`. */ +function fakeState(value: unknown) { + const listeners = new Set<() => void>(); + return { + value: () => value, + on: (_event: 'updated', listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + set(next: unknown) { + value = next; + for (const listener of listeners) listener(); + }, + listeners, + }; +} + +describe('createPageResourceSource', () => { + function source() { + const states = Object.fromEntries( + PAGE_RESOURCE_NAMES.map((name) => [ + name, + fakeState(name === 'forms' ? { pagesReportedAt: {} } : { pages: {} }), + ]), + ) as Record>; + const touch = vi.fn(); + return { states, touch, source: createPageResourceSource({ states, touch, debounceMs: 500 }) }; + } + + it('sends one change per window, and none for a push that keeps the pages', () => { + vi.useFakeTimers(); + const { states, source: pages } = source(); + const listener = vi.fn(); + const off = pages.onPagesChanged(['component-tree', 'forms'], listener); + const components = states['component-tree']; + + components.set({ pages: { p1: {} } }); + components.set({ pages: { p1: {}, p2: {} } }); + components.set({ pages: { p1: {}, p2: {} } }); + vi.advanceTimersByTime(499); + expect(listener).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(listener).toHaveBeenCalledTimes(1); + + // A fresh report from the same pages changes nothing. + components.set({ pages: { p1: { reportedAt: 2 }, p2: {} } }); + vi.advanceTimersByTime(1000); + expect(listener).toHaveBeenCalledTimes(1); + + // A page that expires is a change. + states.forms.set({ pagesReportedAt: { p1: 1 } }); + vi.advanceTimersByTime(500); + expect(listener).toHaveBeenCalledTimes(2); + + // Resources the templates do not offer are not watched. + states.router.set({ pages: [{ pageId: 'p9' }] }); + vi.advanceTimersByTime(500); + expect(listener).toHaveBeenCalledTimes(2); + + off(); + expect(components.listeners.size).toBe(0); + components.set({ pages: {} }); + vi.advanceTimersByTime(500); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it('renews the lease before a read, and answers undefined for an unknown page', async () => { + const { states, touch, source: pages } = source(); + states.router.set({ pages: [{ pageId: 'p1', navigations: [] }] }); + expect(await pages.read('router', 'p1')).toContain('"pageId":"p1"'); + expect(await pages.read('router', 'p2')).toBeUndefined(); + expect(touch).toHaveBeenCalledTimes(2); + expect(pages.pageIds('router')).toEqual(['p1']); + }); +}); + +describe('MCP resource templates over the hub endpoint', () => { + it('lists the templates', async () => { + const { call } = await bootHub(); + const init = await call<{ capabilities: Record }>('initialize', INITIALIZE); + expect(init.result?.capabilities.resources).toEqual({ listChanged: true }); + const list = await call('resources/templates/list'); + expect(list.result?.resourceTemplates.map((t) => t.uriTemplate)).toEqual(templateNames()); + expect(list.result?.resourceTemplates[0]).toMatchObject({ + name: 'pangular:page:component-tree', + title: 'Angular Component Tree of one page', + mimeType: 'application/json', + }); + }); + + it('reads each page through its template', async () => { + const { push, call } = await bootHub(); + await push('push-component-tree', { pageId: 'p1', roots: tree('First'), count: 1 }); + await push('push-component-tree', { pageId: 'p2', roots: tree('Second'), count: 1 }); + + const read = (uri: string) => call('resources/read', { uri }); + const one = (await read('pangular://page/p1/component-tree')).result!.contents[0]!; + const two = (await read('pangular://page/p2/component-tree')).result!.contents[0]!; + expect(one).toMatchObject({ uri: 'pangular://page/p1/component-tree' }); + expect(one.mimeType).toBe('application/json'); + expect(JSON.parse(one.text)).toMatchObject({ nodes: [{ name: 'First' }] }); + expect(Object.keys(JSON.parse(one.text).pages)).toEqual(['p1']); + expect(JSON.parse(two.text)).toMatchObject({ nodes: [{ name: 'Second' }] }); + + const unknown = (await read('pangular://page/zz/component-tree')).result!.contents[0]!; + expect(unknown.mimeType).toBe('text/markdown'); + expect(unknown.text).toContain('No page `zz` is reporting a component tree'); + expect(unknown.text).toContain('`p1`, `p2`'); + + // The static resource still reads every page. + const all = (await read('devframe://resource/pangular%3Acomponent-tree')).result!.contents[0]!; + expect(Object.keys(JSON.parse(all.text).pages).sort()).toEqual(['p1', 'p2']); + + // A URI the template does not match stays unknown. + expect((await read('pangular://page/p1/x/component-tree')).error).toBeDefined(); + }); + + it('names one concrete resource per reporting page in resources/list', async () => { + const { push, call } = await bootHub(); + await push('push-component-tree', { pageId: 'p1', roots: tree('First'), count: 1 }); + const uris = (await call('resources/list')).result!.resources.map((r) => r.uri); + expect(uris).toContain('pangular://page/p1/component-tree'); + expect(uris).not.toContain('pangular://page/p1/router'); + expect(uris).toContain('devframe://resource/pangular%3Acomponent-tree'); + }); + + it('leaves out the template of an inspector that is off', async () => { + const { call } = await bootHub({ inspectors: { components: false } }); + const list = await call('resources/templates/list'); + const uris = list.result!.resourceTemplates.map((t) => t.uriTemplate); + expect(uris).not.toContain('pangular://page/{pageId}/component-tree'); + expect(uris).toContain('pangular://page/{pageId}/router'); + const read = await call('resources/read', { uri: 'pangular://page/p1/component-tree' }); + expect(read.error?.message).toContain('unknown resource URI'); + }); + + it('serves no templates when the setting has none', async () => { + const { push, call } = await bootHub({ mcp: { authorization: false, resourceTemplates: [] } }); + await push('push-component-tree', { pageId: 'p1', roots: tree('First'), count: 1 }); + expect((await call('resources/templates/list')).error).toBeDefined(); + const uris = (await call('resources/list')).result!.resources.map((r) => r.uri); + expect(uris.some((uri) => uri.startsWith('pangular://'))).toBe(false); + expect(uris).toContain('devframe://resource/pangular%3Acomponent-tree'); + const read = await call('resources/read', { uri: 'pangular://page/p1/component-tree' }); + expect(read.error).toBeDefined(); + }); + + it('calls the list_changed hook when a page comes and goes', async () => { + const { ctx, push } = await bootHub(); + const notify = vi.fn(); + const off = pangularMcpPagesChanged(resolvePangularConfig())(notify, ctx as never); + closers.push(async () => off()); + await push('push-component-tree', { pageId: 'p1', roots: tree('First'), count: 1 }); + await vi.waitFor(() => expect(notify).toHaveBeenCalledTimes(1), { timeout: 2000 }); + await push('forget-component-page', 'p1'); + await vi.waitFor(() => expect(notify).toHaveBeenCalledTimes(2), { timeout: 2000 }); + }); +}); + +/** + * Open a 2026-07-28 `subscriptions/listen` stream for resource list changes. + * `next` resolves with the next notification method, or undefined after `ms`. + */ +async function listenForResources(send: Send) { + const abort = new AbortController(); + const response = await send( + new Request(HUB_MCP, { + method: 'POST', + signal: abort.signal, + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + origin: 'http://localhost:4000', + 'mcp-protocol-version': '2026-07-28', + 'mcp-method': 'subscriptions/listen', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'subscriptions/listen', + params: { + notifications: { resourcesListChanged: true }, + _meta: { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientCapabilities': {}, + }, + }, + }), + }), + ); + expect(response.headers.get('content-type')).toContain('text/event-stream'); + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let pending: Promise> | undefined; + const next = async (ms: number): Promise => { + pending ??= reader.read(); + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), ms); + }); + const chunk = await Promise.race([pending, timeout]); + clearTimeout(timer); + if (!chunk) return undefined; + pending = undefined; + const data = /^data: (.*)$/m.exec(decoder.decode(chunk.value))?.[1]; + return data ? (JSON.parse(data) as { method: string }).method : undefined; + }; + closers.push(async () => { + abort.abort(); + await reader.cancel().catch(() => {}); + }); + expect(await next(2000)).toBe('notifications/subscriptions/acknowledged'); + return next; +} + +describe('resources list_changed over the hub endpoint', () => { + const CHANGED = 'notifications/resources/list_changed'; + + it('fires when a page appears or goes, and not for a repeat push', async () => { + const { hub, push } = await bootHub(); + const next = await listenForResources((r) => hub.handler(r)); + await push('push-component-tree', { pageId: 'p1', roots: tree('First'), count: 1 }); + expect(await next(2000)).toBe(CHANGED); + await push('push-component-tree', { pageId: 'p1', roots: tree('Again'), count: 1 }); + expect(await next(1000)).toBeUndefined(); + await push('forget-component-page', 'p1'); + expect(await next(2000)).toBe(CHANGED); + }); + + it('comes from the page hook', async () => { + const { hub, push } = await bootHub({ + mcp: { authorization: false, onResourcesChanged: () => () => {} }, + }); + const next = await listenForResources((r) => hub.handler(r)); + await push('push-component-tree', { pageId: 'p1', roots: tree('First'), count: 1 }); + expect(await next(1000)).toBeUndefined(); + }); +}); + +describe('MCP resource templates over the pangular dev route', () => { + it('lists and reads them', async () => { + const config = resolvePangularConfig({ inspectors: { forms: false } }); + const server = await createDevServer(createPangular(config), { + host: '127.0.0.1', + port: 0, + auth: false, + mcp: pangularMcp(true, config), + }); + closers.push(() => server.close()); + const call = mcpClient(`${server.origin}/__mcp`, (request) => fetch(request)); + const list = await call('resources/templates/list'); + const uris = list.result!.resourceTemplates.map((t) => t.uriTemplate); + expect(uris).toContain('pangular://page/{pageId}/router'); + expect(uris).not.toContain('pangular://page/{pageId}/forms'); + const read = await call('resources/read', { uri: 'pangular://page/p1/router' }); + expect(read.result!.contents[0]!.text).toContain('No page reports router state right now.'); + }); +}); diff --git a/packages/devtools/src/agent-mcp.ts b/packages/devtools/src/agent-mcp.ts index 9dda54c5..353b167b 100644 --- a/packages/devtools/src/agent-mcp.ts +++ b/packages/devtools/src/agent-mcp.ts @@ -1,4 +1,5 @@ import type { McpSetting } from 'devframe/types'; +import { pangularMcpPagesChanged, pangularMcpResourceTemplates } from './agent-page-resources.ts'; import { pangularMcpPrompts } from './agent-prompts.ts'; import { sharedStateExposed, type ResolvedPangularConfig } from './config.ts'; @@ -27,16 +28,18 @@ export function pangularMcpInstructions(config: ResolvedPangularConfig): string /** * The `mcp` setting for a host that serves Pangular: the caller's setting * with the server instructions, the workflow prompts (`agent-prompts.ts`), - * and shared state limited to what `agent.tools` allows (see - * `sharedStateExposed`). `false` stays off. + * the per-page resource templates and their `list_changed` hook + * (`agent-page-resources.ts`), and shared state limited to what + * `agent.tools` allows (see `sharedStateExposed`). `false` stays off. * * `'auto'`, `true` and an omitted setting become an object, which always * mounts the route. That matches `'auto'` here: `@devframes/agentic` is a * dependency, and Pangular always registers agent tools. * * `exposeSharedState` is a devframe route option (1.2.4 and later), so the - * filter also holds for the published package. `instructions` and `prompts` - * still come from the patches in `patches/`; without them devframe ignores them. + * filter also holds for the published package. `instructions`, `prompts`, + * `resourceTemplates` and `onResourcesChanged` still come from the patches in + * `patches/`; without them devframe ignores them. */ export function pangularMcp( setting: McpSetting | undefined, @@ -50,6 +53,8 @@ export function pangularMcp( ...own, instructions: own.instructions ?? pangularMcpInstructions(config), prompts: own.prompts ?? pangularMcpPrompts(config), + resourceTemplates: own.resourceTemplates ?? pangularMcpResourceTemplates(config), + onResourcesChanged: own.onResourcesChanged ?? pangularMcpPagesChanged(config), exposeSharedState: theirs === false ? false diff --git a/packages/devtools/src/agent-page-resources.ts b/packages/devtools/src/agent-page-resources.ts new file mode 100644 index 00000000..1e95aafa --- /dev/null +++ b/packages/devtools/src/agent-page-resources.ts @@ -0,0 +1,304 @@ +import type { DevframeNodeContext, McpRouteOptions } from 'devframe/types'; +import { agentResourceOffered, type ResolvedPangularConfig } from './config.ts'; +import type { NgrxState } from './ngrx-shared.ts'; +import { code, formsResourceText, type FormsState } from './rpc/forms-tools.ts'; +import { LIVE_RESOURCE_NOTE, liveResourceText } from './rpc/resource-text.ts'; +import { routerResourceText, type RouterState } from './rpc/router-tools.ts'; +import type { ComponentPage, InjectorPage } from './types.ts'; + +/** One MCP resource template, as the patched `@devframes/agentic` server takes it. */ +export type PangularMcpResourceTemplate = NonNullable[number]; + +/** + * The page-scoped agent resources that get a per-page template, by their + * `AGENT_ENTRIES` name. Each one mirrors `pangular:` filtered to one page. + */ +export const PAGE_RESOURCE_NAMES = [ + 'component-tree', + 'signal-graph', + 'injector-tree', + 'ngrx-store', + 'forms', + 'router', +] as const; + +export type PageResourceName = (typeof PAGE_RESOURCE_NAMES)[number]; + +/** How long page changes settle before one `list_changed` goes out. */ +export const PAGE_LIST_DEBOUNCE_MS = 500; + +interface PageResourceInfo { + title: string; + /** What the page reports, for the unknown-page answer. */ + what: string; + description: string; +} + +const INFO: Record = { + 'component-tree': { + title: 'Angular Component Tree of one page', + what: 'a component tree', + description: `The pangular:component-tree resource for one page: its rendered instances, counts and the selected instance's detail, as JSON. ${LIVE_RESOURCE_NOTE}`, + }, + 'signal-graph': { + title: 'Angular Signal Graph of one page', + what: 'a signal graph', + description: `The pangular:signal-graph resource for one page: signal, computed, effect and linkedSignal nodes, their edges and recent values, as JSON. ${LIVE_RESOURCE_NOTE}`, + }, + 'injector-tree': { + title: 'Angular Injector Tree of one page', + what: 'an injector tree', + description: `The pangular:injector-tree resource for one page: element and environment injectors with their providers, and the zone mode, as JSON. ${LIVE_RESOURCE_NOTE}`, + }, + 'ngrx-store': { + title: 'NgRx Store State of one page', + what: 'NgRx state', + description: `The pangular:ngrx-store resource for one page: signal stores, classic store state and the change log, as JSON. ${LIVE_RESOURCE_NOTE}`, + }, + forms: { + title: 'Angular Forms of one page', + what: 'forms', + description: + "The pangular:forms resource for one page: each form's fields with value, status, touched, dirty and errors, plus recent changes, as JSON. The JSON starts with an `_untrusted` notice.", + }, + router: { + title: 'Angular Router of one page', + what: 'router state', + description: + 'The pangular:router resource for one page: the active route tree and recent navigations, as JSON. The JSON starts with an `_untrusted` notice.', + }, +}; + +/** The URI of `name` for one page. */ +export function pageResourceUri(name: PageResourceName, pageId: string): string { + return `pangular://page/${encodeURIComponent(pageId)}/${name}`; +} + +/** A shared state the source reads and watches; `value()` is the live state. */ +interface WatchedState { + value(): unknown; + on(event: 'updated', listener: () => void): () => void; +} + +/** What the devframe setup hands the templates: per-page reads over its live state. */ +export interface PageResourceSource { + /** The ids of the pages that report `name` right now, sorted. */ + pageIds(name: PageResourceName): string[]; + /** The resource text of `name` for one page, or undefined when that page does not report it. */ + read(name: PageResourceName, pageId: string): Promise; + /** + * Call `listener` when the pages that report any of `names` change: a page + * appears or expires. Changes settle for {@link PAGE_LIST_DEBOUNCE_MS} + * first, and a push that changes no page id calls nothing. + */ + onPagesChanged(names: readonly PageResourceName[], listener: () => void): () => void; +} + +type PageId = { pageId: string }; +type Pages = Record; + +const recordIds = (pages: unknown) => + pages && typeof pages === 'object' && !Array.isArray(pages) ? Object.keys(pages) : []; +const arrayIds = (pages: unknown) => + Array.isArray(pages) ? (pages as PageId[]).map((page) => page.pageId) : []; + +/** Page ids in one state, by resource. */ +function idsIn(name: PageResourceName, state: unknown): string[] { + const value = (state ?? {}) as Record; + switch (name) { + case 'ngrx-store': + case 'router': + return arrayIds(value['pages']); + case 'forms': + return recordIds(value['pagesReportedAt']); + default: + return recordIds(value['pages']); + } +} + +const formOn = (id: string, pageId: string) => id.endsWith(`@${pageId}`); + +/** The forms state cut down to one page; form ids end with `@`. */ +function formsOf(state: FormsState, pageId: string): FormsState { + const reportedAt = state.pagesReportedAt?.[pageId] ?? 0; + const dropped = state.dropped?.[pageId]; + return { + forms: state.forms.filter((form) => formOn(form.id, pageId)), + events: state.events.filter((event) => formOn(event.formId, pageId)), + reportedAt, + pagesReportedAt: { [pageId]: reportedAt }, + setupErrors: (state.setupErrors ?? []).filter((error) => error.pageId === pageId), + instrumented: (state.instrumented ?? []).filter((id) => id === pageId), + dropped: dropped === undefined ? {} : { [pageId]: dropped }, + ...(state.webMcp ? { webMcp: state.webMcp.filter((page) => page.pageId === pageId) } : {}), + }; +} + +/** + * The text of `name` for one page: the state of the matching `pangular:` + * resource with only that page in it, through the same formatter. Panel + * selection (`selectedId` and the like) is left out, since it belongs to no page. + */ +export function pageResourceText(name: PageResourceName, state: unknown, pageId: string) { + if (!idsIn(name, state).includes(pageId)) return undefined; + const value = state as Record; + switch (name) { + case 'component-tree': { + const page = (value['pages'] as Pages)[pageId]!; + return liveResourceText( + { nodes: page.roots, pages: { [pageId]: page } }, + 'pangular:list-components and pangular:inspect-component', + ); + } + case 'signal-graph': { + const graph = (value['pages'] as Pages)[pageId]; + return liveResourceText({ graph, pages: { [pageId]: graph } }, 'pangular:inspect-signals'); + } + case 'injector-tree': { + const page = (value['pages'] as Pages)[pageId]!; + return liveResourceText( + { + roots: page.roots, + environment: page.environment, + pages: { [pageId]: page }, + truncated: page.truncated === true, + zone: page.zone ?? null, + }, + 'pangular:inspect-providers', + ); + } + case 'ngrx-store': + return liveResourceText( + { pages: (value as unknown as NgrxState).pages.filter((page) => page.pageId === pageId) }, + 'pangular:inspect-signal-store and pangular:signal-store-history', + ); + case 'forms': + return formsResourceText(formsOf(state as FormsState, pageId)); + case 'router': + return routerResourceText({ + pages: (value as unknown as RouterState).pages.filter((page) => page.pageId === pageId), + }); + } +} + +/** + * Per-page reads over the six live shared states. `touch` renews the agent + * lease first, like the `pangular:*` resources do, so idle pages send fresh + * reports before the read. + */ +export function createPageResourceSource(options: { + states: Record; + touch: () => unknown; + debounceMs?: number; +}): PageResourceSource { + const { states, touch, debounceMs = PAGE_LIST_DEBOUNCE_MS } = options; + const pageIds = (name: PageResourceName) => idsIn(name, states[name].value()).sort(); + return { + pageIds, + async read(name, pageId) { + try { + await touch(); + } catch { + // The lease never fails a read. + } + return pageResourceText(name, states[name].value(), pageId); + }, + onPagesChanged(names, listener) { + const signature = () => names.map((name) => `${name}:${pageIds(name).join(',')}`).join('|'); + let last = signature(); + let timer: ReturnType | undefined; + const check = () => { + timer = undefined; + const next = signature(); + if (next === last) return; + last = next; + listener(); + }; + // Pushes come often; the page ids are compared once per window. + const schedule = () => { + if (timer) return; + timer = setTimeout(check, debounceMs); + timer.unref?.(); + }; + const offs = [...new Set(names)].map((name) => states[name].on('updated', schedule)); + return () => { + for (const off of offs) off(); + if (timer) clearTimeout(timer); + timer = undefined; + }; + }, + }; +} + +/** + * The source each devframe context serves, keyed by its agent host, which the + * setup and the MCP route share. The route options are built before setup + * runs, so the templates look the source up when they are read. + */ +const sources = new WeakMap(); + +export function providePageResources(ctx: { agent: object }, source: PageResourceSource): void { + sources.set(ctx.agent, source); +} + +const sourceOf = (ctx: DevframeNodeContext) => sources.get(ctx.agent); + +/** The answer for a page id that reports no such data. */ +function unknownPageResourceText(name: PageResourceName, pageId: string, others: string[]) { + const what = INFO[name].what; + const list = others.length + ? `Pages that report ${what}: ${others.map(code).join(', ')}. Read ${code(pageResourceUri(name, ''))} with one of these ids, or call pangular_list-pages.` + : `No page reports ${what} right now.`; + return `No page ${code(pageId)} is reporting ${what}. ${list}`; +} + +/** The resources each offered template stands for. */ +export function offeredPageResources(config: ResolvedPangularConfig): PageResourceName[] { + return PAGE_RESOURCE_NAMES.filter((name) => agentResourceOffered(name, config)); +} + +/** + * The per-page resource templates the MCP server offers under `config`: + * `pangular://page/{pageId}/` for each page-scoped resource that is on + * for agents (`inspectors`, `agent.tools`). `resources/list` also names one + * concrete resource per reporting page, for clients without template support. + */ +export function pangularMcpResourceTemplates( + config: ResolvedPangularConfig, +): PangularMcpResourceTemplate[] { + return offeredPageResources(config).map((name) => ({ + uriTemplate: `pangular://page/{pageId}/${name}`, + name: `pangular:page:${name}`, + title: INFO[name].title, + description: INFO[name].description, + mimeType: 'application/json', + list: (ctx) => + (sourceOf(ctx)?.pageIds(name) ?? []).map((pageId) => ({ + uri: pageResourceUri(name, pageId), + name: `pangular:page:${name}:${pageId}`, + title: `${INFO[name].title} (${pageId})`, + description: `${INFO[name].title}, for page ${pageId}.`, + mimeType: 'application/json', + })), + read: async ({ pageId = '' }, _uri, ctx) => { + const source = sourceOf(ctx); + const text = await source?.read(name, pageId); + if (text !== undefined) return { text }; + return { + text: unknownPageResourceText(name, pageId, source?.pageIds(name) ?? []), + mimeType: 'text/markdown', + }; + }, + })); +} + +/** + * The `onResourcesChanged` route option: one `list_changed` when the pages + * behind the offered templates change. + */ +export function pangularMcpPagesChanged( + config: ResolvedPangularConfig, +): NonNullable { + const names = offeredPageResources(config); + return (notify, ctx) => sourceOf(ctx)?.onPagesChanged(names, notify) ?? (() => {}); +} diff --git a/packages/devtools/src/config.ts b/packages/devtools/src/config.ts index c56ba22f..8c126910 100644 --- a/packages/devtools/src/config.ts +++ b/packages/devtools/src/config.ts @@ -1603,6 +1603,20 @@ export function agentToolOffered(name: string, config: ResolvedPangularConfig): return !inspector || config.inspectors[inspector]; } +/** + * Whether the built-in agent resource `name` (no `pangular:` prefix) is + * offered to agents under `config`, by the same rules as + * {@link agentToolOffered}. Each per-page MCP resource template follows the + * resource it mirrors through this check. + */ +export function agentResourceOffered(name: string, config: ResolvedPangularConfig): boolean { + const entry = agentEntry(name); + if (entry?.kind !== 'resource') return false; + if (!agentAllowed({ id: `pangular:${name}` }, config)) return false; + const inspector = entry.inspectors?.[0]; + return !inspector || config.inspectors[inspector]; +} + const INSPECTOR_LABEL: Record = { components: 'Components', injectors: 'Injectors', diff --git a/packages/devtools/src/devframe.ts b/packages/devtools/src/devframe.ts index 08b33d26..1100e318 100644 --- a/packages/devtools/src/devframe.ts +++ b/packages/devtools/src/devframe.ts @@ -292,6 +292,7 @@ import { } from './rpc/browser-tools.ts'; import pkg from '../package.json' with { type: 'json' }; import { INTEREST_TICK_MS, createPageInterest, leased } from './rpc/page-interest.ts'; +import { createPageResourceSource, providePageResources } from './agent-page-resources.ts'; import { createReportDeltas, deltaPush } from './rpc/report-deltas.ts'; import { CHANGE_DETECTION_SHAPE, @@ -2618,6 +2619,22 @@ const pangular = defineDevframe({ read: () => ({ text: routerResourceText(routerState.value() as RouterState) }), }); + // The same states one page at a time, for the MCP resource templates (`agent-mcp.ts`). + providePageResources( + ctx, + createPageResourceSource({ + states: { + 'component-tree': componentTree, + 'signal-graph': signalGraphState, + 'injector-tree': injectorTreeState, + 'ngrx-store': ngrxStoreState, + forms: formsState, + router: routerState, + }, + touch: () => interest.touch(), + }), + ); + const ngrxPageProperty = { type: 'string', description: 'Page id, when more than one tab reports. Defaults to every page.', diff --git a/patches/@devframes__agentic@1.2.5.patch b/patches/@devframes__agentic@1.2.5.patch index ebf6a724..9df9745e 100644 --- a/patches/@devframes__agentic@1.2.5.patch +++ b/patches/@devframes__agentic@1.2.5.patch @@ -1,5 +1,5 @@ diff --git a/dist/mcp/index.mjs b/dist/mcp/index.mjs -index 8d2d64dbe9397f74ccb8b9640605e0b1b38a3d40..e51cae93e96a8c5d7280d6ecc1d64de44b9b5a8c 100644 +index 8d2d64dbe9397f74ccb8b9640605e0b1b38a3d40..4c1085fd8814c1dd706007f16ee5f9ff6e93a0d9 100644 --- a/dist/mcp/index.mjs +++ b/dist/mcp/index.mjs @@ -1,6 +1,6 @@ @@ -10,7 +10,7 @@ index 8d2d64dbe9397f74ccb8b9640605e0b1b38a3d40..e51cae93e96a8c5d7280d6ecc1d64de4 import { DEVFRAME_EVENTS } from "devframe/constants"; import { argsToJsonSchema, diagnostics, formatMcpError, returnToJsonSchema, stringifyForMcp } from "devframe/internal"; import { createHostContext } from "devframe/node"; -@@ -26,12 +26,17 @@ function buildMcpServerFromContext(ctx, options) { +@@ -26,12 +26,19 @@ function buildMcpServerFromContext(ctx, options) { const server = new Server({ name: options.serverName, version: options.serverVersion @@ -26,24 +26,66 @@ index 8d2d64dbe9397f74ccb8b9640605e0b1b38a3d40..e51cae93e96a8c5d7280d6ecc1d64de4 + }, + ...options.instructions ? { instructions: options.instructions } : {} + }); ++ if (options.resourceTemplates?.length) serverResourceTemplates.set(server, compileResourceTemplates(options.resourceTemplates)); registerToolHandlers(server, ctx, options.exposeSharedState); registerResourceHandlers(server, ctx, options.exposeSharedState); + if (options.prompts?.length) registerPromptHandlers(server, options.prompts); ++ if (options.resourceTemplates?.length) registerResourceTemplateHandlers(server); return server; } /** -@@ -89,7 +94,9 @@ async function createMcpServer(definition, options = {}) { +@@ -44,9 +51,13 @@ function buildMcpServerFromContext(ctx, options) { + * passes the pinned server's `send*ListChanged` methods, which `serveStdio` + * routes onto the connection's active `subscriptions/listen` streams. + * ++* A host's own `onResourcesChanged` subscription (a route option) feeds ++* `resources()` too, for resources that come and go with the host's data, ++* such as the concrete entries of a resource template. ++* + * @internal + */ +-function bridgeListChanged(ctx, sinks) { ++function bridgeListChanged(ctx, sinks, onResourcesChanged) { + const offManifest = ctx.agent.events.on(DEVFRAME_EVENTS.bus.agentManifestChanged, () => { + sinks.tools(); + sinks.resources(); +@@ -54,9 +65,13 @@ function bridgeListChanged(ctx, sinks) { + const offKeyAdded = ctx.rpc.sharedState.onKeyAdded(() => { + sinks.resources(); + }); ++ const offHost = onResourcesChanged?.(() => { ++ sinks.resources(); ++ }, ctx); + return () => { + offManifest(); + offKeyAdded(); ++ if (typeof offHost === "function") offHost(); + }; + } + /** +@@ -89,7 +104,11 @@ async function createMcpServer(definition, options = {}) { const buildOptions = { serverName: options.serverName ?? `${definition.id} (devframe)`, serverVersion: options.serverVersion ?? definition.version ?? "0.0.0", - exposeSharedState: options.exposeSharedState ?? true + exposeSharedState: options.exposeSharedState ?? true, + instructions: options.instructions, -+ prompts: options.prompts ++ prompts: options.prompts, ++ resourceTemplates: options.resourceTemplates, ++ onResourcesChanged: options.onResourcesChanged }; let handle; try { -@@ -165,6 +172,25 @@ async function readStateResult(ctx, filter, key) { +@@ -103,7 +122,7 @@ async function createMcpServer(definition, options = {}) { + resources: () => { + server.sendResourceListChanged().catch(() => {}); + } +- }); ++ }, buildOptions.onResourcesChanged); + const priorOnClose = server.onclose; + server.onclose = () => { + unbridge(); +@@ -165,6 +184,25 @@ async function readStateResult(ctx, filter, key) { value: (await ctx.rpc.sharedState.get(key)).value() }; } @@ -69,7 +111,7 @@ index 8d2d64dbe9397f74ccb8b9640605e0b1b38a3d40..e51cae93e96a8c5d7280d6ecc1d64de4 function registerToolHandlers(server, ctx, exposeSharedState) { const stateFilter = sharedStateFilter(exposeSharedState); const warnedCollisions = /* @__PURE__ */ new Set(); -@@ -216,6 +242,8 @@ function registerToolHandlers(server, ctx, exposeSharedState) { +@@ -216,6 +254,8 @@ function registerToolHandlers(server, ctx, exposeSharedState) { } const outputSchema = tool ? usableOutputSchema(tool.outputSchema ?? computeOutputSchema(tool, ctx)) : void 0; const result = await ctx.agent.invoke(tool?.id ?? name, args ?? {}); @@ -78,10 +120,114 @@ index 8d2d64dbe9397f74ccb8b9640605e0b1b38a3d40..e51cae93e96a8c5d7280d6ecc1d64de4 return { content: [{ type: "text", -@@ -278,6 +306,39 @@ function registerResourceHandlers(server, ctx, exposeSharedState) { +@@ -252,6 +292,7 @@ function registerResourceHandlers(server, ctx, exposeSharedState) { + mimeType: "application/json" + }); + } ++ for (const entry of await listTemplateResources(server, ctx)) resources.push(entry); + return { resources }; + }); + server.setRequestHandler("resources/read", async (request) => { +@@ -274,10 +315,135 @@ function registerResourceHandlers(server, ctx, exposeSharedState) { + text: stringifyForMcp(state.value()) + }] }; + } ++ const templated = await readTemplateResource(server, ctx, uri); ++ if (templated) return templated; + throw new Error(`[devframe/mcp] unknown resource URI "${uri}"`); }); } /** ++* Resource templates per server, set by `buildMcpServerFromContext` from the ++* `resourceTemplates` option before the resource handlers run. ++*/ ++const serverResourceTemplates = /* @__PURE__ */ new WeakMap(); ++/** ++* Turn each template's RFC 6570 level-1 URI template (`{name}` expressions ++* only) into a matcher. A variable matches one or more characters other than ++* `/`, and its value is percent-decoded. A template with another kind of ++* expression (`{+path}`, `{?q}`) never matches. ++*/ ++function compileResourceTemplates(templates) { ++ return templates.map((template) => { ++ const names = []; ++ let source = "^"; ++ let last = 0; ++ let valid = true; ++ for (const match of template.uriTemplate.matchAll(/\{([^{}]*)\}/g)) { ++ if (!/^[A-Za-z0-9_]+$/.test(match[1])) valid = false; ++ source += escapeRegExp(template.uriTemplate.slice(last, match.index)); ++ source += "([^/]+)"; ++ names.push(match[1]); ++ last = match.index + match[0].length; ++ } ++ source += escapeRegExp(template.uriTemplate.slice(last)) + "$"; ++ const pattern = valid ? new RegExp(source) : void 0; ++ return { ++ template, ++ match: (uri) => { ++ const found = pattern?.exec(uri); ++ if (!found) return void 0; ++ const params = {}; ++ try { ++ names.forEach((name, index) => { ++ params[name] = decodeURIComponent(found[index + 1]); ++ }); ++ } catch { ++ return void 0; ++ } ++ return Object.values(params).some((value) => value.includes("/")) ? void 0 : params; ++ } ++ }; ++ }); ++} ++function escapeRegExp(text) { ++ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); ++} ++/** The concrete resources each template's `list` names, for `resources/list`. */ ++async function listTemplateResources(server, ctx) { ++ const compiled = serverResourceTemplates.get(server) ?? []; ++ const entries = []; ++ for (const { template } of compiled) { ++ if (!template.list) continue; ++ for (const entry of await template.list(ctx)) entries.push({ ++ uri: entry.uri, ++ name: entry.name, ++ ...entry.title !== void 0 ? { title: entry.title } : {}, ++ ...entry.description !== void 0 ? { description: entry.description } : {}, ++ mimeType: entry.mimeType ?? template.mimeType ?? "application/json" ++ }); ++ } ++ return entries; ++} ++/** ++* Read `uri` through the first template that matches it, or `undefined` when ++* none does. A failing `read` is reported like a failing resource read. ++*/ ++async function readTemplateResource(server, ctx, uri) { ++ for (const { template, match } of serverResourceTemplates.get(server) ?? []) { ++ const params = match(uri); ++ if (!params) continue; ++ const content = await template.read(params, uri, ctx); ++ return { contents: [{ ++ uri, ++ mimeType: content.mimeType ?? template.mimeType ?? "application/json", ++ text: content.text ?? stringifyForMcp(content.json) ++ }] }; ++ } ++ return void 0; ++} ++/** Serve `resources/templates/list` from the `resourceTemplates` option. */ ++function registerResourceTemplateHandlers(server) { ++ server.setRequestHandler("resources/templates/list", async () => ({ resourceTemplates: (serverResourceTemplates.get(server) ?? []).map(({ template }) => ({ ++ uriTemplate: template.uriTemplate, ++ name: template.name, ++ ...template.title !== void 0 ? { title: template.title } : {}, ++ ...template.description !== void 0 ? { description: template.description } : {}, ++ ...template.mimeType !== void 0 ? { mimeType: template.mimeType } : {} ++ })) })); ++} ++/** +* Serve a fixed list of prompt templates. `prompts/list` projects each one; +* `prompts/get` checks the required arguments and returns the messages its +* `get` builds. An unknown name or a missing required argument is an @@ -118,14 +264,24 @@ index 8d2d64dbe9397f74ccb8b9640605e0b1b38a3d40..e51cae93e96a8c5d7280d6ecc1d64de4 * MCP constrains a tool's `outputSchema` to a JSON Schema of `type: * "object"`; clients (the SDK included) reject anything else. Non-object * return schemas (e.g. a schema for `void` / a bare string) simply project -@@ -398,7 +459,9 @@ function createMcpFetchHandler(ctx, options) { +@@ -398,7 +564,10 @@ function createMcpFetchHandler(ctx, options) { const handler = createMcpHandler(() => buildMcpServerFromContext(ctx, { serverName: options.serverName, serverVersion: options.serverVersion, - exposeSharedState: options.exposeSharedState + exposeSharedState: options.exposeSharedState, + instructions: options.instructions, -+ prompts: options.prompts ++ prompts: options.prompts, ++ resourceTemplates: options.resourceTemplates })); const unbridge = bridgeListChanged(ctx, { tools: () => { +@@ -407,7 +576,7 @@ function createMcpFetchHandler(ctx, options) { + resources: () => { + handler.notify.resourcesChanged(); + } +- }); ++ }, options.onResourcesChanged); + const originOnlyDefault = allowedOrigins === void 0 && authorization === false; + async function handle(req, connection) { + const origin = req.headers.get("origin") ?? void 0; diff --git a/patches/@devframes__hub@1.2.5.patch b/patches/@devframes__hub@1.2.5.patch index 559d5b04..92012a8a 100644 --- a/patches/@devframes__hub@1.2.5.patch +++ b/patches/@devframes__hub@1.2.5.patch @@ -1,13 +1,15 @@ diff --git a/dist/node/initiate.mjs b/dist/node/initiate.mjs -index d1bff38cf29d5003365d2baddc36d7e11c5707ab..da6b8b66e7ad6e0607624a78ec342f593eeafee0 100644 +index d1bff38cf29d5003365d2baddc36d7e11c5707ab..42c9eb0fd5d4c584c4f411dd7e4e35d38bd2b649 100644 --- a/dist/node/initiate.mjs +++ b/dist/node/initiate.mjs -@@ -150,6 +150,8 @@ function initHub(options) { +@@ -150,6 +150,10 @@ function initHub(options) { serverName: options.name ?? "devframes-hub", serverVersion: options.version ?? "0.0.0", exposeSharedState: mcpConfig.exposeSharedState ?? true, + instructions: mcpConfig.instructions, + prompts: mcpConfig.prompts, ++ resourceTemplates: mcpConfig.resourceTemplates, ++ onResourcesChanged: mcpConfig.onResourcesChanged, authorization: mcpConfig.authorization, allowedOrigins: mcpConfig.allowedOrigins }); diff --git a/patches/devframe@1.2.5.patch b/patches/devframe@1.2.5.patch index 11d066c4..599809b0 100644 --- a/patches/devframe@1.2.5.patch +++ b/patches/devframe@1.2.5.patch @@ -1,18 +1,20 @@ diff --git a/dist/_shared-DlRoV7v3.mjs b/dist/_shared-DlRoV7v3.mjs -index 6f112f6e47f992e17597b8e949e6d3cadcf34c4c..36e20be540fa32ccd3b67a82548f0c5332d8c510 100644 +index 6f112f6e47f992e17597b8e949e6d3cadcf34c4c..f0e11845ca39dc7d42de9e6c6e4e3ce6ad504f50 100644 --- a/dist/_shared-DlRoV7v3.mjs +++ b/dist/_shared-DlRoV7v3.mjs -@@ -144,6 +144,8 @@ function resolveMcpConfig(mcp) { +@@ -144,6 +144,10 @@ function resolveMcpConfig(mcp) { ...mcp.path !== void 0 ? { path: mcp.path } : {}, ...mcp.allowedOrigins !== void 0 ? { allowedOrigins: mcp.allowedOrigins } : {}, ...mcp.exposeSharedState !== void 0 ? { exposeSharedState: mcp.exposeSharedState } : {}, + ...mcp.instructions !== void 0 ? { instructions: mcp.instructions } : {}, + ...mcp.prompts !== void 0 ? { prompts: mcp.prompts } : {}, ++ ...mcp.resourceTemplates !== void 0 ? { resourceTemplates: mcp.resourceTemplates } : {}, ++ ...mcp.onResourcesChanged !== void 0 ? { onResourcesChanged: mcp.onResourcesChanged } : {}, authorization }; } diff --git a/dist/_shared-NSl4p_om.d.mts b/dist/_shared-NSl4p_om.d.mts -index d6f43f5913434da962a95c3fe7e075a5edef546d..172afc14f9cb80ef26add073e04c0e23a77b5e79 100644 +index d6f43f5913434da962a95c3fe7e075a5edef546d..8047bba60d73846022a0b7aaeef0b4e6c0380354 100644 --- a/dist/_shared-NSl4p_om.d.mts +++ b/dist/_shared-NSl4p_om.d.mts @@ -1,4 +1,4 @@ @@ -21,7 +23,7 @@ index d6f43f5913434da962a95c3fe7e075a5edef546d..172afc14f9cb80ef26add073e04c0e23 import { t as AgenticMcpModule } from "./agentic-D61fXLzA.mjs"; //#region src/adapters/_shared.d.ts /** -@@ -37,6 +37,10 @@ interface ResolvedMcpConfig { +@@ -37,6 +37,13 @@ interface ResolvedMcpConfig { allowedOrigins?: readonly string[] | false; /** Shared-state exposure from the route options; omitted means every key. */ exposeSharedState?: boolean | ((key: string) => boolean); @@ -29,14 +31,17 @@ index d6f43f5913434da962a95c3fe7e075a5edef546d..172afc14f9cb80ef26add073e04c0e23 + instructions?: string; + /** Prompt templates served through `prompts/list` and `prompts/get`. */ + prompts?: McpRouteOptions['prompts']; ++ /** Resource templates, and the host's resource-change subscription. */ ++ resourceTemplates?: McpRouteOptions['resourceTemplates']; ++ onResourcesChanged?: McpRouteOptions['onResourcesChanged']; /** The resolved identity policy: a bearer token, callback, or `false`. */ authorization: McpAuthorization; } diff --git a/dist/context-DhycZ2lx.d.mts b/dist/context-DhycZ2lx.d.mts -index 2f3e7035b31326e1de8c19526c8683896e9dbc30..fea92d92926b26db391bacaed444c081d0dd90df 100644 +index 2f3e7035b31326e1de8c19526c8683896e9dbc30..f7ee324383715c223baf5f907599988078a7703b 100644 --- a/dist/context-DhycZ2lx.d.mts +++ b/dist/context-DhycZ2lx.d.mts -@@ -1289,6 +1289,10 @@ interface McpRouteOptions { +@@ -1289,6 +1289,22 @@ interface McpRouteOptions { * The states themselves stay available to the UI over RPC. */ exposeSharedState?: boolean | ((key: string) => boolean); @@ -44,10 +49,22 @@ index 2f3e7035b31326e1de8c19526c8683896e9dbc30..fea92d92926b26db391bacaed444c081 + instructions?: string; + /** Prompt templates served through `prompts/list` and `prompts/get`; omitted means no `prompts` capability. */ + prompts?: readonly McpPrompt[]; ++ /** ++ * Resource templates served through `resources/templates/list`, read through ++ * `resources/read` when no other resource matches, with the concrete entries ++ * of each template's `list` added to `resources/list`. ++ */ ++ resourceTemplates?: readonly McpResourceTemplate[]; ++ /** ++ * Subscribe to changes in the resources the host serves outside the agent ++ * manifest (template entries, say). Each `notify()` sends ++ * `notifications/resources/list_changed`. Returns the unsubscribe function. ++ */ ++ onResourcesChanged?: (notify: () => void, ctx: DevframeNodeContext) => () => void; } interface DevframeCliOptions { /** Binary name; default: the devframe's `id`. */ -@@ -1601,6 +1605,38 @@ type DevframeSnapshotRpcEntry = string | { +@@ -1601,6 +1617,70 @@ type DevframeSnapshotRpcEntry = string | { * `devframe/internal`) instead: `devframe/types` must stay lib-neutral, and * h3's declarations are not. */ @@ -82,11 +99,43 @@ index 2f3e7035b31326e1de8c19526c8683896e9dbc30..fea92d92926b26db391bacaed444c081 + arguments?: readonly McpPromptArgument[]; + /** Build the messages from the arguments the client sent. */ + get: (args: Record) => McpPromptResult | Promise; ++} ++/** One concrete resource a {@link McpResourceTemplate} lists. */ ++interface McpResourceTemplateEntry { ++ uri: string; ++ name: string; ++ title?: string; ++ description?: string; ++ mimeType?: string; ++} ++/** What a {@link McpResourceTemplate} returns for one URI: `text`, or `json` to stringify. */ ++interface McpResourceTemplateContent { ++ text?: string; ++ json?: unknown; ++ /** Defaults to the template's `mimeType`, then `application/json`. */ ++ mimeType?: string; ++} ++/** ++ * A resource template (RFC 6570 level 1: `{name}` expressions only) the MCP ++ * server lists in `resources/templates/list` and reads in `resources/read`. ++ * A variable matches one URI segment (no `/`) and is percent-decoded. ++ */ ++interface McpResourceTemplate { ++ /** Such as `myapp://page/{pageId}/state`. */ ++ uriTemplate: string; ++ name: string; ++ title?: string; ++ description?: string; ++ mimeType?: string; ++ /** The concrete resources it stands for right now, added to `resources/list`. */ ++ list?: (ctx: DevframeNodeContext) => readonly McpResourceTemplateEntry[] | Promise; ++ /** Read one URI that matched, with its decoded variables. */ ++ read: (params: Record, uri: string, ctx: DevframeNodeContext) => McpResourceTemplateContent | Promise; +} interface CreateMcpServerOptions { /** * Transport to use. `createMcpServer` itself runs `'stdio'` (a standalone -@@ -1616,6 +1652,10 @@ interface CreateMcpServerOptions { +@@ -1616,6 +1696,22 @@ interface CreateMcpServerOptions { * - `(key) => boolean`: filter */ exposeSharedState?: boolean | ((key: string) => boolean); @@ -94,10 +143,22 @@ index 2f3e7035b31326e1de8c19526c8683896e9dbc30..fea92d92926b26db391bacaed444c081 + instructions?: string; + /** Prompt templates served through `prompts/list` and `prompts/get`; omitted means no `prompts` capability. */ + prompts?: readonly McpPrompt[]; ++ /** ++ * Resource templates served through `resources/templates/list`, read through ++ * `resources/read` when no other resource matches, with the concrete entries ++ * of each template's `list` added to `resources/list`. ++ */ ++ resourceTemplates?: readonly McpResourceTemplate[]; ++ /** ++ * Subscribe to changes in the resources the host serves outside the agent ++ * manifest (template entries, say). Each `notify()` sends ++ * `notifications/resources/list_changed`. Returns the unsubscribe function. ++ */ ++ onResourcesChanged?: (notify: () => void, ctx: DevframeNodeContext) => () => void; /** Override the name reported in the MCP handshake. */ serverName?: string; /** Override the version reported in the MCP handshake. Defaults to `definition.version ?? '0.0.0'`. */ -@@ -1635,6 +1675,10 @@ interface CreateMcpFetchHandlerOptions { +@@ -1635,6 +1731,22 @@ interface CreateMcpFetchHandlerOptions { serverVersion: string; /** Expose shared-state keys as MCP resources; see {@link CreateMcpServerOptions.exposeSharedState}. */ exposeSharedState: boolean | ((key: string) => boolean); @@ -105,19 +166,33 @@ index 2f3e7035b31326e1de8c19526c8683896e9dbc30..fea92d92926b26db391bacaed444c081 + instructions?: string; + /** Prompt templates served through `prompts/list` and `prompts/get`; omitted means no `prompts` capability. */ + prompts?: readonly McpPrompt[]; ++ /** ++ * Resource templates served through `resources/templates/list`, read through ++ * `resources/read` when no other resource matches, with the concrete entries ++ * of each template's `list` added to `resources/list`. ++ */ ++ resourceTemplates?: readonly McpResourceTemplate[]; ++ /** ++ * Subscribe to changes in the resources the host serves outside the agent ++ * manifest (template entries, say). Each `notify()` sends ++ * `notifications/resources/list_changed`. Returns the unsubscribe function. ++ */ ++ onResourcesChanged?: (notify: () => void, ctx: DevframeNodeContext) => () => void; /** * Optional identity check, layered on top of the origin gate and checked * **after** it: a bearer token string (matched in constant time against diff --git a/dist/dev-BViAfnlw.mjs b/dist/dev-BViAfnlw.mjs -index 19f385cf45e59be7c0508a3c5a255f694003a347..13e4b8479c03c793b542a0901aaf73fd2060690b 100644 +index 19f385cf45e59be7c0508a3c5a255f694003a347..c5dfbd5067baa74d859b8ae5b59f0f11807da67c 100644 --- a/dist/dev-BViAfnlw.mjs +++ b/dist/dev-BViAfnlw.mjs -@@ -140,6 +140,8 @@ async function mountMcpRoute(app, context, def, base, setting) { +@@ -140,6 +140,10 @@ async function mountMcpRoute(app, context, def, base, setting) { serverName: `${def.id} (devframe)`, serverVersion: def.version ?? "0.0.0", exposeSharedState: config.exposeSharedState ?? true, + instructions: config.instructions, + prompts: config.prompts, ++ resourceTemplates: config.resourceTemplates, ++ onResourcesChanged: config.onResourcesChanged, authorization: config.authorization, allowedOrigins: config.allowedOrigins }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7dd4525..5565bdc2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,13 +8,13 @@ packageExtensionsChecksum: sha256-pZ5Yb81CXMsN4lVTkqET1vwZkSfW0B8hDR3b6oB8KVw= patchedDependencies: '@devframes/agentic@1.2.5': - hash: c31b74d2f6d91c729d3f6d5d98612871163019427da960f0c7fde5e90c95714d + hash: cb2492340202dd7496f3903f523d04b3880c16c7acb87da996d5ee319fd1c81a path: patches/@devframes__agentic@1.2.5.patch '@devframes/hub@1.2.5': - hash: 4adf20d028ae064225ce64077943909413eb59a0743124130d5cba7396ddc46c + hash: ac0ddf7ffd9cb52dbfd779b287609f504d161a035199e9e45bb9fc3c20121bce path: patches/@devframes__hub@1.2.5.patch devframe@1.2.5: - hash: c86b8ae2a19f79528c181ec03a959472187296172208299dbc83a2d5f2c01b2a + hash: a79d264e0ed1c42d99d847d5a873fbf5919ce5b5aae14d26518597683d637c54 path: patches/devframe@1.2.5.patch importers: @@ -53,7 +53,7 @@ importers: version: link:packages/devtools devframe: specifier: ^1.2.5 - version: 1.2.5(patch_hash=c86b8ae2a19f79528c181ec03a959472187296172208299dbc83a2d5f2c01b2a)(@devframes/agentic@1.2.5)(cac@7.0.0)(srvx@1.0.5) + version: 1.2.5(patch_hash=a79d264e0ed1c42d99d847d5a873fbf5919ce5b5aae14d26518597683d637c54)(@devframes/agentic@1.2.5)(cac@7.0.0)(srvx@1.0.5) express: specifier: ^5.1.0 version: 5.2.1 @@ -90,10 +90,10 @@ importers: version: 4.13.0(playwright-core@1.63.0) '@devframes/agentic': specifier: ^1.2.5 - version: 1.2.5(patch_hash=c31b74d2f6d91c729d3f6d5d98612871163019427da960f0c7fde5e90c95714d)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5) + version: 1.2.5(patch_hash=cb2492340202dd7496f3903f523d04b3880c16c7acb87da996d5ee319fd1c81a)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5) '@devframes/vite': specifier: ^1.2.5 - version: 1.2.5(@devframes/hub-ui@1.2.5(@devframes/hub@1.2.5(patch_hash=4adf20d028ae064225ce64077943909413eb59a0743124130d5cba7396ddc46c)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(devframe@1.2.5))(@devframes/hub@1.2.5(patch_hash=4adf20d028ae064225ce64077943909413eb59a0743124130d5cba7396ddc46c)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(devframe@1.2.5)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) + version: 1.2.5(@devframes/hub-ui@1.2.5(@devframes/hub@1.2.5(patch_hash=ac0ddf7ffd9cb52dbfd779b287609f504d161a035199e9e45bb9fc3c20121bce)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(devframe@1.2.5))(@devframes/hub@1.2.5(patch_hash=ac0ddf7ffd9cb52dbfd779b287609f504d161a035199e9e45bb9fc3c20121bce)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(devframe@1.2.5)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) '@ngrx/store': specifier: ^22.0.1 version: 22.0.1(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) @@ -356,22 +356,22 @@ importers: dependencies: '@devframes/agentic': specifier: ^1.2.5 - version: 1.2.5(patch_hash=c31b74d2f6d91c729d3f6d5d98612871163019427da960f0c7fde5e90c95714d)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5) + version: 1.2.5(patch_hash=cb2492340202dd7496f3903f523d04b3880c16c7acb87da996d5ee319fd1c81a)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5) '@devframes/hub': specifier: ^1.2.5 - version: 1.2.5(patch_hash=4adf20d028ae064225ce64077943909413eb59a0743124130d5cba7396ddc46c)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5) + version: 1.2.5(patch_hash=ac0ddf7ffd9cb52dbfd779b287609f504d161a035199e9e45bb9fc3c20121bce)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5) '@devframes/hub-ui': specifier: ^1.2.5 - version: 1.2.5(@devframes/hub@1.2.5(patch_hash=4adf20d028ae064225ce64077943909413eb59a0743124130d5cba7396ddc46c)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(devframe@1.2.5) + version: 1.2.5(@devframes/hub@1.2.5(patch_hash=ac0ddf7ffd9cb52dbfd779b287609f504d161a035199e9e45bb9fc3c20121bce)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(devframe@1.2.5) '@devframes/plugin-messages': specifier: ^1.2.5 - version: 1.2.5(@devframes/hub@1.2.5(patch_hash=4adf20d028ae064225ce64077943909413eb59a0743124130d5cba7396ddc46c)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(@devframes/plugin-messages--assets@1.2.5)(devframe@1.2.5)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) + version: 1.2.5(@devframes/hub@1.2.5(patch_hash=ac0ddf7ffd9cb52dbfd779b287609f504d161a035199e9e45bb9fc3c20121bce)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(@devframes/plugin-messages--assets@1.2.5)(devframe@1.2.5)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) '@devframes/plugin-messages--assets': specifier: ^1.2.5 version: 1.2.5 '@devframes/plugin-terminals': specifier: ^1.2.5 - version: 1.2.5(@devframes/hub-ui@1.2.5(@devframes/hub@1.2.5(patch_hash=4adf20d028ae064225ce64077943909413eb59a0743124130d5cba7396ddc46c)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(devframe@1.2.5))(@devframes/hub@1.2.5(patch_hash=4adf20d028ae064225ce64077943909413eb59a0743124130d5cba7396ddc46c)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(@devframes/plugin-terminals--assets@1.2.5)(devframe@1.2.5)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) + version: 1.2.5(@devframes/hub-ui@1.2.5(@devframes/hub@1.2.5(patch_hash=ac0ddf7ffd9cb52dbfd779b287609f504d161a035199e9e45bb9fc3c20121bce)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(devframe@1.2.5))(@devframes/hub@1.2.5(patch_hash=ac0ddf7ffd9cb52dbfd779b287609f504d161a035199e9e45bb9fc3c20121bce)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(@devframes/plugin-terminals--assets@1.2.5)(devframe@1.2.5)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) '@devframes/plugin-terminals--assets': specifier: ^1.2.5 version: 1.2.5 @@ -386,7 +386,7 @@ importers: version: 7.0.0 devframe: specifier: ^1.2.5 - version: 1.2.5(patch_hash=c86b8ae2a19f79528c181ec03a959472187296172208299dbc83a2d5f2c01b2a)(@devframes/agentic@1.2.5)(cac@7.0.0)(srvx@1.0.5) + version: 1.2.5(patch_hash=a79d264e0ed1c42d99d847d5a873fbf5919ce5b5aae14d26518597683d637c54)(@devframes/agentic@1.2.5)(cac@7.0.0)(srvx@1.0.5) h3: specifier: ^1.15.11 version: 1.15.11 @@ -9069,55 +9069,55 @@ snapshots: '@csstools/css-tokenizer@4.0.1': {} - '@devframes/agentic@1.2.5(patch_hash=c31b74d2f6d91c729d3f6d5d98612871163019427da960f0c7fde5e90c95714d)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5)': + '@devframes/agentic@1.2.5(patch_hash=cb2492340202dd7496f3903f523d04b3880c16c7acb87da996d5ee319fd1c81a)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5)': dependencies: '@modelcontextprotocol/client': 2.3.1 '@modelcontextprotocol/server': 2.3.1 - devframe: 1.2.5(patch_hash=c86b8ae2a19f79528c181ec03a959472187296172208299dbc83a2d5f2c01b2a)(@devframes/agentic@1.2.5)(cac@7.0.0)(srvx@1.0.5) + devframe: 1.2.5(patch_hash=a79d264e0ed1c42d99d847d5a873fbf5919ce5b5aae14d26518597683d637c54)(@devframes/agentic@1.2.5)(cac@7.0.0)(srvx@1.0.5) h3: 2.0.1(crossws@0.4.12(srvx@1.0.5)) pathe: 2.0.3 transitivePeerDependencies: - crossws - ocache - '@devframes/hub-ui@1.2.5(@devframes/hub@1.2.5(patch_hash=4adf20d028ae064225ce64077943909413eb59a0743124130d5cba7396ddc46c)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(devframe@1.2.5)': + '@devframes/hub-ui@1.2.5(@devframes/hub@1.2.5(patch_hash=ac0ddf7ffd9cb52dbfd779b287609f504d161a035199e9e45bb9fc3c20121bce)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(devframe@1.2.5)': dependencies: - '@devframes/hub': 1.2.5(patch_hash=4adf20d028ae064225ce64077943909413eb59a0743124130d5cba7396ddc46c)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5) - devframe: 1.2.5(patch_hash=c86b8ae2a19f79528c181ec03a959472187296172208299dbc83a2d5f2c01b2a)(@devframes/agentic@1.2.5)(cac@7.0.0)(srvx@1.0.5) + '@devframes/hub': 1.2.5(patch_hash=ac0ddf7ffd9cb52dbfd779b287609f504d161a035199e9e45bb9fc3c20121bce)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5) + devframe: 1.2.5(patch_hash=a79d264e0ed1c42d99d847d5a873fbf5919ce5b5aae14d26518597683d637c54)(@devframes/agentic@1.2.5)(cac@7.0.0)(srvx@1.0.5) - '@devframes/hub@1.2.5(patch_hash=4adf20d028ae064225ce64077943909413eb59a0743124130d5cba7396ddc46c)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5)': + '@devframes/hub@1.2.5(patch_hash=ac0ddf7ffd9cb52dbfd779b287609f504d161a035199e9e45bb9fc3c20121bce)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5)': dependencies: destr: 2.0.5 - devframe: 1.2.5(patch_hash=c86b8ae2a19f79528c181ec03a959472187296172208299dbc83a2d5f2c01b2a)(@devframes/agentic@1.2.5)(cac@7.0.0)(srvx@1.0.5) + devframe: 1.2.5(patch_hash=a79d264e0ed1c42d99d847d5a873fbf5919ce5b5aae14d26518597683d637c54)(@devframes/agentic@1.2.5)(cac@7.0.0)(srvx@1.0.5) h3: 2.0.1(crossws@0.4.12(srvx@1.0.5)) pathe: 2.0.3 tinyexec: 1.3.1 zigpty: 0.2.1 optionalDependencies: - '@devframes/agentic': 1.2.5(patch_hash=c31b74d2f6d91c729d3f6d5d98612871163019427da960f0c7fde5e90c95714d)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5) + '@devframes/agentic': 1.2.5(patch_hash=cb2492340202dd7496f3903f523d04b3880c16c7acb87da996d5ee319fd1c81a)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5) transitivePeerDependencies: - crossws - ocache '@devframes/plugin-messages--assets@1.2.5': {} - '@devframes/plugin-messages@1.2.5(@devframes/hub@1.2.5(patch_hash=4adf20d028ae064225ce64077943909413eb59a0743124130d5cba7396ddc46c)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(@devframes/plugin-messages--assets@1.2.5)(devframe@1.2.5)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))': + '@devframes/plugin-messages@1.2.5(@devframes/hub@1.2.5(patch_hash=ac0ddf7ffd9cb52dbfd779b287609f504d161a035199e9e45bb9fc3c20121bce)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(@devframes/plugin-messages--assets@1.2.5)(devframe@1.2.5)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))': dependencies: - '@devframes/hub': 1.2.5(patch_hash=4adf20d028ae064225ce64077943909413eb59a0743124130d5cba7396ddc46c)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5) + '@devframes/hub': 1.2.5(patch_hash=ac0ddf7ffd9cb52dbfd779b287609f504d161a035199e9e45bb9fc3c20121bce)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5) '@devframes/service-open': 1.2.5(devframe@1.2.5) cac: 7.0.0 - devframe: 1.2.5(patch_hash=c86b8ae2a19f79528c181ec03a959472187296172208299dbc83a2d5f2c01b2a)(@devframes/agentic@1.2.5)(cac@7.0.0)(srvx@1.0.5) + devframe: 1.2.5(patch_hash=a79d264e0ed1c42d99d847d5a873fbf5919ce5b5aae14d26518597683d637c54)(@devframes/agentic@1.2.5)(cac@7.0.0)(srvx@1.0.5) optionalDependencies: '@devframes/plugin-messages--assets': 1.2.5 vite: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0) '@devframes/plugin-terminals--assets@1.2.5': {} - '@devframes/plugin-terminals@1.2.5(@devframes/hub-ui@1.2.5(@devframes/hub@1.2.5(patch_hash=4adf20d028ae064225ce64077943909413eb59a0743124130d5cba7396ddc46c)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(devframe@1.2.5))(@devframes/hub@1.2.5(patch_hash=4adf20d028ae064225ce64077943909413eb59a0743124130d5cba7396ddc46c)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(@devframes/plugin-terminals--assets@1.2.5)(devframe@1.2.5)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))': + '@devframes/plugin-terminals@1.2.5(@devframes/hub-ui@1.2.5(@devframes/hub@1.2.5(patch_hash=ac0ddf7ffd9cb52dbfd779b287609f504d161a035199e9e45bb9fc3c20121bce)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(devframe@1.2.5))(@devframes/hub@1.2.5(patch_hash=ac0ddf7ffd9cb52dbfd779b287609f504d161a035199e9e45bb9fc3c20121bce)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(@devframes/plugin-terminals--assets@1.2.5)(devframe@1.2.5)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))': dependencies: - '@devframes/vite': 1.2.5(@devframes/hub-ui@1.2.5(@devframes/hub@1.2.5(patch_hash=4adf20d028ae064225ce64077943909413eb59a0743124130d5cba7396ddc46c)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(devframe@1.2.5))(@devframes/hub@1.2.5(patch_hash=4adf20d028ae064225ce64077943909413eb59a0743124130d5cba7396ddc46c)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(devframe@1.2.5)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) + '@devframes/vite': 1.2.5(@devframes/hub-ui@1.2.5(@devframes/hub@1.2.5(patch_hash=ac0ddf7ffd9cb52dbfd779b287609f504d161a035199e9e45bb9fc3c20121bce)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(devframe@1.2.5))(@devframes/hub@1.2.5(patch_hash=ac0ddf7ffd9cb52dbfd779b287609f504d161a035199e9e45bb9fc3c20121bce)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(devframe@1.2.5)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) cac: 7.0.0 - devframe: 1.2.5(patch_hash=c86b8ae2a19f79528c181ec03a959472187296172208299dbc83a2d5f2c01b2a)(@devframes/agentic@1.2.5)(cac@7.0.0)(srvx@1.0.5) + devframe: 1.2.5(patch_hash=a79d264e0ed1c42d99d847d5a873fbf5919ce5b5aae14d26518597683d637c54)(@devframes/agentic@1.2.5)(cac@7.0.0)(srvx@1.0.5) zigpty: 0.2.1 optionalDependencies: '@devframes/plugin-terminals--assets': 1.2.5 @@ -9128,16 +9128,16 @@ snapshots: '@devframes/service-open@1.2.5(devframe@1.2.5)': dependencies: - devframe: 1.2.5(patch_hash=c86b8ae2a19f79528c181ec03a959472187296172208299dbc83a2d5f2c01b2a)(@devframes/agentic@1.2.5)(cac@7.0.0)(srvx@1.0.5) + devframe: 1.2.5(patch_hash=a79d264e0ed1c42d99d847d5a873fbf5919ce5b5aae14d26518597683d637c54)(@devframes/agentic@1.2.5)(cac@7.0.0)(srvx@1.0.5) pathe: 2.0.3 - '@devframes/vite@1.2.5(@devframes/hub-ui@1.2.5(@devframes/hub@1.2.5(patch_hash=4adf20d028ae064225ce64077943909413eb59a0743124130d5cba7396ddc46c)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(devframe@1.2.5))(@devframes/hub@1.2.5(patch_hash=4adf20d028ae064225ce64077943909413eb59a0743124130d5cba7396ddc46c)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(devframe@1.2.5)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))': + '@devframes/vite@1.2.5(@devframes/hub-ui@1.2.5(@devframes/hub@1.2.5(patch_hash=ac0ddf7ffd9cb52dbfd779b287609f504d161a035199e9e45bb9fc3c20121bce)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(devframe@1.2.5))(@devframes/hub@1.2.5(patch_hash=ac0ddf7ffd9cb52dbfd779b287609f504d161a035199e9e45bb9fc3c20121bce)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(devframe@1.2.5)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))': dependencies: - devframe: 1.2.5(patch_hash=c86b8ae2a19f79528c181ec03a959472187296172208299dbc83a2d5f2c01b2a)(@devframes/agentic@1.2.5)(cac@7.0.0)(srvx@1.0.5) + devframe: 1.2.5(patch_hash=a79d264e0ed1c42d99d847d5a873fbf5919ce5b5aae14d26518597683d637c54)(@devframes/agentic@1.2.5)(cac@7.0.0)(srvx@1.0.5) pathe: 2.0.3 optionalDependencies: - '@devframes/hub': 1.2.5(patch_hash=4adf20d028ae064225ce64077943909413eb59a0743124130d5cba7396ddc46c)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5) - '@devframes/hub-ui': 1.2.5(@devframes/hub@1.2.5(patch_hash=4adf20d028ae064225ce64077943909413eb59a0743124130d5cba7396ddc46c)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(devframe@1.2.5) + '@devframes/hub': 1.2.5(patch_hash=ac0ddf7ffd9cb52dbfd779b287609f504d161a035199e9e45bb9fc3c20121bce)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5) + '@devframes/hub-ui': 1.2.5(@devframes/hub@1.2.5(patch_hash=ac0ddf7ffd9cb52dbfd779b287609f504d161a035199e9e45bb9fc3c20121bce)(@devframes/agentic@1.2.5)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5))(devframe@1.2.5) vite: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0) '@emnapi/core@1.11.1': @@ -11838,14 +11838,14 @@ snapshots: dependencies: address: 2.0.3 - devframe@1.2.5(patch_hash=c86b8ae2a19f79528c181ec03a959472187296172208299dbc83a2d5f2c01b2a)(@devframes/agentic@1.2.5)(cac@7.0.0)(srvx@1.0.5): + devframe@1.2.5(patch_hash=a79d264e0ed1c42d99d847d5a873fbf5919ce5b5aae14d26518597683d637c54)(@devframes/agentic@1.2.5)(cac@7.0.0)(srvx@1.0.5): dependencies: crossws: 0.4.12(srvx@1.0.5) h3: 2.0.1(crossws@0.4.12(srvx@1.0.5)) nostics: 1.3.0 pathe: 2.0.3 optionalDependencies: - '@devframes/agentic': 1.2.5(patch_hash=c31b74d2f6d91c729d3f6d5d98612871163019427da960f0c7fde5e90c95714d)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5) + '@devframes/agentic': 1.2.5(patch_hash=cb2492340202dd7496f3903f523d04b3880c16c7acb87da996d5ee319fd1c81a)(crossws@0.4.12(srvx@1.0.5))(devframe@1.2.5) cac: 7.0.0 transitivePeerDependencies: - ocache