diff --git a/alias.ts b/alias.ts index 788d6b25..2a641afe 100644 --- a/alias.ts +++ b/alias.ts @@ -31,6 +31,7 @@ export const alias = { 'devframe/utils/shared-state': r('devframe/src/utils/shared-state.ts'), 'devframe/utils/streaming-channel': r('devframe/src/utils/streaming-channel.ts'), 'devframe/utils/structured-clone': r('devframe/src/utils/structured-clone.ts'), + 'devframe/utils/valibot-json-schema': r('devframe/src/utils/valibot-json-schema.ts'), 'devframe/utils/when': r('devframe/src/utils/when.ts'), 'devframe/adapters/cac': r('devframe/src/adapters/cac.ts'), 'devframe/adapters/cli': r('devframe/src/adapters/cli.ts'), diff --git a/docs/adapters/mcp.md b/docs/adapters/mcp.md index fc58b07d..5321c9ed 100644 --- a/docs/adapters/mcp.md +++ b/docs/adapters/mcp.md @@ -46,4 +46,31 @@ defineDevframe({ }) ``` +### Hosted bridges + +Both hosted bridges forward the same option to their side-car dev server and advertise the endpoint (with its port) in the `__connection.json` they serve: + +```ts +// Vite +viteDevBridge(devframe, { devMiddleware: true, mcp: true }) + +// Next.js (@devframes/next) +createDevframeNextHandler(devframe, { mcp: true }) +``` + +## Custom hosts + +`createMcpFetchHandler(ctx, options)` returns the endpoint as a web-standard `Request → Response` handler plus a `dispose()` for session teardown — mount it on any fetch-shaped server (a Next.js App Router route, a custom Node server). The h3 `mountMcpHttp` used by the dev server is a thin wrapper over it. + +```ts +import { createMcpFetchHandler } from 'devframe/adapters/mcp' + +const mcp = createMcpFetchHandler(ctx, { + serverName: 'my-tool (devframe)', + serverVersion: '1.0.0', + exposeSharedState: true, +}) +// route every method on /__mcp to mcp.fetch(request) +``` + See the [Agent-Native](/guide/agent-native) page for the full API, safety model, and Claude Desktop integration example. diff --git a/docs/errors/DF8404.md b/docs/errors/DF8404.md new file mode 100644 index 00000000..3d457a76 --- /dev/null +++ b/docs/errors/DF8404.md @@ -0,0 +1,48 @@ +--- +outline: deep +--- + +# DF8404: Agent Exposure Without Handler + +## Message + +> Command "`{id}`" declares agent exposure but has no handler + +## Cause + +`ctx.commands.register(command)` or a command handle `update()` received a command carrying an `agent` field but no `handler`. Agent-exposed commands are projected into `ctx.agent` as callable tools (reaching MCP clients through the devframe MCP adapter), so they must be executable server-side — a handler-less command is a palette group and cannot run. + +## Example + +```ts +// ✗ Bad: group-only command opting into the agent surface +ctx.commands.register({ + id: 'my-tool:group', + title: 'My tool', + agent: { description: 'Run my tool.' }, + children: [/* … */], +}) + +// ✓ Good: the executable child carries the agent field +ctx.commands.register({ + id: 'my-tool:group', + title: 'My tool', + children: [ + { + id: 'my-tool:reload', + title: 'Reload', + agent: { description: 'Reload my tool\'s state. Call after changing its config.' }, + handler: () => reload(), + }, + ], +}) +``` + +## Fix + +- Add a `handler` to the command carrying the `agent` field. +- Or move the `agent` field to an executable child command. + +## Source + +- [`packages/hub/src/node/host-commands.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/host-commands.ts) — `DevframeCommandsHost.register()` and command handle `update()` validate agent exposure across the command tree. diff --git a/docs/guide/agent-native.md b/docs/guide/agent-native.md index f1fc382c..98536835 100644 --- a/docs/guide/agent-native.md +++ b/docs/guide/agent-native.md @@ -79,6 +79,8 @@ ctx.agent.registerResource({ Every `ctx.rpc.sharedState` key is also automatically exposed to MCP as `devframe://state/`. Pass `exposeSharedState: false` (or a filter function) to `createMcpServer` to opt out. +Shared state is additionally reachable through the built-in **`read_state` tool** — call it without arguments for the key list, with a `key` for that value — since many MCP clients only consume tools. It honors the same `exposeSharedState` filter as the resource projection. + ## Starting the MCP server The simplest path is the CLI: diff --git a/docs/guide/hub.md b/docs/guide/hub.md index 4a588be9..2d4c0a19 100644 --- a/docs/guide/hub.md +++ b/docs/guide/hub.md @@ -30,6 +30,24 @@ Every hub context auto-registers these RPC functions so framework kits don't rei Host-specific capabilities (open in editor, reveal in finder, …) ship as kit-registered RPC functions rather than as part of the hub surface. +## Commands as agent tools + +A server command opts into the [agent surface](./agent-native) with an `agent` field — the same default-deny convention as `defineRpcFunction`. Agent-flagged, handler-bearing commands are projected into `ctx.agent` as callable tools and reach MCP clients through the devframe MCP adapter: + +```ts +ctx.commands.register({ + id: 'app:build', + title: 'Run build', + agent: { + description: 'Run the production build. Call after config or dependency changes to verify the app still builds.', + args: [v.object({ configFile: v.optional(v.string()) })], + }, + handler: (opts?: { configFile?: string }) => runBuild(opts), +}) +``` + +`args` takes positional valibot schemas (a single `v.object(...)` is unwrapped into the tool's input object); omit it for a zero-argument tool. `safety` defaults to `'action'`. `when` clauses evaluate client-side only and are not enforced for agent calls — opt in a `when`-gated command only if running it outside its UI context is safe. + ## Cross-iframe dock activation The viewer's active dock is client-local state — which dock is on screen lives in the shell page, not in shared state. A mounted devframe runs in its own iframe on its own RPC client, so it can't reach that selection directly. `hub:docks:activate` bridges the gap: any connected client asks the hub to switch the active dock, and the hub relays the request to the shell. diff --git a/packages/devframe/package.json b/packages/devframe/package.json index 67428a87..ef68c07a 100644 --- a/packages/devframe/package.json +++ b/packages/devframe/package.json @@ -54,6 +54,7 @@ "./utils/shared-state": "./dist/utils/shared-state.mjs", "./utils/streaming-channel": "./dist/utils/streaming-channel.mjs", "./utils/structured-clone": "./dist/utils/structured-clone.mjs", + "./utils/valibot-json-schema": "./dist/utils/valibot-json-schema.mjs", "./utils/when": "./dist/utils/when.mjs", "./package.json": "./package.json" }, diff --git a/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts b/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts index c6751aaf..801f47da 100644 --- a/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts +++ b/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts @@ -176,4 +176,84 @@ describe('mcp adapter (in-memory)', () => { await cleanup() } }) + + it('exposes shared state through the built-in read_state tool', async () => { + const { ctx, client, cleanup } = await bootPair() + try { + await ctx.rpc.sharedState.get('my-plugin:counter', { + initialValue: { count: 7 }, + }) + + const listed = await client.listTools() + const tool = listed.tools.find(t => t.name === 'read_state') + expect(tool).toBeDefined() + expect(tool!.annotations?.readOnlyHint).toBe(true) + + // No key → key list. + const keys = await client.callTool({ name: 'read_state', arguments: {} }) + expect(keys.structuredContent).toEqual({ keys: ['my-plugin:counter'] }) + + // With key → the value. + const value = await client.callTool({ name: 'read_state', arguments: { key: 'my-plugin:counter' } }) + expect(value.structuredContent).toEqual({ key: 'my-plugin:counter', value: { count: 7 } }) + + // Unknown key → agent-actionable error. + const missing = await client.callTool({ name: 'read_state', arguments: { key: 'nope' } }) + expect(missing.isError).toBe(true) + const content = missing.content as Array<{ text: string }> + expect(content[0]!.text).toContain('unknown shared-state key') + } + finally { + await cleanup() + } + }) + + it('hides read_state when shared-state exposure is disabled', async () => { + const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() }) + const { server, dispose } = buildMcpServerFromContext(ctx, { + serverName: 'test', + serverVersion: '0.0.0-test', + exposeSharedState: false, + }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + const client = new Client({ name: 'test-client', version: '0.0.0' }) + await client.connect(clientTransport) + try { + const listed = await client.listTools() + expect(listed.tools.map(t => t.name)).not.toContain('read_state') + } + finally { + dispose() + await client.close() + await server.close() + } + }) + + it('respects the shared-state filter in read_state', async () => { + const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() }) + await ctx.rpc.sharedState.get('visible:key', { initialValue: { n: 1 } }) + await ctx.rpc.sharedState.get('hidden:key', { initialValue: { n: 2 } }) + const { server, dispose } = buildMcpServerFromContext(ctx, { + serverName: 'test', + serverVersion: '0.0.0-test', + exposeSharedState: key => key.startsWith('visible:'), + }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + const client = new Client({ name: 'test-client', version: '0.0.0' }) + await client.connect(clientTransport) + try { + const keys = await client.callTool({ name: 'read_state', arguments: {} }) + expect(keys.structuredContent).toEqual({ keys: ['visible:key'] }) + + const hidden = await client.callTool({ name: 'read_state', arguments: { key: 'hidden:key' } }) + expect(hidden.isError).toBe(true) + } + finally { + dispose() + await client.close() + await server.close() + } + }) }) diff --git a/packages/devframe/src/adapters/mcp/build-server.ts b/packages/devframe/src/adapters/mcp/build-server.ts index 5bb0ba4f..84b79a8d 100644 --- a/packages/devframe/src/adapters/mcp/build-server.ts +++ b/packages/devframe/src/adapters/mcp/build-server.ts @@ -68,7 +68,7 @@ export function buildMcpServerFromContext( }, ) - registerToolHandlers(server, ctx) + registerToolHandlers(server, ctx, options.exposeSharedState) registerResourceHandlers(server, ctx, options.exposeSharedState) const notify = (method: string): void => { @@ -151,15 +151,85 @@ export async function createMcpServer( } } -function registerToolHandlers(server: Server, ctx: DevframeNodeContext): void { +/** + * Name of the built-in shared-state read tool. Tool-shaped access matters + * because many MCP clients only consume tools — the parallel + * `devframe://state/` resource projection stays for the clients that do + * read resources. + */ +const READ_STATE_TOOL = 'read_state' + +function sharedStateFilter(exposeSharedState: boolean | ((key: string) => boolean)): ((key: string) => boolean) | undefined { + if (exposeSharedState === false) + return undefined + return typeof exposeSharedState === 'function' ? exposeSharedState : () => true +} + +function readStateToolProjection(): Record { + return { + name: READ_STATE_TOOL, + title: 'Read shared state', + description: 'Read this devtool\'s live shared state. Call without arguments to list the available keys, then with a key to get that value as JSON. Safe to call freely.', + inputSchema: { + type: 'object', + properties: { + key: { + type: 'string', + description: 'A shared-state key from the key list. Omit to list all keys.', + }, + }, + }, + annotations: { + title: 'Read shared state', + readOnlyHint: true, + destructiveHint: false, + }, + } +} + +async function readStateResult( + ctx: DevframeNodeContext, + filter: (key: string) => boolean, + key: string | undefined, +): Promise { + const keys = ctx.rpc.sharedState.keys().filter(filter) + if (key === undefined) + return { keys } + if (!keys.includes(key)) + throw new Error(`unknown shared-state key "${key}" — call ${READ_STATE_TOOL} without arguments to list the available keys`) + const state = await ctx.rpc.sharedState.get(key) + return { key, value: state.value() } +} + +function registerToolHandlers( + server: Server, + ctx: DevframeNodeContext, + exposeSharedState: boolean | ((key: string) => boolean), +): void { + const stateFilter = sharedStateFilter(exposeSharedState) + server.setRequestHandler(ListToolsRequestSchema, async () => { const tools = ctx.agent.list().tools.map(tool => projectTool(tool, ctx)) + // A registered agent tool of the same name wins over the built-in. + if (stateFilter && !ctx.agent.getTool(READ_STATE_TOOL)) + tools.push(readStateToolProjection()) return { tools } }) server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params try { + // Built-in shared-state read. A registered agent tool of the same + // name wins (mirroring the list projection above); plugin tools keep + // namespaced ids (`:`), so collisions are deliberate. + if (stateFilter && name === READ_STATE_TOOL && !ctx.agent.getTool(READ_STATE_TOOL)) { + const key = (args as { key?: string } | undefined)?.key + const result = await readStateResult(ctx, stateFilter, key) + return { + content: [{ type: 'text', text: stringifyForMcp(result) }], + structuredContent: result as Record, + } + } const tool = ctx.agent.getTool(name) const outputSchema = tool ? tool.outputSchema ?? computeOutputSchema(tool, ctx) diff --git a/packages/devframe/src/adapters/mcp/fetch.ts b/packages/devframe/src/adapters/mcp/fetch.ts new file mode 100644 index 00000000..5aa91ff3 --- /dev/null +++ b/packages/devframe/src/adapters/mcp/fetch.ts @@ -0,0 +1,168 @@ +import type { DevframeNodeContext } from 'devframe/types' +import { randomUUID } from 'node:crypto' +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' +import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js' +import { isAllowedOrigin } from 'devframe/rpc/transports/ws-server' +import { buildMcpServerFromContext } from './build-server' + +export interface CreateMcpFetchHandlerOptions { + /** Name reported in the MCP handshake. */ + serverName: string + /** Version reported in the MCP handshake. */ + serverVersion: string + /** Expose shared-state keys as MCP resources — see `buildMcpServerFromContext`. */ + exposeSharedState: boolean | ((key: string) => boolean) + /** + * Origin allow-list beyond the loopback default. `false` disables the + * origin gate entirely. Default: loopback-only (mirrors the WS transport). + */ + allowedOrigins?: readonly string[] | false +} + +export interface McpFetchHandler { + /** + * WHATWG-`fetch` handler for the MCP Streamable-HTTP endpoint. Hand every + * method (POST/GET/DELETE) on the endpoint's path to it — routing by path + * is the host's job. + */ + fetch: (request: Request) => Promise + /** Tear down every live MCP session (closes servers, drops subscriptions). */ + dispose: () => Promise +} + +interface McpSession { + transport: WebStandardStreamableHTTPServerTransport + dispose: () => Promise +} + +/** + * Build a framework-agnostic MCP Streamable-HTTP endpoint over a devframe + * context: a web-standard `Request → Response` handler any host can mount — + * h3 (see `mountMcpHttp`), a Next.js App Router route, or any other + * fetch-shaped server. + * + * Each MCP session gets its own {@link WebStandardStreamableHTTPServerTransport} + * and MCP server (built from the shared, live `ctx` via + * `buildMcpServerFromContext`), correlated by the `Mcp-Session-Id` header: an + * `initialize` POST spins up a session; later requests route to it; a `DELETE` + * (or client disconnect) tears it down. The origin gate applies devframe's + * loopback-default DNS-rebinding protection (identical semantics to the WS + * upgrade's `isAllowedOrigin`). + * + * @experimental + */ +export function createMcpFetchHandler( + ctx: DevframeNodeContext, + options: CreateMcpFetchHandlerOptions, +): McpFetchHandler { + const sessions = new Map() + const allowedOrigins = options.allowedOrigins + + function drop(sessionId: string): void { + const session = sessions.get(sessionId) + if (!session) + return + sessions.delete(sessionId) + void session.dispose() + } + + async function createSession(): Promise { + // Declared up front so the transport's session callbacks can capture it; + // it's assigned before any of them can fire (they run during + // `handleRequest`, after `connect` below). + let session!: McpSession + + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (id) => { + sessions.set(id, session) + }, + onsessionclosed: (id) => { + drop(id) + }, + }) + + const { server, dispose } = buildMcpServerFromContext(ctx, { + serverName: options.serverName, + serverVersion: options.serverVersion, + exposeSharedState: options.exposeSharedState, + }) + + session = { + transport, + dispose: async () => { + dispose() + await server.close() + }, + } + + transport.onclose = () => { + if (transport.sessionId) + drop(transport.sessionId) + } + + await server.connect(transport) + return session + } + + async function handle(req: Request): Promise { + // Origin gate — identical semantics to the WS upgrade's `isAllowedOrigin` + // (loopback + `Origin`-less native clients + the configured allow-list). + // This is the endpoint's DNS-rebinding protection. + const origin = req.headers.get('origin') ?? undefined + if (allowedOrigins !== false && !isAllowedOrigin(origin, allowedOrigins ?? [])) + return new Response('Forbidden: origin not allowed', { status: 403 }) + + const sessionId = req.headers.get('mcp-session-id') ?? undefined + let session = sessionId ? sessions.get(sessionId) : undefined + + // A POST may carry an `initialize` request that opens a brand-new + // session. Parse the body once and hand it to the transport as + // `parsedBody` (the web Request body can only be consumed once). + if (!session && req.method === 'POST') { + let body: unknown + try { + body = await req.json() + } + catch { + body = undefined + } + + if (!sessionId && isInitializeRequest(body)) { + session = await createSession() + } + else { + return new Response( + sessionId + ? 'Not Found: unknown MCP session' + : 'Bad Request: no valid session ID and not an initialize request', + { status: sessionId ? 404 : 400 }, + ) + } + + return session.transport.handleRequest(req, { parsedBody: body }) + } + + if (!session) { + // GET (open the SSE stream) / DELETE (end the session) require a + // known session id. + return new Response( + sessionId + ? 'Not Found: unknown MCP session' + : 'Bad Request: missing MCP session ID', + { status: sessionId ? 404 : 400 }, + ) + } + + return session.transport.handleRequest(req) + } + + return { + fetch: handle, + dispose: async () => { + const live = [...sessions.values()] + sessions.clear() + await Promise.all(live.map(session => session.dispose())) + }, + } +} diff --git a/packages/devframe/src/adapters/mcp/http.ts b/packages/devframe/src/adapters/mcp/http.ts index 7ea2495e..89c5e464 100644 --- a/packages/devframe/src/adapters/mcp/http.ts +++ b/packages/devframe/src/adapters/mcp/http.ts @@ -1,49 +1,25 @@ import type { DevframeNodeContext } from 'devframe/types' import type { H3, H3Event } from 'h3' -import { randomUUID } from 'node:crypto' -import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' -import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js' -import { isAllowedOrigin } from 'devframe/rpc/transports/ws-server' +import type { CreateMcpFetchHandlerOptions } from './fetch' import { defineHandler } from 'h3' -import { buildMcpServerFromContext } from './build-server' +import { createMcpFetchHandler } from './fetch' -export interface MountMcpHttpOptions { - /** Name reported in the MCP handshake. */ - serverName: string - /** Version reported in the MCP handshake. */ - serverVersion: string - /** Expose shared-state keys as MCP resources — see `buildMcpServerFromContext`. */ - exposeSharedState: boolean | ((key: string) => boolean) - /** - * Origin allow-list beyond the loopback default. `false` disables the - * origin gate entirely. Default: loopback-only (mirrors the WS transport). - */ - allowedOrigins?: readonly string[] | false -} +export interface MountMcpHttpOptions extends CreateMcpFetchHandlerOptions {} export interface MountedMcpHttp { /** Tear down every live MCP session (closes servers, drops subscriptions). */ dispose: () => Promise } -interface McpSession { - transport: WebStandardStreamableHTTPServerTransport - dispose: () => Promise -} - /** - * Mount an MCP Streamable-HTTP endpoint on an h3 app at `path`. - * - * Each MCP session gets its own {@link WebStandardStreamableHTTPServerTransport} - * and MCP server (built from the shared, live `ctx` via - * `buildMcpServerFromContext`), correlated by the `Mcp-Session-Id` header: - * an `initialize` POST spins up a session; later requests route to it; a - * `DELETE` (or client disconnect) tears it down. + * Mount an MCP Streamable-HTTP endpoint on an h3 app at `path` — the h3 + * binding over {@link createMcpFetchHandler}, which owns the sessions, the + * origin gate, and the transport plumbing. * - * The transport is web-standard — its `handleRequest` takes the h3 event's - * web `Request` and returns a web `Response` (an SSE `ReadableStream` body - * for the server→client stream). We copy that response onto `event.res` and - * return its body rather than returning the `Response` object directly, so a + * The handler is web-standard — it takes the h3 event's web `Request` and + * returns a web `Response` (an SSE `ReadableStream` body for the + * server→client stream). We copy that response onto `event.res` and return + * its body rather than returning the `Response` object directly, so a * legitimate MCP 404 (unknown session) isn't swallowed by h3's * "Response-with-404 falls through to the next handler" rule (which would * otherwise hand the request to the SPA static catch-all). @@ -56,114 +32,12 @@ export function mountMcpHttp( path: string, options: MountMcpHttpOptions, ): MountedMcpHttp { - const sessions = new Map() - const allowedOrigins = options.allowedOrigins - - function drop(sessionId: string): void { - const session = sessions.get(sessionId) - if (!session) - return - sessions.delete(sessionId) - void session.dispose() - } - - async function createSession(): Promise { - // Declared up front so the transport's session callbacks can capture it; - // it's assigned before any of them can fire (they run during - // `handleRequest`, after `connect` below). - let session!: McpSession - - const transport = new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (id) => { - sessions.set(id, session) - }, - onsessionclosed: (id) => { - drop(id) - }, - }) - - const { server, dispose } = buildMcpServerFromContext(ctx, { - serverName: options.serverName, - serverVersion: options.serverVersion, - exposeSharedState: options.exposeSharedState, - }) - - session = { - transport, - dispose: async () => { - dispose() - await server.close() - }, - } - - transport.onclose = () => { - if (transport.sessionId) - drop(transport.sessionId) - } - - await server.connect(transport) - return session - } - - app.use(path, defineHandler(async (event) => { - const req = event.req - - // Origin gate — identical semantics to the WS upgrade's `isAllowedOrigin` - // (loopback + `Origin`-less native clients + the configured allow-list). - // This is the endpoint's DNS-rebinding protection. - const origin = req.headers.get('origin') ?? undefined - if (allowedOrigins !== false && !isAllowedOrigin(origin, allowedOrigins ?? [])) { - event.res.status = 403 - return 'Forbidden: origin not allowed' - } - - const sessionId = req.headers.get('mcp-session-id') ?? undefined - let session = sessionId ? sessions.get(sessionId) : undefined - - // A POST may carry an `initialize` request that opens a brand-new - // session. Parse the body once and hand it to the transport as - // `parsedBody` (the web Request body can only be consumed once). - if (!session && req.method === 'POST') { - let body: unknown - try { - body = await req.json() - } - catch { - body = undefined - } - - if (!sessionId && isInitializeRequest(body)) { - session = await createSession() - } - else { - event.res.status = sessionId ? 404 : 400 - return sessionId - ? 'Not Found: unknown MCP session' - : 'Bad Request: no valid session ID and not an initialize request' - } - - return respond(event, await session.transport.handleRequest(req, { parsedBody: body })) - } - - if (!session) { - // GET (open the SSE stream) / DELETE (end the session) require a - // known session id. - event.res.status = sessionId ? 404 : 400 - return sessionId - ? 'Not Found: unknown MCP session' - : 'Bad Request: missing MCP session ID' - } + const handler = createMcpFetchHandler(ctx, options) - return respond(event, await session.transport.handleRequest(req)) - })) + app.use(path, defineHandler(async event => respond(event, await handler.fetch(event.req)))) return { - dispose: async () => { - const live = [...sessions.values()] - sessions.clear() - await Promise.all(live.map(session => session.dispose())) - }, + dispose: handler.dispose, } } diff --git a/packages/devframe/src/adapters/mcp/index.ts b/packages/devframe/src/adapters/mcp/index.ts index 3dcce657..de4d672d 100644 --- a/packages/devframe/src/adapters/mcp/index.ts +++ b/packages/devframe/src/adapters/mcp/index.ts @@ -17,3 +17,9 @@ export { type CreateMcpServerOptions, type McpServerHandle, } from './build-server' + +export { + createMcpFetchHandler, + type CreateMcpFetchHandlerOptions, + type McpFetchHandler, +} from './fetch' diff --git a/packages/devframe/src/adapters/mcp/to-json-schema.ts b/packages/devframe/src/adapters/mcp/to-json-schema.ts index ccf1b144..ef26db43 100644 --- a/packages/devframe/src/adapters/mcp/to-json-schema.ts +++ b/packages/devframe/src/adapters/mcp/to-json-schema.ts @@ -1,81 +1,4 @@ -import type { GenericSchema } from 'valibot' -import { toJsonSchema } from '@valibot/to-json-schema' - -const FALLBACK_OBJECT_SCHEMA = Object.freeze({ type: 'object', additionalProperties: true }) - -/** - * Convert a valibot return schema to JSON Schema. - * @internal - */ -export function valibotReturnToJsonSchema(schema: GenericSchema | undefined): unknown { - if (!schema) - return undefined - try { - return toJsonSchema(schema as any) - } - catch { - return FALLBACK_OBJECT_SCHEMA - } -} - -/** - * Convert positional RPC args schemas to a single MCP-friendly object - * schema. When the RPC declares `args: [v.object(...)]`, unwrap the - * single-object schema directly (nicer agent UX than `{ arg0: {...} }`). - * - * Returns `undefined` when there are no args (the MCP SDK treats this - * as `{ type: 'object', properties: {} }`). - * @internal - */ -export function valibotArgsToJsonSchema( - args: readonly GenericSchema[] | undefined, -): { schema: unknown, unwrapped: boolean } { - if (!args || args.length === 0) - return { schema: { type: 'object', properties: {} }, unwrapped: false } - - // Single-object arg: unwrap. - if (args.length === 1) { - const inner = safeToJsonSchema(args[0]!) - if (isObjectJsonSchema(inner)) - return { schema: inner, unwrapped: true } - // Non-object single arg (e.g. a string): fall through to arg0 shape. - } - - const properties: Record = {} - const required: string[] = [] - for (let i = 0; i < args.length; i++) { - const key = `arg${i}` - const s = safeToJsonSchema(args[i]!) - properties[key] = s - // Conservatively mark every positional arg as required — the RPC - // layer validates against valibot anyway. - required.push(key) - } - - return { - schema: { - type: 'object', - properties, - required, - additionalProperties: false, - }, - unwrapped: false, - } -} - -function safeToJsonSchema(schema: GenericSchema): unknown { - try { - return toJsonSchema(schema as any) - } - catch { - return FALLBACK_OBJECT_SCHEMA - } -} - -function isObjectJsonSchema(value: unknown): boolean { - return ( - !!value - && typeof value === 'object' - && (value as { type?: unknown }).type === 'object' - ) -} +// Moved to `devframe/utils/valibot-json-schema` so hosts without the MCP SDK +// (e.g. the hub's commands→agent bridge) can convert schemas too. This module +// keeps the adapter-local import path stable. +export { valibotArgsToJsonSchema, valibotReturnToJsonSchema } from 'devframe/utils/valibot-json-schema' diff --git a/packages/devframe/src/rpc/types.ts b/packages/devframe/src/rpc/types.ts index 370c08e6..0682bdb3 100644 --- a/packages/devframe/src/rpc/types.ts +++ b/packages/devframe/src/rpc/types.ts @@ -303,11 +303,15 @@ export type RpcFunctionDefinition< */ agent?: RpcFunctionAgentOptions /** Setup function called with context to initialize handler and dump */ - setup?: (context: CONTEXT) => Thenable, InferReturnType>> - /** Function implementation (required if setup doesn't provide one) */ - handler?: (...args: InferArgsType) => InferReturnType + setup?: (context: CONTEXT) => Thenable, Thenable>>> + /** + * Function implementation (required if setup doesn't provide one). + * The declared `returns` schema describes the *resolved* value — + * async handlers return a promise of it (the runtime always awaits). + */ + handler?: (...args: InferArgsType) => Thenable> /** Dump definition (setup dump takes priority) */ - dump?: RpcDump, InferReturnType, CONTEXT> + dump?: RpcDump, Thenable>, CONTEXT> /** * Sugar for "query in dev, single baked snapshot in build": when * `true` and no `dump` is provided, the build adapter runs the @@ -318,9 +322,9 @@ export type RpcFunctionDefinition< */ snapshot?: boolean /** Per-context setup-result cache, populated by `getRpcResolvedSetupResult`. @internal */ - __cache?: WeakMap, InferReturnType>>> + __cache?: WeakMap, Thenable>>>> /** Single-slot fallback for primitive contexts. @internal */ - __promise?: Thenable, InferReturnType>> + __promise?: Thenable, Thenable>>> } export type RpcFunctionDefinitionToFunction diff --git a/packages/devframe/src/utils/valibot-json-schema.ts b/packages/devframe/src/utils/valibot-json-schema.ts new file mode 100644 index 00000000..52ba8005 --- /dev/null +++ b/packages/devframe/src/utils/valibot-json-schema.ts @@ -0,0 +1,83 @@ +import type { GenericSchema } from 'valibot' +import { toJsonSchema } from '@valibot/to-json-schema' + +const FALLBACK_OBJECT_SCHEMA = Object.freeze({ type: 'object', additionalProperties: true }) + +/** + * Convert a valibot return schema to JSON Schema. + * + * @experimental + */ +export function valibotReturnToJsonSchema(schema: GenericSchema | undefined): unknown { + if (!schema) + return undefined + try { + return toJsonSchema(schema as any) + } + catch { + return FALLBACK_OBJECT_SCHEMA + } +} + +/** + * Convert positional RPC args schemas to a single MCP-friendly object + * schema. When the RPC declares `args: [v.object(...)]`, unwrap the + * single-object schema directly (nicer agent UX than `{ arg0: {...} }`). + * + * Returns `undefined` when there are no args (the MCP SDK treats this + * as `{ type: 'object', properties: {} }`). + * + * @experimental + */ +export function valibotArgsToJsonSchema( + args: readonly GenericSchema[] | undefined, +): { schema: unknown, unwrapped: boolean } { + if (!args || args.length === 0) + return { schema: { type: 'object', properties: {} }, unwrapped: false } + + // Single-object arg: unwrap. + if (args.length === 1) { + const inner = safeToJsonSchema(args[0]!) + if (isObjectJsonSchema(inner)) + return { schema: inner, unwrapped: true } + // Non-object single arg (e.g. a string): fall through to arg0 shape. + } + + const properties: Record = {} + const required: string[] = [] + for (let i = 0; i < args.length; i++) { + const key = `arg${i}` + const s = safeToJsonSchema(args[i]!) + properties[key] = s + // Conservatively mark every positional arg as required — the RPC + // layer validates against valibot anyway. + required.push(key) + } + + return { + schema: { + type: 'object', + properties, + required, + additionalProperties: false, + }, + unwrapped: false, + } +} + +function safeToJsonSchema(schema: GenericSchema): unknown { + try { + return toJsonSchema(schema as any) + } + catch { + return FALLBACK_OBJECT_SCHEMA + } +} + +function isObjectJsonSchema(value: unknown): boolean { + return ( + !!value + && typeof value === 'object' + && (value as { type?: unknown }).type === 'object' + ) +} diff --git a/packages/devframe/tsdown.config.ts b/packages/devframe/tsdown.config.ts index 1ca9eeef..3617512d 100644 --- a/packages/devframe/tsdown.config.ts +++ b/packages/devframe/tsdown.config.ts @@ -99,6 +99,7 @@ const serverEntries = { 'utils/launch-editor': 'src/utils/launch-editor.ts', 'utils/open': 'src/utils/open.ts', 'utils/serve-static': 'src/utils/serve-static.ts', + 'utils/valibot-json-schema': 'src/utils/valibot-json-schema.ts', 'adapters/cac': 'src/adapters/cac.ts', 'adapters/cli': 'src/adapters/cli.ts', 'adapters/dev': 'src/adapters/dev.ts', diff --git a/packages/hub/package.json b/packages/hub/package.json index 53f13710..26647686 100644 --- a/packages/hub/package.json +++ b/packages/hub/package.json @@ -48,6 +48,7 @@ "pathe": "catalog:deps", "perfect-debounce": "catalog:deps", "tinyexec": "catalog:deps", + "valibot": "catalog:deps", "zigpty": "catalog:deps" }, "devDependencies": { diff --git a/packages/hub/src/node/__tests__/host-commands.test.ts b/packages/hub/src/node/__tests__/host-commands.test.ts index 7f9f5dae..8bd07f77 100644 --- a/packages/hub/src/node/__tests__/host-commands.test.ts +++ b/packages/hub/src/node/__tests__/host-commands.test.ts @@ -1,4 +1,6 @@ +import type { AgentToolInput } from 'devframe/types' import type { DevframeHubContext } from '../context' +import * as v from 'valibot' import { describe, expect, it } from 'vitest' import { DevframeCommandsHost } from '../host-commands' @@ -61,3 +63,122 @@ describe('devframeCommandsHost command id validation', () => { })).toThrow('Command id "other:child" is already used') }) }) + +function createAgentContext(): { context: DevframeHubContext, tools: Map } { + const tools = new Map() + const context = { + agent: { + registerTool: (input: AgentToolInput) => { + tools.set(input.id, input) + return { unregister: () => tools.delete(input.id) } + }, + }, + } as unknown as DevframeHubContext + return { context, tools } +} + +describe('devframeCommandsHost agent bridge', () => { + it('projects agent-flagged commands (incl. children) into ctx.agent', async () => { + const { context, tools } = createAgentContext() + const host = new DevframeCommandsHost(context) + const calls: unknown[][] = [] + + host.register({ + id: 'demo:parent', + title: 'Parent group', + children: [ + { + id: 'demo:greet', + title: 'Greet', + agent: { + description: 'Greet someone by name.', + args: [v.object({ name: v.optional(v.string()) })], + }, + handler: (...args: unknown[]) => { + calls.push(args) + return 'done' + }, + }, + ], + }) + + // Group-only parent stays off the agent surface; the child projects. + expect(tools.has('demo:parent')).toBe(false) + const tool = tools.get('demo:greet')! + expect(tool.description).toBe('Greet someone by name.') + expect(tool.title).toBe('Greet') + expect(tool.safety).toBe('action') + expect((tool.inputSchema as { type: string }).type).toBe('object') + + // A single object args schema is unwrapped — the MCP args object lands + // as the handler's first positional argument. + await expect(tool.handler({ name: 'devframe' })).resolves.toBe('done') + expect(calls).toEqual([[{ name: 'devframe' }]]) + }) + + it('registers zero-arg tools for commands without an args schema', async () => { + const { context, tools } = createAgentContext() + const host = new DevframeCommandsHost(context) + const calls: unknown[][] = [] + + host.register({ + id: 'demo:ping', + title: 'Ping', + agent: { description: 'Ping the hub.', safety: 'read' }, + handler: (...args: unknown[]) => { + calls.push(args) + }, + }) + + const tool = tools.get('demo:ping')! + expect(tool.safety).toBe('read') + await tool.handler({ stray: true }) + expect(calls).toEqual([[]]) + }) + + it('re-syncs the projection on update and drops it on unregister', () => { + const { context, tools } = createAgentContext() + const host = new DevframeCommandsHost(context) + + const handle = host.register({ + id: 'demo:sync', + title: 'Sync', + agent: { description: 'Initial description.' }, + handler: () => {}, + }) + expect(tools.get('demo:sync')!.description).toBe('Initial description.') + + handle.update({ agent: { description: 'Patched description.' } }) + expect(tools.get('demo:sync')!.description).toBe('Patched description.') + + handle.unregister() + expect(tools.has('demo:sync')).toBe(false) + }) + + it('rejects agent exposure on handler-less commands', () => { + const { context } = createAgentContext() + const host = new DevframeCommandsHost(context) + + expect(() => host.register({ + id: 'demo:group', + title: 'Group', + agent: { description: 'A group cannot be a tool.' }, + })).toThrow('declares agent exposure but has no handler') + }) + + it('keeps the agent field off the serializable entry', () => { + const { context } = createAgentContext() + const host = new DevframeCommandsHost(context) + + host.register({ + id: 'demo:wire', + title: 'Wire', + agent: { description: 'Not for the wire.' }, + handler: () => {}, + }) + + const entry = host.list().find(cmd => cmd.id === 'demo:wire')! + expect('agent' in entry).toBe(false) + expect('handler' in entry).toBe(false) + }) +}) diff --git a/packages/hub/src/node/diagnostics.ts b/packages/hub/src/node/diagnostics.ts index e18fd931..839e856c 100644 --- a/packages/hub/src/node/diagnostics.ts +++ b/packages/hub/src/node/diagnostics.ts @@ -80,5 +80,9 @@ export const diagnostics = defineDiagnostics({ why: (p: { id: string }) => `Command id "${p.id}" is already used by another command or child command`, fix: 'Use globally unique command ids for top-level commands and all child commands.', }, + DF8404: { + why: (p: { id: string }) => `Command "${p.id}" declares agent exposure but has no handler`, + fix: 'Agent-exposed commands must be executable server-side. Add a `handler` to the command, or move the `agent` field to an executable child command.', + }, }, }) diff --git a/packages/hub/src/node/host-commands.ts b/packages/hub/src/node/host-commands.ts index 2668f398..c40de9f2 100644 --- a/packages/hub/src/node/host-commands.ts +++ b/packages/hub/src/node/host-commands.ts @@ -1,3 +1,4 @@ +import type { AgentHandle } from 'devframe/types' import type { DevframeCommandHandle, DevframeCommandsHost as DevframeCommandsHostType, @@ -6,6 +7,7 @@ import type { } from '../types/commands' import type { DevframeHubContext } from './context' import { createEventEmitter } from 'devframe/utils/events' +import { valibotArgsToJsonSchema } from 'devframe/utils/valibot-json-schema' import { diagnostics } from './diagnostics' function findChildCommand(command: DevframeServerCommandInput, id: string): DevframeServerCommandInput | undefined { @@ -54,6 +56,9 @@ export class DevframeCommandsHost implements DevframeCommandsHostType { public readonly commands: DevframeCommandsHostType['commands'] = new Map() public readonly events: DevframeCommandsHostType['events'] = createEventEmitter() + /** Agent-tool handles per command id (incl. children), for teardown/re-sync. */ + private readonly agentHandles = new Map() + constructor( public readonly context: DevframeHubContext, ) {} @@ -63,8 +68,10 @@ export class DevframeCommandsHost implements DevframeCommandsHostType { throw diagnostics.DF8400({ id: command.id }) } validateCommandIds(this.commands, command) + this.validateAgentExposure(command) this.commands.set(command.id, command) this.events.emit('command:registered', this.toSerializable(command)) + this.registerAgentTools(command) return { id: command.id, @@ -82,16 +89,24 @@ export class DevframeCommandsHost implements DevframeCommandsHostType { id: existing.id, } validateCommandIds(this.commands, next, existing.id) + this.validateAgentExposure(next) + // Re-sync the agent projection: drop the old tree's tools before the + // patch lands, re-register from the patched command below. + this.unregisterAgentTools(existing) Object.assign(existing, patch) this.events.emit('command:registered', this.toSerializable(existing)) + this.registerAgentTools(existing) }, unregister: () => this.unregister(command.id), } } unregister(id: string): boolean { + const command = this.commands.get(id) const deleted = this.commands.delete(id) if (deleted) { + if (command) + this.unregisterAgentTools(command) this.events.emit('command:unregistered', id) } return deleted @@ -129,7 +144,9 @@ export class DevframeCommandsHost implements DevframeCommandsHostType { } private toSerializable(cmd: DevframeServerCommandInput): DevframeServerCommandEntry { - const { handler: _, children, ...rest } = cmd + // `agent` stays server-side: it carries valibot schemas (not wire-safe) + // and only concerns the agent projection, not the palette. + const { handler: _, agent: __, children, ...rest } = cmd return { ...rest, source: 'server', @@ -139,4 +156,67 @@ export class DevframeCommandsHost implements DevframeCommandsHostType { ), } } + + /** Reject `agent` on handler-less commands anywhere in the tree, up front. */ + private validateAgentExposure(command: DevframeServerCommandInput): void { + if (command.agent && !command.handler) + throw diagnostics.DF8404({ id: command.id }) + for (const child of command.children ?? []) + this.validateAgentExposure(child) + } + + /** + * Project every agent-flagged command in the tree into `ctx.agent` as a + * callable tool. `when` clauses evaluate client-side only and are not + * enforced here — opting in a `when`-gated command is a deliberate author + * decision (documented on `DevframeCommandAgentOptions`). + */ + private registerAgentTools(command: DevframeServerCommandInput): void { + const agent = command.agent + if (agent && command.handler) { + const { schema, unwrapped } = valibotArgsToJsonSchema(agent.args) + const handle = this.context.agent.registerTool({ + id: command.id, + title: agent.title ?? command.title, + description: agent.description, + safety: agent.safety ?? 'action', + tags: agent.tags, + inputSchema: schema, + handler: async (args: unknown) => + this.execute(command.id, ...coercePositionalArgs(args, agent.args, unwrapped)), + }) + this.agentHandles.set(command.id, handle) + } + for (const child of command.children ?? []) + this.registerAgentTools(child) + } + + private unregisterAgentTools(command: DevframeServerCommandInput): void { + for (const id of collectCommandIds(command)) { + const handle = this.agentHandles.get(id) + if (handle) { + this.agentHandles.delete(id) + handle.unregister() + } + } + } +} + +/** + * Map the single-object args an MCP client sends onto the command handler's + * positional parameters, mirroring the agent host's RPC coercion: no declared + * schemas → zero-arg call; a single unwrapped object schema → the object + * itself; positional schemas → `arg0..argN` keys in order. + */ +function coercePositionalArgs( + args: unknown, + schemas: readonly unknown[] | undefined, + unwrapped: boolean, +): unknown[] { + if (!schemas || schemas.length === 0) + return [] + if (unwrapped) + return [args ?? {}] + const obj = (args ?? {}) as Record + return schemas.map((_, i) => obj[`arg${i}`]) } diff --git a/packages/hub/src/types/commands.ts b/packages/hub/src/types/commands.ts index 28eff6cc..e2f6f186 100644 --- a/packages/hub/src/types/commands.ts +++ b/packages/hub/src/types/commands.ts @@ -1,4 +1,5 @@ import type { EventEmitter } from 'devframe/types' +import type { GenericSchema } from 'valibot' import type { DevframeDockEntryIcon } from './docks' export interface DevframeCommandKeybinding { @@ -43,6 +44,43 @@ export interface DevframeCommandBase { keybindings?: DevframeCommandKeybinding[] } +/** + * Opt-in agent exposure for a server command — mirrors the `agent` field on + * `defineRpcFunction`. A command carrying this field (and a `handler`) is + * projected into `ctx.agent` as a callable tool, reaching MCP clients through + * the devframe MCP adapter. + * + * `when` clauses are evaluated client-side only and are **not** enforced for + * agent calls — opt in a `when`-gated command only if running it outside its + * UI context is safe. + * + * @experimental The agent-native surface is experimental and may change + * without a major version bump until it stabilizes. + */ +export interface DevframeCommandAgentOptions { + /** + * Description shown to the agent. Write it as a prompt: state when to call + * the command, not just what it does. + */ + description: string + /** Display title (falls back to the command's `title`). */ + title?: string + /** + * Safety classification — drives MCP hint annotations. + * @default 'action' + */ + safety?: 'read' | 'action' | 'destructive' + /** Free-form tags for grouping/filtering. */ + tags?: readonly string[] + /** + * Positional valibot schemas for the handler's arguments, converted to the + * tool's JSON-Schema input (a single `v.object(...)` schema is unwrapped — + * the friendliest shape at the agent boundary). Omitted: the tool takes no + * arguments. + */ + args?: readonly GenericSchema[] +} + /** * Server command input — what plugins pass to `ctx.commands.register()`. */ @@ -51,6 +89,13 @@ export interface DevframeServerCommandInput extends DevframeCommandBase { * Handler for this command. Optional if the command only serves as a group for children. */ handler?: (...args: any[]) => any | Promise + /** + * Opt this command in to the agent surface (`ctx.agent` → MCP). Requires a + * `handler`. See {@link DevframeCommandAgentOptions}. + * + * @experimental + */ + agent?: DevframeCommandAgentOptions /** * Static sub-commands. Two levels max (parent → children). * Each child must have a globally unique `id`. diff --git a/plugins/git/package.json b/plugins/git/package.json index 9f1ed18f..57906733 100644 --- a/plugins/git/package.json +++ b/plugins/git/package.json @@ -49,7 +49,8 @@ "dependencies": { "cac": "catalog:deps", "devframe": "workspace:*", - "pathe": "catalog:deps" + "pathe": "catalog:deps", + "valibot": "catalog:deps" }, "devDependencies": { "@antfu/design": "catalog:frontend", diff --git a/plugins/git/src/rpc/functions/branches.ts b/plugins/git/src/rpc/functions/branches.ts index 979a57f4..fdafc724 100644 --- a/plugins/git/src/rpc/functions/branches.ts +++ b/plugins/git/src/rpc/functions/branches.ts @@ -46,6 +46,10 @@ export const branches = defineRpcFunction({ type: 'query', snapshot: true, jsonSerializable: true, + agent: { + description: 'List local and remote branches of the inspected repository with tracking state (ahead/behind, gone upstreams) and the current branch. Safe to call freely.', + title: 'Git branches', + }, setup: (ctx) => { const git = getGitContext(ctx) return { diff --git a/plugins/git/src/rpc/functions/diff.ts b/plugins/git/src/rpc/functions/diff.ts index 8e44fdc1..f703901f 100644 --- a/plugins/git/src/rpc/functions/diff.ts +++ b/plugins/git/src/rpc/functions/diff.ts @@ -1,4 +1,5 @@ import { defineRpcFunction } from 'devframe' +import * as v from 'valibot' import { runGit, splitClean, tryGit } from '../../node/git.ts' import { getGitContext } from '../context.ts' @@ -25,6 +26,24 @@ export interface GitDiff { truncated: boolean } +const diffFileSchema = v.object({ + path: v.string(), + additions: v.number(), + deletions: v.number(), + binary: v.boolean(), +}) + +const gitDiffSchema = v.object({ + isRepo: v.boolean(), + staged: v.boolean(), + path: v.nullable(v.string()), + files: v.array(diffFileSchema), + totalAdditions: v.number(), + totalDeletions: v.number(), + patch: v.nullable(v.string()), + truncated: v.boolean(), +}) + export interface DiffArgs { /** Limit the diff to a single path; omit for the whole tree. */ path?: string @@ -50,6 +69,15 @@ export const diff = defineRpcFunction({ type: 'query', snapshot: true, jsonSerializable: true, + args: [v.object({ + path: v.optional(v.string()), + staged: v.optional(v.boolean()), + })], + returns: gitDiffSchema, + agent: { + description: 'Unified diff of uncommitted changes in the inspected repository — the working tree by default, the index with staged: true, one file with path. Call before summarizing or reviewing in-progress work. Safe to call freely.', + title: 'Git diff', + }, setup: (ctx) => { const git = getGitContext(ctx) return { diff --git a/plugins/git/src/rpc/functions/log.ts b/plugins/git/src/rpc/functions/log.ts index 372bf300..cf070d37 100644 --- a/plugins/git/src/rpc/functions/log.ts +++ b/plugins/git/src/rpc/functions/log.ts @@ -1,4 +1,5 @@ import { defineRpcFunction } from 'devframe' +import * as v from 'valibot' import { isSafeRevision, RECORD, splitClean, tryGit, UNIT } from '../../node/git.ts' import { getGitContext } from '../context.ts' @@ -26,6 +27,26 @@ export interface GitLog { hasMore: boolean } +const commitSchema = v.object({ + hash: v.string(), + shortHash: v.string(), + author: v.string(), + email: v.string(), + date: v.number(), + subject: v.string(), + body: v.string(), + refs: v.array(v.string()), + parents: v.array(v.string()), +}) + +const gitLogSchema = v.object({ + isRepo: v.boolean(), + commits: v.array(commitSchema), + limit: v.number(), + skip: v.number(), + hasMore: v.boolean(), +}) + export interface LogArgs { /** Number of commits to return (clamped to 1–200, default 30). */ limit?: number @@ -79,11 +100,21 @@ export const log = defineRpcFunction({ name: 'devframes:plugin:git:log', type: 'query', jsonSerializable: true, + args: [v.object({ + limit: v.optional(v.number()), + skip: v.optional(v.number()), + ref: v.optional(v.string()), + })], + returns: gitLogSchema, + agent: { + description: 'Commit history of the inspected repository, newest first. Paginate with limit (1-200, default 30) and skip; pass ref to read another branch. Call before reasoning about recent changes. Safe to call freely.', + title: 'Git log', + }, // A static build can't run git on demand, so bake the head of history (up to // `SNAPSHOT_LIMIT`) as the snapshot. Every client call resolves to this baked // page via the fallback; since a static bundle has no further page to fetch, // it reports `hasMore: false` so the UI shows everything it has in one shot. - dump: async (_ctx, handler: (args?: LogArgs) => Promise) => { + dump: async (_ctx, handler: (args: LogArgs) => GitLog | Promise) => { const output = await handler({ limit: SNAPSHOT_LIMIT, skip: 0 }) const baked: GitLog = { ...output, hasMore: false } // `RETURN` carries the handler's `Promise`, while dump records hold diff --git a/plugins/git/src/rpc/functions/show.ts b/plugins/git/src/rpc/functions/show.ts index 50be2ba3..498a0427 100644 --- a/plugins/git/src/rpc/functions/show.ts +++ b/plugins/git/src/rpc/functions/show.ts @@ -1,6 +1,7 @@ import type { GitContext } from '../context.ts' import type { FileStatusCode } from './status.ts' import { defineRpcFunction } from 'devframe' +import * as v from 'valibot' import { isSafeRevision, splitClean, tryGit, UNIT } from '../../node/git.ts' import { getGitContext } from '../context.ts' @@ -50,6 +51,47 @@ export interface CommitDetail { truncated: boolean } +const fileStatusCodeSchema = v.picklist([ + 'modified', + 'added', + 'deleted', + 'renamed', + 'copied', + 'type-changed', + 'unmerged', + 'unknown', +]) + +const commitFileSchema = v.object({ + path: v.string(), + additions: v.number(), + deletions: v.number(), + binary: v.boolean(), + status: fileStatusCodeSchema, +}) + +const commitDetailSchema = v.object({ + isRepo: v.boolean(), + found: v.boolean(), + hash: v.string(), + shortHash: v.string(), + author: v.string(), + email: v.string(), + date: v.number(), + committer: v.string(), + committerEmail: v.string(), + commitDate: v.number(), + subject: v.string(), + body: v.string(), + parents: v.array(v.string()), + refs: v.array(v.string()), + files: v.array(commitFileSchema), + totalAdditions: v.number(), + totalDeletions: v.number(), + patch: v.nullable(v.string()), + truncated: v.boolean(), +}) + export interface ShowArgs { /** Commit-ish to inspect (full or short hash). */ hash: string @@ -210,10 +252,19 @@ export const show = defineRpcFunction({ name: 'devframes:plugin:git:show', type: 'query', jsonSerializable: true, + args: [v.object({ + hash: v.string(), + patch: v.optional(v.boolean()), + })], + returns: commitDetailSchema, + agent: { + description: 'Full detail of one commit by hash (from the git log tool): metadata, changed files, and the unified patch (pass patch: false to skip it for large commits). Safe to call freely.', + title: 'Git show', + }, // Static builds can't run git per click, so bake one record per commit in the // same window `devframes:plugin:git:log` snapshots. Patches are omitted from the baked records // to keep the bundle bounded — static detail panels show metadata + files. - dump: async (ctx, _handler: (args: ShowArgs) => Promise) => { + dump: async (ctx, _handler: (args: ShowArgs) => CommitDetail | Promise) => { const git = getGitContext(ctx) const root = await git.resolveRoot() if (!root) diff --git a/plugins/git/src/rpc/functions/status.ts b/plugins/git/src/rpc/functions/status.ts index e99dcbb6..a79005f3 100644 --- a/plugins/git/src/rpc/functions/status.ts +++ b/plugins/git/src/rpc/functions/status.ts @@ -168,6 +168,10 @@ export const status = defineRpcFunction({ type: 'query', snapshot: true, jsonSerializable: true, + agent: { + description: 'Working-tree status of the inspected repository: current branch, ahead/behind counts, and every staged/unstaged/untracked file. Call this first to orient before reading diffs or history. Safe to call freely.', + title: 'Git status', + }, setup: (ctx) => { const git = getGitContext(ctx) return { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 084ae5ac..cc6374b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -876,6 +876,9 @@ importers: tinyexec: specifier: catalog:deps version: 1.2.4 + valibot: + specifier: catalog:deps + version: 1.4.2(typescript@6.0.3) zigpty: specifier: catalog:deps version: 0.2.1 @@ -1204,6 +1207,9 @@ importers: pathe: specifier: catalog:deps version: 2.0.3 + valibot: + specifier: catalog:deps + version: 1.4.2(typescript@6.0.3) devDependencies: '@antfu/design': specifier: catalog:frontend diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts index d08cc714..33a08112 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts @@ -37,6 +37,13 @@ export interface DevframeClientCommand extends DevframeCommandBase { action?: (..._: any[]) => void | DevframeClientCommand[] | Promise; children?: DevframeClientCommand[]; } +export interface DevframeCommandAgentOptions { + description: string; + title?: string; + safety?: 'read' | 'action' | 'destructive'; + tags?: readonly string[]; + args?: readonly GenericSchema[]; +} export interface DevframeCommandBase { id: string; title: string; @@ -230,6 +237,7 @@ export interface DevframeServerCommandEntry extends DevframeCommandBase { } export interface DevframeServerCommandInput extends DevframeCommandBase { handler?: (..._: any[]) => any | Promise; + agent?: DevframeCommandAgentOptions; children?: DevframeServerCommandInput[]; } export interface DevframeTerminalSession extends DevframeTerminalSessionBase { diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts index 1bb6d739..2268c3f2 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts @@ -13,6 +13,7 @@ export declare class DevframeCommandsHost implements DevframeCommandsHost$1 { readonly context: DevframeHubContext; readonly commands: DevframeCommandsHost$1['commands']; readonly events: DevframeCommandsHost$1['events']; + private readonly agentHandles; constructor(_: DevframeHubContext); register(_: DevframeServerCommandInput): DevframeCommandHandle; unregister(_: string): boolean; @@ -20,6 +21,9 @@ export declare class DevframeCommandsHost implements DevframeCommandsHost$1 { list(): DevframeServerCommandEntry[]; private findCommand; private toSerializable; + private validateAgentExposure; + private registerAgentTools; + private unregisterAgentTools; } export declare class DevframeDocksHost implements DevframeDocksHost$1 { readonly context: DevframeHubContext; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js index 4677d5b7..46363d1a 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js @@ -6,6 +6,7 @@ export class DevframeCommandsHost { context commands events + agentHandles constructor(_) {} register(_) {} unregister(_) {} @@ -13,6 +14,9 @@ export class DevframeCommandsHost { list() {} findCommand(_) {} toSerializable(_) {} + validateAgentExposure(_) {} + registerAgentTools(_) {} + unregisterAgentTools(_) {} } export class DevframeDocksHost { context diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts index 698b63c0..099e839c 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts @@ -11,6 +11,7 @@ export { DevframeChildProcessOutput } export { DevframeChildProcessResult } export { DevframeChildProcessTerminalSession } export { DevframeClientCommand } +export { DevframeCommandAgentOptions } export { DevframeCommandBase } export { DevframeCommandEntry } export { DevframeCommandHandle } diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-og/rpc.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-og/rpc.snapshot.d.ts index 25339d5a..e02b51cd 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-og/rpc.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-og/rpc.snapshot.d.ts @@ -24,7 +24,7 @@ export declare const serverFunctions: readonly [{ agent?: import("devframe").RpcFunctionAgentOptions; setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; + }>>>) | undefined; handler?: ((args_0: { url?: string | undefined; - }) => { + }) => import("devframe/rpc").Thenable<{ requestedUrl: string; url: string; status: number; @@ -47,10 +47,10 @@ export declare const serverFunctions: readonly [{ name: string; value: string; }[]; - }) | undefined; + }>) | undefined; dump?: import("devframe/rpc").RpcDump<[{ url?: string | undefined; - }], { + }], import("devframe/rpc").Thenable<{ requestedUrl: string; url: string; status: number; @@ -60,11 +60,11 @@ export declare const serverFunctions: readonly [{ name: string; value: string; }[]; - }, import("devframe").DevframeNodeContext> | undefined; + }>, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; __cache?: WeakMap>> | undefined; + }>>>> | undefined; __promise?: import("devframe/rpc").Thenable> | undefined; + }>>> | undefined; }]; // #endregion diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/rpc.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/rpc.snapshot.d.ts index ef57bad9..d2323780 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/rpc.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/rpc.snapshot.d.ts @@ -29,7 +29,7 @@ export declare const serverFunctions: readonly [{ }, undefined>, undefined>; jsonSerializable?: boolean; agent?: import("devframe").RpcFunctionAgentOptions; - setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable import("devframe/rpc").Thenable>) | undefined; - handler?: (() => { + }[]>>>) | undefined; + handler?: (() => import("devframe/rpc").Thenable<{ id: string; title: string; processName?: string | undefined; @@ -68,8 +68,8 @@ export declare const serverFunctions: readonly [{ channel?: string | undefined; presetId?: string | undefined; createdAt: number; - }[]) | undefined; - dump?: import("devframe/rpc").RpcDump<[], { + }[]>) | undefined; + dump?: import("devframe/rpc").RpcDump<[], import("devframe/rpc").Thenable<{ id: string; title: string; processName?: string | undefined; @@ -88,9 +88,9 @@ export declare const serverFunctions: readonly [{ channel?: string | undefined; presetId?: string | undefined; createdAt: number; - }[], import("devframe").DevframeNodeContext> | undefined; + }[]>, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: import("devframe/rpc").Thenable>>> | undefined; + __promise?: import("devframe/rpc").Thenable> | undefined; + }[]>>> | undefined; }, { name: "devframes:plugin:terminals:presets"; type?: "query" | undefined; @@ -145,47 +145,47 @@ export declare const serverFunctions: readonly [{ }, undefined>, undefined>; jsonSerializable?: boolean; agent?: import("devframe").RpcFunctionAgentOptions; - setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable import("devframe/rpc").Thenable>) | undefined; - handler?: (() => { + }[]>>>) | undefined; + handler?: (() => import("devframe/rpc").Thenable<{ id: string; title: string; command: string; args: string[]; mode: "interactive" | "readonly"; icon?: string | undefined; - }[]) | undefined; - dump?: import("devframe/rpc").RpcDump<[], { + }[]>) | undefined; + dump?: import("devframe/rpc").RpcDump<[], import("devframe/rpc").Thenable<{ id: string; title: string; command: string; args: string[]; mode: "interactive" | "readonly"; icon?: string | undefined; - }[], import("devframe").DevframeNodeContext> | undefined; + }[]>, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: import("devframe/rpc").Thenable>>> | undefined; + __promise?: import("devframe/rpc").Thenable> | undefined; + }[]>>> | undefined; }, { name: "devframes:plugin:terminals:spawn"; type?: "action" | undefined; @@ -235,7 +235,7 @@ export declare const serverFunctions: readonly [{ env?: { [x: string]: string; } | undefined; - }], { + }], import("devframe/rpc").Thenable<{ id: string; title: string; processName?: string | undefined; @@ -254,7 +254,7 @@ export declare const serverFunctions: readonly [{ channel?: string | undefined; presetId?: string | undefined; createdAt: number; - }>>) | undefined; + }>>>) | undefined; handler?: ((args_0: { presetId?: string | undefined; command?: string | undefined; @@ -267,7 +267,7 @@ export declare const serverFunctions: readonly [{ env?: { [x: string]: string; } | undefined; - }) => { + }) => import("devframe/rpc").Thenable<{ id: string; title: string; processName?: string | undefined; @@ -286,7 +286,7 @@ export declare const serverFunctions: readonly [{ channel?: string | undefined; presetId?: string | undefined; createdAt: number; - }) | undefined; + }>) | undefined; dump?: import("devframe/rpc").RpcDump<[{ presetId?: string | undefined; command?: string | undefined; @@ -299,7 +299,7 @@ export declare const serverFunctions: readonly [{ env?: { [x: string]: string; } | undefined; - }], { + }], import("devframe/rpc").Thenable<{ id: string; title: string; processName?: string | undefined; @@ -318,7 +318,7 @@ export declare const serverFunctions: readonly [{ channel?: string | undefined; presetId?: string | undefined; createdAt: number; - }, import("devframe").DevframeNodeContext> | undefined; + }>, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; __cache?: WeakMap>> | undefined; + }>>>> | undefined; __promise?: import("devframe/rpc").Thenable> | undefined; + }>>> | undefined; }, { name: "devframes:plugin:terminals:write"; type?: "action" | undefined; @@ -398,24 +398,24 @@ export declare const serverFunctions: readonly [{ setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; + }], import("devframe/rpc").Thenable>>) | undefined; handler?: ((args_0: { id: string; data: string; - }) => void) | undefined; + }) => import("devframe/rpc").Thenable) | undefined; dump?: import("devframe/rpc").RpcDump<[{ id: string; data: string; - }], void, import("devframe").DevframeNodeContext> | undefined; + }], import("devframe/rpc").Thenable, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; __cache?: WeakMap>> | undefined; + }], import("devframe/rpc").Thenable>>> | undefined; __promise?: import("devframe/rpc").Thenable> | undefined; + }], import("devframe/rpc").Thenable>> | undefined; }, { name: "devframes:plugin:terminals:resize"; type?: "action" | undefined; @@ -432,28 +432,28 @@ export declare const serverFunctions: readonly [{ id: string; cols: number; rows: number; - }], void>>) | undefined; + }], import("devframe/rpc").Thenable>>) | undefined; handler?: ((args_0: { id: string; cols: number; rows: number; - }) => void) | undefined; + }) => import("devframe/rpc").Thenable) | undefined; dump?: import("devframe/rpc").RpcDump<[{ id: string; cols: number; rows: number; - }], void, import("devframe").DevframeNodeContext> | undefined; + }], import("devframe/rpc").Thenable, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; __cache?: WeakMap>> | undefined; + }], import("devframe/rpc").Thenable>>> | undefined; __promise?: import("devframe/rpc").Thenable> | undefined; + }], import("devframe/rpc").Thenable>> | undefined; }, { name: "devframes:plugin:terminals:terminate"; type?: "action" | undefined; @@ -466,20 +466,20 @@ export declare const serverFunctions: readonly [{ agent?: import("devframe").RpcFunctionAgentOptions; setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; + }], import("devframe/rpc").Thenable>>) | undefined; handler?: ((args_0: { id: string; - }) => void) | undefined; + }) => import("devframe/rpc").Thenable) | undefined; dump?: import("devframe/rpc").RpcDump<[{ id: string; - }], void, import("devframe").DevframeNodeContext> | undefined; + }], import("devframe/rpc").Thenable, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; __cache?: WeakMap>> | undefined; + }], import("devframe/rpc").Thenable>>> | undefined; __promise?: import("devframe/rpc").Thenable> | undefined; + }], import("devframe/rpc").Thenable>> | undefined; }, { name: "devframes:plugin:terminals:restart"; type?: "action" | undefined; @@ -511,7 +511,7 @@ export declare const serverFunctions: readonly [{ agent?: import("devframe").RpcFunctionAgentOptions; setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; + }>>>) | undefined; handler?: ((args_0: { id: string; - }) => { + }) => import("devframe/rpc").Thenable<{ id: string; title: string; processName?: string | undefined; @@ -552,10 +552,10 @@ export declare const serverFunctions: readonly [{ channel?: string | undefined; presetId?: string | undefined; createdAt: number; - }) | undefined; + }>) | undefined; dump?: import("devframe/rpc").RpcDump<[{ id: string; - }], { + }], import("devframe/rpc").Thenable<{ id: string; title: string; processName?: string | undefined; @@ -574,11 +574,11 @@ export declare const serverFunctions: readonly [{ channel?: string | undefined; presetId?: string | undefined; createdAt: number; - }, import("devframe").DevframeNodeContext> | undefined; + }>, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; __cache?: WeakMap>> | undefined; + }>>>> | undefined; __promise?: import("devframe/rpc").Thenable> | undefined; + }>>> | undefined; }, { name: "devframes:plugin:terminals:rename"; type?: "action" | undefined; @@ -634,24 +634,24 @@ export declare const serverFunctions: readonly [{ setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; + }], import("devframe/rpc").Thenable>>) | undefined; handler?: ((args_0: { id: string; title: string; - }) => void) | undefined; + }) => import("devframe/rpc").Thenable) | undefined; dump?: import("devframe/rpc").RpcDump<[{ id: string; title: string; - }], void, import("devframe").DevframeNodeContext> | undefined; + }], import("devframe/rpc").Thenable, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; __cache?: WeakMap>> | undefined; + }], import("devframe/rpc").Thenable>>> | undefined; __promise?: import("devframe/rpc").Thenable> | undefined; + }], import("devframe/rpc").Thenable>> | undefined; }, { name: "devframes:plugin:terminals:remove"; type?: "action" | undefined; @@ -664,20 +664,20 @@ export declare const serverFunctions: readonly [{ agent?: import("devframe").RpcFunctionAgentOptions; setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; + }], import("devframe/rpc").Thenable>>) | undefined; handler?: ((args_0: { id: string; - }) => void) | undefined; + }) => import("devframe/rpc").Thenable) | undefined; dump?: import("devframe/rpc").RpcDump<[{ id: string; - }], void, import("devframe").DevframeNodeContext> | undefined; + }], import("devframe/rpc").Thenable, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; __cache?: WeakMap>> | undefined; + }], import("devframe/rpc").Thenable>>> | undefined; __promise?: import("devframe/rpc").Thenable> | undefined; + }], import("devframe/rpc").Thenable>> | undefined; }, { name: "devframes:plugin:terminals:clear-exited"; type?: "action" | undefined; @@ -686,11 +686,11 @@ export declare const serverFunctions: readonly [{ returns: import("valibot").VoidSchema; jsonSerializable?: boolean; agent?: import("devframe").RpcFunctionAgentOptions; - setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; - handler?: (() => void) | undefined; - dump?: import("devframe/rpc").RpcDump<[], void, import("devframe").DevframeNodeContext> | undefined; + setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>>) | undefined; + handler?: (() => import("devframe/rpc").Thenable) | undefined; + dump?: import("devframe/rpc").RpcDump<[], import("devframe/rpc").Thenable, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: import("devframe/rpc").Thenable> | undefined; + __cache?: WeakMap>>> | undefined; + __promise?: import("devframe/rpc").Thenable>> | undefined; }]; // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts index ce7dfb37..9c610d2b 100644 --- a/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts @@ -2,6 +2,12 @@ * Generated by tsnapi — public API snapshot of `devframe/adapters/mcp` */ // #region Interfaces +export interface CreateMcpFetchHandlerOptions { + serverName: string; + serverVersion: string; + exposeSharedState: boolean | ((_: string) => boolean); + allowedOrigins?: readonly string[] | false; +} export interface CreateMcpServerOptions { transport?: 'stdio'; exposeSharedState?: boolean | ((_: string) => boolean); @@ -11,11 +17,16 @@ export interface CreateMcpServerOptions { transport: 'stdio'; }) => void; } +export interface McpFetchHandler { + fetch: (_: Request) => Promise; + dispose: () => Promise; +} export interface McpServerHandle { stop: () => Promise; } // #endregion // #region Functions +export declare function createMcpFetchHandler(_: DevframeNodeContext, _: CreateMcpFetchHandlerOptions): McpFetchHandler; export declare function createMcpServer(_: DevframeDefinition, _?: CreateMcpServerOptions): Promise; // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.js b/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.js index 57441c35..8743467b 100644 --- a/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.js @@ -2,5 +2,6 @@ * Generated by tsnapi — public API snapshot of `devframe/adapters/mcp` */ // #region Other +export { createMcpFetchHandler } export { createMcpServer } // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/recipes/open-helpers.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/recipes/open-helpers.snapshot.d.ts index ea53a20a..199048d6 100644 --- a/tests/__snapshots__/tsnapi/devframe/recipes/open-helpers.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/recipes/open-helpers.snapshot.d.ts @@ -10,12 +10,12 @@ export declare const openHelpers: readonly [{ returns: v.VoidSchema; jsonSerializable?: boolean; agent?: RpcFunctionAgentOptions; - setup?: ((context: undefined) => Thenable>) | undefined; - handler?: ((args_0: string) => void) | undefined; - dump?: RpcDump<[string], void, undefined> | undefined; + setup?: ((context: undefined) => Thenable>>) | undefined; + handler?: ((args_0: string) => Thenable) | undefined; + dump?: RpcDump<[string], Thenable, undefined> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: Thenable> | undefined; + __cache?: WeakMap>>> | undefined; + __promise?: Thenable>> | undefined; }, { name: "devframe:open-in-finder"; type?: "action" | undefined; @@ -24,12 +24,12 @@ export declare const openHelpers: readonly [{ returns: v.VoidSchema; jsonSerializable?: boolean; agent?: RpcFunctionAgentOptions; - setup?: ((context: undefined) => Thenable>) | undefined; - handler?: ((args_0: string) => void) | undefined; - dump?: RpcDump<[string], void, undefined> | undefined; + setup?: ((context: undefined) => Thenable>>) | undefined; + handler?: ((args_0: string) => Thenable) | undefined; + dump?: RpcDump<[string], Thenable, undefined> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: Thenable> | undefined; + __cache?: WeakMap>>> | undefined; + __promise?: Thenable>> | undefined; }]; export declare const openInEditor: { name: "devframe:open-in-editor"; @@ -39,12 +39,12 @@ export declare const openInEditor: { returns: v.VoidSchema; jsonSerializable?: boolean; agent?: RpcFunctionAgentOptions; - setup?: ((context: undefined) => Thenable>) | undefined; - handler?: ((args_0: string) => void) | undefined; - dump?: RpcDump<[string], void, undefined> | undefined; + setup?: ((context: undefined) => Thenable>>) | undefined; + handler?: ((args_0: string) => Thenable) | undefined; + dump?: RpcDump<[string], Thenable, undefined> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: Thenable> | undefined; + __cache?: WeakMap>>> | undefined; + __promise?: Thenable>> | undefined; }; export declare const openInFinder: { name: "devframe:open-in-finder"; @@ -54,11 +54,11 @@ export declare const openInFinder: { returns: v.VoidSchema; jsonSerializable?: boolean; agent?: RpcFunctionAgentOptions; - setup?: ((context: undefined) => Thenable>) | undefined; - handler?: ((args_0: string) => void) | undefined; - dump?: RpcDump<[string], void, undefined> | undefined; + setup?: ((context: undefined) => Thenable>>) | undefined; + handler?: ((args_0: string) => Thenable) | undefined; + dump?: RpcDump<[string], Thenable, undefined> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: Thenable> | undefined; + __cache?: WeakMap>>> | undefined; + __promise?: Thenable>> | undefined; }; // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/valibot-json-schema.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/utils/valibot-json-schema.snapshot.d.ts new file mode 100644 index 00000000..dbbf3298 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/valibot-json-schema.snapshot.d.ts @@ -0,0 +1,10 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/valibot-json-schema` + */ +// #region Functions +export declare function valibotArgsToJsonSchema(_: readonly GenericSchema[] | undefined): { + schema: unknown; + unwrapped: boolean; +}; +export declare function valibotReturnToJsonSchema(_: GenericSchema | undefined): unknown; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/valibot-json-schema.snapshot.js b/tests/__snapshots__/tsnapi/devframe/utils/valibot-json-schema.snapshot.js new file mode 100644 index 00000000..563c03e8 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/valibot-json-schema.snapshot.js @@ -0,0 +1,7 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/valibot-json-schema` + */ +// #region Functions +export function valibotArgsToJsonSchema(_) {} +export function valibotReturnToJsonSchema(_) {} +// #endregion \ No newline at end of file diff --git a/tsconfig.base.json b/tsconfig.base.json index e6c6a69f..07ff85ec 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -79,6 +79,9 @@ "devframe/utils/structured-clone": [ "./packages/devframe/src/utils/structured-clone.ts" ], + "devframe/utils/valibot-json-schema": [ + "./packages/devframe/src/utils/valibot-json-schema.ts" + ], "devframe/utils/when": [ "./packages/devframe/src/utils/when.ts" ],