@devframes/agentic 0.9.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-PRESENT Anthony Fu <https://github.com/antfu>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,58 @@
1
+ import { DevframeInstanceRecord } from "devframe/internal";
2
+ //#region src/connect/index.d.ts
3
+ export interface ConnectServerOptions {
4
+ /**
5
+ * Explicit ports to probe besides the registry, for instances started
6
+ * before the registry existed, or reachable only by convention. Each port
7
+ * is probed at `/` (`http://localhost:<port>/__connection.json`).
8
+ */
9
+ ports?: number[];
10
+ /** Override the registry directory (`DEVFRAME_INSTANCES_DIR` also applies). */
11
+ instancesDir?: string;
12
+ /** Probe timeout per instance, ms. Default 1000. */
13
+ timeoutMs?: number;
14
+ /**
15
+ * The bearer credential the connector presents to each instance's
16
+ * authenticated MCP route, sent as `Authorization: Bearer <token>`.
17
+ *
18
+ * - a **string**: one shared token for every instance;
19
+ * - a **resolver** `(record) => string | undefined`: a per-instance token,
20
+ * for connecting to a fleet with distinct credentials (return `undefined`
21
+ * to send none for that instance).
22
+ *
23
+ * The token is only ever placed in a request header: it never enters the
24
+ * instance registry records, the indexed results, connection URLs, or
25
+ * formatted errors. Left unset, no `Authorization` header is sent, so only an
26
+ * instance whose route opted out of identity (`authorization: false`) will
27
+ * answer.
28
+ */
29
+ authToken?: string | ((record: DevframeInstanceRecord) => string | undefined);
30
+ }
31
+ /**
32
+ * Resolve the per-record bearer from the {@link ConnectServerOptions.authToken}
33
+ * option. Exported for focused tests of the credential resolution.
34
+ */
35
+ export declare function resolveAuthToken(authToken: ConnectServerOptions['authToken'], record: DevframeInstanceRecord): string | undefined;
36
+ /**
37
+ * Build the request headers the connector sends to one instance's MCP route:
38
+ * the instance's own (loopback) `origin` so the route's origin gate accepts
39
+ * this native client, plus `Authorization: Bearer <token>` when a bearer is
40
+ * configured. The bearer appears **only** here, never in the connection URL,
41
+ * the registry records, or the indexed results. Exported for focused tests.
42
+ */
43
+ export declare function buildInstanceRequestHeaders(url: string, token: string | undefined): Record<string, string>;
44
+ export interface ConnectServerHandle {
45
+ stop: () => Promise<void>;
46
+ }
47
+ /**
48
+ * Start the devframe MCP connector on stdio: a thin discovery + proxy server
49
+ * in the shape Vercel's next-devtools-mcp (https://github.com/vercel/next-devtools-mcp)
50
+ * validated; credit is due there for the architecture this connector follows.
51
+ * It exposes two gateway tools:
52
+ * `devframe_connect_list-instances` (discover running devframe instances via
53
+ * the instance registry and list each one's MCP tools) and
54
+ * `devframe_connect_call-tool` (invoke one tool on one instance over its
55
+ * Streamable-HTTP endpoint), and holds no domain knowledge of its own.
56
+ */
57
+ export declare function startConnectServer(options?: ConnectServerOptions): Promise<ConnectServerHandle>;
58
+ //#endregion
@@ -0,0 +1,243 @@
1
+ import { Server } from "@modelcontextprotocol/server";
2
+ import { diagnostics, listLiveDevframeInstances, probeDevframeOrigin } from "devframe/internal";
3
+ import { toAgentToolName } from "devframe/utils/agent-tool-name";
4
+ import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
6
+ import { Diagnostic } from "devframe/utils/nostics";
7
+ import { joinURL } from "devframe/utils/url";
8
+ //#region src/connect/index.ts
9
+ /**
10
+ * Resolve the per-record bearer from the {@link ConnectServerOptions.authToken}
11
+ * option. Exported for focused tests of the credential resolution.
12
+ */
13
+ function resolveAuthToken(authToken, record) {
14
+ return typeof authToken === "function" ? authToken(record) : authToken;
15
+ }
16
+ /**
17
+ * Build the request headers the connector sends to one instance's MCP route:
18
+ * the instance's own (loopback) `origin` so the route's origin gate accepts
19
+ * this native client, plus `Authorization: Bearer <token>` when a bearer is
20
+ * configured. The bearer appears **only** here, never in the connection URL,
21
+ * the registry records, or the indexed results. Exported for focused tests.
22
+ */
23
+ function buildInstanceRequestHeaders(url, token) {
24
+ const headers = { origin: new URL(url).origin };
25
+ if (token) headers.authorization = `Bearer ${token}`;
26
+ return headers;
27
+ }
28
+ const INDEX_TOOL = toAgentToolName("devframe:connect:list-instances");
29
+ const CALL_TOOL = toAgentToolName("devframe:connect:call-tool");
30
+ const MCP_DISABLED_HINT = "This instance runs without an MCP route. Restart it with the --mcp flag to expose its tools, then list instances again.";
31
+ const GATEWAY_TOOLS = [{
32
+ name: INDEX_TOOL,
33
+ title: "Discover running devframes",
34
+ description: "Discover every running devframe dev server on this machine and list each one's MCP tools. Call this FIRST, before assuming which devtools are available; the result names the instance (id, project root, origin) and the port to pass to the call tool. Safe to call freely.",
35
+ inputSchema: {
36
+ type: "object",
37
+ properties: {}
38
+ },
39
+ annotations: {
40
+ readOnlyHint: true,
41
+ destructiveHint: false
42
+ }
43
+ }, {
44
+ name: CALL_TOOL,
45
+ title: "Call a devframe tool",
46
+ description: "Invoke one MCP tool on one running devframe instance discovered via the list-instances tool. Pass the instance's port, the tool name, and the tool's arguments object.",
47
+ inputSchema: {
48
+ type: "object",
49
+ properties: {
50
+ port: {
51
+ type: "number",
52
+ description: "The instance's port, from the list-instances tool."
53
+ },
54
+ tool: {
55
+ type: "string",
56
+ description: "Tool name, from the instance's tool list."
57
+ },
58
+ args: {
59
+ type: "object",
60
+ description: "Arguments object for the tool. Omit for zero-argument tools."
61
+ }
62
+ },
63
+ required: ["port", "tool"],
64
+ additionalProperties: false
65
+ }
66
+ }];
67
+ /**
68
+ * Start the devframe MCP connector on stdio: a thin discovery + proxy server
69
+ * in the shape Vercel's next-devtools-mcp (https://github.com/vercel/next-devtools-mcp)
70
+ * validated; credit is due there for the architecture this connector follows.
71
+ * It exposes two gateway tools:
72
+ * `devframe_connect_list-instances` (discover running devframe instances via
73
+ * the instance registry and list each one's MCP tools) and
74
+ * `devframe_connect_call-tool` (invoke one tool on one instance over its
75
+ * Streamable-HTTP endpoint), and holds no domain knowledge of its own.
76
+ */
77
+ async function startConnectServer(options = {}) {
78
+ const server = new Server({
79
+ name: "devframe-connect",
80
+ version: "0.0.0"
81
+ }, { capabilities: { tools: {} } });
82
+ server.setRequestHandler("tools/list", async () => ({ tools: GATEWAY_TOOLS }));
83
+ server.setRequestHandler("tools/call", async (request) => {
84
+ const { name, arguments: args } = request.params;
85
+ try {
86
+ if (name === INDEX_TOOL) return textResult(await index(options));
87
+ if (name === CALL_TOOL) return textResult(await call(options, args ?? {}));
88
+ return errorResult({
89
+ message: `unknown tool "${name}"`,
90
+ fix: `Call ${INDEX_TOOL} or ${CALL_TOOL}.`
91
+ });
92
+ } catch (error) {
93
+ return errorResult(toErrorPayload(error));
94
+ }
95
+ });
96
+ const transport = new StdioServerTransport();
97
+ await server.connect(transport);
98
+ return { stop: async () => {
99
+ await server.close();
100
+ } };
101
+ }
102
+ /** Discover instances: registry (prune-on-read) + explicit port probes. */
103
+ async function index(options) {
104
+ const { live } = await listLiveDevframeInstances({
105
+ instancesDir: options.instancesDir,
106
+ timeoutMs: options.timeoutMs
107
+ });
108
+ const records = [...live];
109
+ for (const port of options.ports ?? []) {
110
+ if (records.some((r) => r.port === port)) continue;
111
+ const probed = await probePort(port, options.timeoutMs);
112
+ if (probed) records.push(probed);
113
+ }
114
+ const instances = await Promise.all(records.map(async (record) => {
115
+ const { mcp, ...rest } = record;
116
+ const entry = {
117
+ ...rest,
118
+ mcp: null
119
+ };
120
+ if (!mcp) {
121
+ entry.hint = MCP_DISABLED_HINT;
122
+ return entry;
123
+ }
124
+ const url = `${record.origin}${mcp.path}`;
125
+ try {
126
+ entry.mcp = {
127
+ url,
128
+ tools: await listInstanceTools(url, resolveAuthToken(options.authToken, record))
129
+ };
130
+ } catch (error) {
131
+ entry.mcp = {
132
+ url,
133
+ error: error instanceof Error ? error.message : String(error)
134
+ };
135
+ }
136
+ return entry;
137
+ }));
138
+ return {
139
+ instances,
140
+ ...instances.length === 0 ? { hint: "No running devframe instances found. Start a devframe dev server (with --mcp for tools), or pass --port <n> to devframe connect if the instance predates the registry." } : {}
141
+ };
142
+ }
143
+ /**
144
+ * Probe an explicit port for a devframe serving `__connection.json` at `/`,
145
+ * reusing the registry's origin-candidate probe (a `localhost`-bound server
146
+ * may listen on either address family).
147
+ */
148
+ async function probePort(port, timeoutMs) {
149
+ const probed = await probeDevframeOrigin(`http://localhost:${port}`, "/", timeoutMs);
150
+ if (!probed) return null;
151
+ const mcpPath = probed.meta.mcp ? joinURL("/", probed.meta.mcp.path) : null;
152
+ return {
153
+ pid: -1,
154
+ port,
155
+ origin: probed.origin,
156
+ basePath: "/",
157
+ id: `port-${port}`,
158
+ rootDir: "",
159
+ mcp: mcpPath ? { path: mcpPath } : null,
160
+ startedAt: 0
161
+ };
162
+ }
163
+ async function listInstanceTools(url, token) {
164
+ return withInstanceClient(url, token, async (client) => {
165
+ return (await client.listTools()).tools.map((tool) => ({
166
+ name: tool.name,
167
+ description: tool.description
168
+ }));
169
+ });
170
+ }
171
+ async function call(options, args) {
172
+ if (typeof args.port !== "number" || typeof args.tool !== "string") throw diagnostics.DF0049();
173
+ const { live } = await listLiveDevframeInstances({
174
+ instancesDir: options.instancesDir,
175
+ timeoutMs: options.timeoutMs
176
+ });
177
+ const record = live.find((r) => r.port === args.port) ?? await probePort(args.port, options.timeoutMs);
178
+ if (!record) throw diagnostics.DF0050({ port: args.port });
179
+ if (!record.mcp) throw diagnostics.DF0051({ port: args.port });
180
+ return withInstanceClient(`${record.origin}${record.mcp.path}`, resolveAuthToken(options.authToken, record), async (client) => {
181
+ const result = await client.callTool({
182
+ name: args.tool,
183
+ arguments: args.args ?? {}
184
+ });
185
+ return {
186
+ instance: {
187
+ id: record.id,
188
+ port: record.port
189
+ },
190
+ tool: args.tool,
191
+ isError: result.isError ?? false,
192
+ content: result.content,
193
+ ...result.structuredContent ? { structuredContent: result.structuredContent } : {}
194
+ };
195
+ });
196
+ }
197
+ async function withInstanceClient(url, token, fn) {
198
+ const transport = new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers: buildInstanceRequestHeaders(url, token) } });
199
+ const client = new Client({
200
+ name: "devframe-connect",
201
+ version: "0.0.0"
202
+ }, { versionNegotiation: { mode: "auto" } });
203
+ await client.connect(transport);
204
+ try {
205
+ return await fn(client);
206
+ } finally {
207
+ await client.close().catch(() => {});
208
+ }
209
+ }
210
+ function textResult(value) {
211
+ return { content: [{
212
+ type: "text",
213
+ text: JSON.stringify(value, null, 2)
214
+ }] };
215
+ }
216
+ /**
217
+ * Project a thrown value into the connector's structured error payload. A
218
+ * nostics `Diagnostic` carries its code, `fix`, and docs URL across so the
219
+ * calling agent gets the actionable next step.
220
+ */
221
+ function toErrorPayload(error) {
222
+ if (error instanceof Diagnostic) return {
223
+ code: error.code,
224
+ message: error.message,
225
+ ...error.fix ? { fix: error.fix } : {},
226
+ ...error.docs ? { docs: error.docs } : {}
227
+ };
228
+ return {
229
+ message: error instanceof Error ? error.message : String(error),
230
+ ...error && typeof error === "object" && "fix" in error && typeof error.fix === "string" ? { fix: error.fix } : {}
231
+ };
232
+ }
233
+ function errorResult(error) {
234
+ return {
235
+ isError: true,
236
+ content: [{
237
+ type: "text",
238
+ text: JSON.stringify({ error }, null, 2)
239
+ }]
240
+ };
241
+ }
242
+ //#endregion
243
+ export { buildInstanceRequestHeaders, resolveAuthToken, startConnectServer };
@@ -0,0 +1 @@
1
+ export {}
package/dist/index.mjs ADDED
@@ -0,0 +1,4 @@
1
+ //#region src/index.ts
2
+ throw new Error("[@devframes/agentic] is not imported directly; installing it enables devframe's agent surfaces.\n • import from \"devframe/adapters/mcp\" to serve a devframe over MCP\n • run \"devframe connect\" for the stdio discovery gateway\n");
3
+ //#endregion
4
+ export {};
@@ -0,0 +1,61 @@
1
+ import { Server } from "@modelcontextprotocol/server";
2
+ import { MountMcpHttpOptions, MountMcpHttpOptions as MountMcpHttpOptions$1, MountedMcpHttp, MountedMcpHttp as MountedMcpHttp$1 } from "devframe/internal";
3
+ import { H3 } from "h3";
4
+ import { CreateMcpFetchHandlerOptions, CreateMcpFetchHandlerOptions as CreateMcpFetchHandlerOptions$1, CreateMcpServerOptions, CreateMcpServerOptions as CreateMcpServerOptions$1, DevframeDefinition, DevframeNodeContext, McpConnectionInfo, McpFetchHandler, McpFetchHandler as McpFetchHandler$1, McpServerHandle, McpServerHandle as McpServerHandle$1 } from "devframe/types";
5
+ //#region src/mcp/build-server.d.ts
6
+ /**
7
+ * Build an MCP server over the agent surface of a devframe definition.
8
+ * Currently supports `stdio` transport only.
9
+ */
10
+ export declare function createMcpServer(definition: DevframeDefinition, options?: CreateMcpServerOptions$1): Promise<McpServerHandle$1>;
11
+ //#endregion
12
+ //#region src/mcp/fetch.d.ts
13
+ /**
14
+ * Build a framework-agnostic MCP endpoint over a devframe context: a
15
+ * web-standard `Request → Response` handler any host can mount: h3 (see
16
+ * `mountMcpHttp`), a Next.js App Router route, or any other fetch-shaped
17
+ * server.
18
+ *
19
+ * The endpoint is **stateless**: it serves the 2026-07-28 revision per request
20
+ * through the SDK's {@link createMcpHandler}, which builds a fresh MCP server
21
+ * (from the shared, live `ctx` via `buildMcpServerFromContext`) for each
22
+ * request: no `Mcp-Session-Id` registry, no session-local routing, no
23
+ * GET/DELETE teardown protocol. 2025-era clients are still served through the
24
+ * SDK's default stateless legacy path. `list_changed` events reach modern
25
+ * `subscriptions/listen` streams through the handler's `notify` bus.
26
+ *
27
+ * The origin gate guards every request: loopback-default DNS-rebinding
28
+ * protection that (unlike the WS upgrade's `isAllowedOrigin`) also rejects
29
+ * `Origin`-less requests, so a browser can't reach the route across origins (a
30
+ * disallowed origin gets `403`). The `Origin` header is only browser hardening:
31
+ * a non-browser client forges it. So on the zero-config default (no widened
32
+ * `allowedOrigins`, no identity check) a second locality gate requires the
33
+ * connected peer to be loopback, proven from the host-supplied
34
+ * {@link McpConnectionInfo.remoteAddress} (which a client cannot forge), so the
35
+ * "trusts same-machine callers" default holds against a remote raw client.
36
+ * When same-machine isn't your trust boundary, add an identity gate
37
+ * ({@link CreateMcpFetchHandlerOptions.authorization}), checked after the
38
+ * origin gate: a bearer/callback check that proves *who* is calling (a
39
+ * missing/invalid credential gets `401` with a `WWW-Authenticate: Bearer`
40
+ * challenge), which also lifts the loopback-peer restriction for authenticated
41
+ * callers.
42
+ */
43
+ export declare function createMcpFetchHandler(ctx: DevframeNodeContext, options: CreateMcpFetchHandlerOptions$1): McpFetchHandler$1;
44
+ //#endregion
45
+ //#region src/mcp/http.d.ts
46
+ /**
47
+ * Mount a stateless MCP endpoint on an h3 app at `path`: the h3 binding over
48
+ * {@link createMcpFetchHandler}, which owns the per-request serving, the
49
+ * origin gate, and the transport plumbing.
50
+ *
51
+ * The handler is web-standard: it takes the h3 event's web `Request` and
52
+ * returns a web `Response` (an SSE `ReadableStream` body for a
53
+ * `subscriptions/listen` stream). We copy that response onto `event.res` and
54
+ * return its body rather than returning the `Response` object directly, so an
55
+ * MCP error response (e.g. a 4xx) isn't swallowed by h3's "Response-with-404
56
+ * falls through to the next handler" rule (which would otherwise hand the
57
+ * request to the SPA static catch-all).
58
+ */
59
+ export declare function mountMcpHttp(app: H3, ctx: DevframeNodeContext, path: string, options: MountMcpHttpOptions$1): MountedMcpHttp$1;
60
+ //#endregion
61
+ export type { CreateMcpFetchHandlerOptions, CreateMcpServerOptions, McpConnectionInfo, McpFetchHandler, McpServerHandle, MountMcpHttpOptions, MountedMcpHttp };
@@ -0,0 +1,464 @@
1
+ import { homedir } from "node:os";
2
+ import process from "node:process";
3
+ import { Server, createMcpHandler } from "@modelcontextprotocol/server";
4
+ import { DEVFRAME_EVENTS } from "devframe/constants";
5
+ import { argsToJsonSchema, diagnostics, formatMcpError, returnToJsonSchema, stringifyForMcp } from "devframe/internal";
6
+ import { createHostContext } from "devframe/node";
7
+ import { toAgentToolName } from "devframe/utils/agent-tool-name";
8
+ import { join } from "pathe";
9
+ import { timingSafeEqual } from "devframe/utils/crypto-token";
10
+ import { isAllowedOrigin, isLoopbackAddress } from "devframe/utils/origin";
11
+ import { defineHandler, getRequestIP } from "h3";
12
+ //#region src/mcp/build-server.ts
13
+ /**
14
+ * Build a fresh MCP {@link Server} over a devframe context, registering its
15
+ * tool and resource handlers. This is a pure factory: it sets up no
16
+ * long-lived subscriptions and holds no per-connection state, so it is safe
17
+ * to call once per request under `createMcpHandler` or once per connection
18
+ * under `serveStdio`. Change notifications are published separately: over
19
+ * HTTP through the handler's `notify` bus (see `createMcpFetchHandler`), and
20
+ * on stdio through the connection's own `send*ListChanged` calls (see
21
+ * {@link bridgeListChanged}, wired by `serveStdio`).
22
+ *
23
+ * @internal
24
+ */
25
+ function buildMcpServerFromContext(ctx, options) {
26
+ const server = new Server({
27
+ name: options.serverName,
28
+ version: options.serverVersion
29
+ }, { capabilities: {
30
+ tools: { listChanged: true },
31
+ resources: { listChanged: true }
32
+ } });
33
+ registerToolHandlers(server, ctx, options.exposeSharedState);
34
+ registerResourceHandlers(server, ctx, options.exposeSharedState);
35
+ return server;
36
+ }
37
+ /**
38
+ * Publish devframe's `list_changed` events through a set of typed sinks:
39
+ * `tools()` for tool-list changes and `resources()` for resource-list
40
+ * changes (shared-state keys are surfaced as resources). Returns an
41
+ * unsubscribe function.
42
+ *
43
+ * The HTTP path passes the handler's `notify` bus sugar; the stdio path
44
+ * passes the pinned server's `send*ListChanged` methods, which `serveStdio`
45
+ * routes onto the connection's active `subscriptions/listen` streams.
46
+ *
47
+ * @internal
48
+ */
49
+ function bridgeListChanged(ctx, sinks) {
50
+ const offManifest = ctx.agent.events.on(DEVFRAME_EVENTS.bus.agentManifestChanged, () => {
51
+ sinks.tools();
52
+ sinks.resources();
53
+ });
54
+ const offKeyAdded = ctx.rpc.sharedState.onKeyAdded(() => {
55
+ sinks.resources();
56
+ });
57
+ return () => {
58
+ offManifest();
59
+ offKeyAdded();
60
+ };
61
+ }
62
+ /**
63
+ * Build an MCP server over the agent surface of a devframe definition.
64
+ * Currently supports `stdio` transport only.
65
+ */
66
+ async function createMcpServer(definition, options = {}) {
67
+ const transport = options.transport ?? "stdio";
68
+ if (transport !== "stdio") throw diagnostics.DF0017({
69
+ transport,
70
+ reason: "Only stdio transport is supported in this release."
71
+ });
72
+ const ctx = await createHostContext({
73
+ cwd: process.cwd(),
74
+ mode: "dev",
75
+ host: {
76
+ mountStatic: () => {},
77
+ resolveOrigin: () => "mcp://devframe",
78
+ getStorageDir: (scope) => {
79
+ if (scope === "workspace") return join(process.cwd(), ".devframe");
80
+ if (scope === "project") return join(process.cwd(), `node_modules/.${definition.id}/devframe`);
81
+ return join(homedir(), `.${definition.id}/devframe`);
82
+ }
83
+ },
84
+ importMetaUrl: definition.importMetaUrl
85
+ });
86
+ for (const input of definition.services ?? []) ctx.services.install(input, { resolveFrom: definition.importMetaUrl });
87
+ await ctx.services.ready();
88
+ await definition.setup(ctx);
89
+ const buildOptions = {
90
+ serverName: options.serverName ?? `${definition.id} (devframe)`,
91
+ serverVersion: options.serverVersion ?? definition.version ?? "0.0.0",
92
+ exposeSharedState: options.exposeSharedState ?? true
93
+ };
94
+ let handle;
95
+ try {
96
+ const { serveStdio } = await import("@modelcontextprotocol/server/stdio");
97
+ handle = serveStdio(() => {
98
+ const server = buildMcpServerFromContext(ctx, buildOptions);
99
+ const unbridge = bridgeListChanged(ctx, {
100
+ tools: () => {
101
+ server.sendToolListChanged().catch(() => {});
102
+ },
103
+ resources: () => {
104
+ server.sendResourceListChanged().catch(() => {});
105
+ }
106
+ });
107
+ const priorOnClose = server.onclose;
108
+ server.onclose = () => {
109
+ unbridge();
110
+ priorOnClose?.();
111
+ };
112
+ return server;
113
+ });
114
+ } catch (error) {
115
+ const reason = error instanceof Error ? error.message : String(error);
116
+ throw diagnostics.DF0017({
117
+ transport,
118
+ reason,
119
+ cause: error
120
+ });
121
+ }
122
+ options.onReady?.({ transport: "stdio" });
123
+ return { async stop() {
124
+ await handle.close();
125
+ } };
126
+ }
127
+ /**
128
+ * Id of the built-in shared-state read tool, namespaced like every other
129
+ * built-in (`devframe:<area>:<fn>`). Tool-shaped access matters because many
130
+ * MCP clients only consume tools; the parallel `devframe://state/<key>`
131
+ * resource projection stays for the clients that do read resources.
132
+ */
133
+ const READ_STATE_TOOL = "devframe:state:read";
134
+ /** Wire name of the built-in shared-state read tool: `devframe_state_read`. */
135
+ const READ_STATE_NAME = toAgentToolName(READ_STATE_TOOL);
136
+ function sharedStateFilter(exposeSharedState) {
137
+ if (exposeSharedState === false) return void 0;
138
+ return typeof exposeSharedState === "function" ? exposeSharedState : () => true;
139
+ }
140
+ function readStateToolProjection() {
141
+ return {
142
+ name: READ_STATE_NAME,
143
+ title: "Read shared state",
144
+ 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.",
145
+ inputSchema: {
146
+ type: "object",
147
+ properties: { key: {
148
+ type: "string",
149
+ description: "A shared-state key from the key list. Omit to list all keys."
150
+ } }
151
+ },
152
+ annotations: {
153
+ title: "Read shared state",
154
+ readOnlyHint: true,
155
+ destructiveHint: false
156
+ }
157
+ };
158
+ }
159
+ async function readStateResult(ctx, filter, key) {
160
+ const keys = ctx.rpc.sharedState.keys().filter(filter);
161
+ if (key === void 0) return { keys };
162
+ if (!keys.includes(key)) throw diagnostics.DF0048({ key });
163
+ return {
164
+ key,
165
+ value: (await ctx.rpc.sharedState.get(key)).value()
166
+ };
167
+ }
168
+ function registerToolHandlers(server, ctx, exposeSharedState) {
169
+ const stateFilter = sharedStateFilter(exposeSharedState);
170
+ const warnedCollisions = /* @__PURE__ */ new Set();
171
+ /**
172
+ * Resolve a wire tool name back to the registered {@link AgentTool}.
173
+ * Wire-name matching runs first, in manifest order (the same tool the
174
+ * list projection advertises under that name), with a raw-id fallback so
175
+ * a colon-namespaced id keeps working as a call name.
176
+ */
177
+ const resolveTool = (name) => {
178
+ return ctx.agent.list().tools.find((tool) => toAgentToolName(tool.id) === name) ?? ctx.agent.getTool(name);
179
+ };
180
+ server.setRequestHandler("tools/list", async () => {
181
+ const byName = /* @__PURE__ */ new Map();
182
+ for (const tool of ctx.agent.list().tools) {
183
+ const name = toAgentToolName(tool.id);
184
+ const existing = byName.get(name);
185
+ if (existing) {
186
+ if (!warnedCollisions.has(`${name}|${tool.id}`)) {
187
+ warnedCollisions.add(`${name}|${tool.id}`);
188
+ diagnostics.DF0047({
189
+ name,
190
+ id: tool.id,
191
+ existing: existing.id
192
+ });
193
+ }
194
+ continue;
195
+ }
196
+ byName.set(name, tool);
197
+ }
198
+ const tools = [...byName.entries()].map(([name, tool]) => projectTool(name, tool, ctx));
199
+ if (stateFilter && !byName.has(READ_STATE_NAME)) tools.push(readStateToolProjection());
200
+ return { tools };
201
+ });
202
+ server.setRequestHandler("tools/call", async (request) => {
203
+ const { name, arguments: args } = request.params;
204
+ try {
205
+ const tool = resolveTool(name);
206
+ if (stateFilter && !tool && (name === READ_STATE_NAME || name === READ_STATE_TOOL)) {
207
+ const key = args?.key;
208
+ const result = await readStateResult(ctx, stateFilter, key);
209
+ return {
210
+ content: [{
211
+ type: "text",
212
+ text: stringifyForMcp(result)
213
+ }],
214
+ structuredContent: result
215
+ };
216
+ }
217
+ const outputSchema = tool ? usableOutputSchema(tool.outputSchema ?? computeOutputSchema(tool, ctx)) : void 0;
218
+ const result = await ctx.agent.invoke(tool?.id ?? name, args ?? {});
219
+ return {
220
+ content: [{
221
+ type: "text",
222
+ text: stringifyForMcp(result)
223
+ }],
224
+ ...outputSchema ? { structuredContent: result } : {}
225
+ };
226
+ } catch (error) {
227
+ return {
228
+ isError: true,
229
+ content: [{
230
+ type: "text",
231
+ text: `Error invoking "${name}": ${formatMcpError(error)}`
232
+ }]
233
+ };
234
+ }
235
+ });
236
+ }
237
+ function registerResourceHandlers(server, ctx, exposeSharedState) {
238
+ const stateFilter = sharedStateFilter(exposeSharedState);
239
+ server.setRequestHandler("resources/list", async () => {
240
+ const resources = ctx.agent.list().resources.map((resource) => ({
241
+ uri: resource.uri,
242
+ name: resource.name,
243
+ description: resource.description,
244
+ mimeType: resource.mimeType
245
+ }));
246
+ if (stateFilter) for (const key of ctx.rpc.sharedState.keys()) {
247
+ if (!stateFilter(key)) continue;
248
+ resources.push({
249
+ uri: `devframe://state/${encodeURIComponent(key)}`,
250
+ name: key,
251
+ description: `Shared state: ${key}`,
252
+ mimeType: "application/json"
253
+ });
254
+ }
255
+ return { resources };
256
+ });
257
+ server.setRequestHandler("resources/read", async (request) => {
258
+ const { uri } = request.params;
259
+ const parsed = parseResourceUri(uri);
260
+ if (parsed.kind === "resource") {
261
+ const content = await ctx.agent.read(parsed.id);
262
+ return { contents: [{
263
+ uri,
264
+ mimeType: content.mimeType ?? "application/json",
265
+ text: content.text ?? stringifyForMcp(content.json)
266
+ }] };
267
+ }
268
+ if (parsed.kind === "state") {
269
+ if (!stateFilter || !stateFilter(parsed.key)) throw diagnostics.DF0048({ key: parsed.key });
270
+ const state = await ctx.rpc.sharedState.get(parsed.key);
271
+ return { contents: [{
272
+ uri,
273
+ mimeType: "application/json",
274
+ text: stringifyForMcp(state.value())
275
+ }] };
276
+ }
277
+ throw new Error(`[devframe/mcp] unknown resource URI "${uri}"`);
278
+ });
279
+ }
280
+ /**
281
+ * MCP constrains a tool's `outputSchema` to a JSON Schema of `type:
282
+ * "object"`; clients (the SDK included) reject anything else. Non-object
283
+ * return schemas (e.g. a schema for `void` / a bare string) simply project
284
+ * no output schema; the text content still carries the result.
285
+ */
286
+ function usableOutputSchema(schema) {
287
+ return schema && typeof schema === "object" && schema.type === "object" ? schema : void 0;
288
+ }
289
+ function projectTool(name, tool, ctx) {
290
+ const inputSchema = tool.inputSchema ?? computeInputSchema(tool, ctx);
291
+ const outputSchema = usableOutputSchema(tool.outputSchema ?? computeOutputSchema(tool, ctx));
292
+ return {
293
+ name,
294
+ title: tool.title,
295
+ description: tool.description,
296
+ inputSchema,
297
+ ...outputSchema ? { outputSchema } : {},
298
+ annotations: {
299
+ title: tool.title,
300
+ readOnlyHint: tool.safety === "read",
301
+ destructiveHint: tool.safety === "destructive"
302
+ }
303
+ };
304
+ }
305
+ function computeInputSchema(tool, ctx) {
306
+ if (tool.kind === "tool") return argsToJsonSchema(tool.args);
307
+ if (tool.kind !== "rpc" || !tool.rpcName) return {
308
+ type: "object",
309
+ properties: {}
310
+ };
311
+ const def = ctx.rpc.definitions.get(tool.rpcName);
312
+ if (!def) return {
313
+ type: "object",
314
+ properties: {}
315
+ };
316
+ const args = def.args;
317
+ return argsToJsonSchema(args);
318
+ }
319
+ function computeOutputSchema(tool, ctx) {
320
+ if (tool.kind !== "rpc" || !tool.rpcName) return void 0;
321
+ const def = ctx.rpc.definitions.get(tool.rpcName);
322
+ if (!def) return void 0;
323
+ return returnToJsonSchema(def.returns);
324
+ }
325
+ function parseResourceUri(uri) {
326
+ const match = uri.match(/^devframe:\/\/(resource|state)\/(.+)$/);
327
+ if (!match) return { kind: "unknown" };
328
+ const [, kind, rest] = match;
329
+ const decoded = decodeURIComponent(rest);
330
+ if (kind === "resource") return {
331
+ kind: "resource",
332
+ id: decoded
333
+ };
334
+ return {
335
+ kind: "state",
336
+ key: decoded
337
+ };
338
+ }
339
+ //#endregion
340
+ //#region src/mcp/fetch.ts
341
+ /**
342
+ * Parse exactly one `Authorization: Bearer <token>` credential, returning the
343
+ * token or `undefined` for a missing, malformed, empty, or multi-credential
344
+ * header. The token itself is never logged. `\S+` rejects the whitespace that
345
+ * a second credential (fetch merges duplicate headers as `a, b`) or an empty
346
+ * value would introduce.
347
+ */
348
+ function parseBearerToken(header) {
349
+ if (!header) return void 0;
350
+ const match = /^Bearer (\S+)$/i.exec(header.trim());
351
+ return match ? match[1] : void 0;
352
+ }
353
+ /**
354
+ * Resolve the identity gate for one request. `false` is the origin-only
355
+ * opt-out; a callback delegates identity; a string requires a constant-time
356
+ * bearer match. Never reveals whether a supplied token was close to correct.
357
+ */
358
+ async function isAuthorized(req, authorization) {
359
+ if (authorization === false) return true;
360
+ if (typeof authorization === "function") return await authorization(req) === true;
361
+ const token = parseBearerToken(req.headers.get("authorization"));
362
+ if (token === void 0) return false;
363
+ return timingSafeEqual(token, authorization);
364
+ }
365
+ /**
366
+ * Build a framework-agnostic MCP endpoint over a devframe context: a
367
+ * web-standard `Request → Response` handler any host can mount: h3 (see
368
+ * `mountMcpHttp`), a Next.js App Router route, or any other fetch-shaped
369
+ * server.
370
+ *
371
+ * The endpoint is **stateless**: it serves the 2026-07-28 revision per request
372
+ * through the SDK's {@link createMcpHandler}, which builds a fresh MCP server
373
+ * (from the shared, live `ctx` via `buildMcpServerFromContext`) for each
374
+ * request: no `Mcp-Session-Id` registry, no session-local routing, no
375
+ * GET/DELETE teardown protocol. 2025-era clients are still served through the
376
+ * SDK's default stateless legacy path. `list_changed` events reach modern
377
+ * `subscriptions/listen` streams through the handler's `notify` bus.
378
+ *
379
+ * The origin gate guards every request: loopback-default DNS-rebinding
380
+ * protection that (unlike the WS upgrade's `isAllowedOrigin`) also rejects
381
+ * `Origin`-less requests, so a browser can't reach the route across origins (a
382
+ * disallowed origin gets `403`). The `Origin` header is only browser hardening:
383
+ * a non-browser client forges it. So on the zero-config default (no widened
384
+ * `allowedOrigins`, no identity check) a second locality gate requires the
385
+ * connected peer to be loopback, proven from the host-supplied
386
+ * {@link McpConnectionInfo.remoteAddress} (which a client cannot forge), so the
387
+ * "trusts same-machine callers" default holds against a remote raw client.
388
+ * When same-machine isn't your trust boundary, add an identity gate
389
+ * ({@link CreateMcpFetchHandlerOptions.authorization}), checked after the
390
+ * origin gate: a bearer/callback check that proves *who* is calling (a
391
+ * missing/invalid credential gets `401` with a `WWW-Authenticate: Bearer`
392
+ * challenge), which also lifts the loopback-peer restriction for authenticated
393
+ * callers.
394
+ */
395
+ function createMcpFetchHandler(ctx, options) {
396
+ const allowedOrigins = options.allowedOrigins;
397
+ const authorization = options.authorization ?? false;
398
+ const handler = createMcpHandler(() => buildMcpServerFromContext(ctx, {
399
+ serverName: options.serverName,
400
+ serverVersion: options.serverVersion,
401
+ exposeSharedState: options.exposeSharedState
402
+ }));
403
+ const unbridge = bridgeListChanged(ctx, {
404
+ tools: () => {
405
+ handler.notify.toolsChanged();
406
+ },
407
+ resources: () => {
408
+ handler.notify.resourcesChanged();
409
+ }
410
+ });
411
+ const originOnlyDefault = allowedOrigins === void 0 && authorization === false;
412
+ async function handle(req, connection) {
413
+ const origin = req.headers.get("origin") ?? void 0;
414
+ if (allowedOrigins !== false && (origin === void 0 || !isAllowedOrigin(origin, allowedOrigins ?? []))) return new Response("Forbidden", { status: 403 });
415
+ if (originOnlyDefault && connection?.remoteAddress !== void 0 && !isLoopbackAddress(connection.remoteAddress)) return new Response("Forbidden", { status: 403 });
416
+ if (!await isAuthorized(req, authorization)) return new Response("Unauthorized", {
417
+ status: 401,
418
+ headers: { "WWW-Authenticate": "Bearer" }
419
+ });
420
+ return handler.fetch(req);
421
+ }
422
+ return {
423
+ fetch: handle,
424
+ dispose: async () => {
425
+ unbridge();
426
+ await handler.close();
427
+ }
428
+ };
429
+ }
430
+ //#endregion
431
+ //#region src/mcp/http.ts
432
+ /**
433
+ * Mount a stateless MCP endpoint on an h3 app at `path`: the h3 binding over
434
+ * {@link createMcpFetchHandler}, which owns the per-request serving, the
435
+ * origin gate, and the transport plumbing.
436
+ *
437
+ * The handler is web-standard: it takes the h3 event's web `Request` and
438
+ * returns a web `Response` (an SSE `ReadableStream` body for a
439
+ * `subscriptions/listen` stream). We copy that response onto `event.res` and
440
+ * return its body rather than returning the `Response` object directly, so an
441
+ * MCP error response (e.g. a 4xx) isn't swallowed by h3's "Response-with-404
442
+ * falls through to the next handler" rule (which would otherwise hand the
443
+ * request to the SPA static catch-all).
444
+ */
445
+ function mountMcpHttp(app, ctx, path, options) {
446
+ const handler = createMcpFetchHandler(ctx, options);
447
+ app.use(path, defineHandler(async (event) => respond(event, await handler.fetch(event.req, { remoteAddress: getRequestIP(event) }))));
448
+ return { dispose: handler.dispose };
449
+ }
450
+ /**
451
+ * Copy a web `Response` from the MCP transport onto the h3 event's response
452
+ * and return its body. Returning the body (a `ReadableStream` or `null`)
453
+ * rather than the `Response` object avoids h3's 404-fall-through behavior.
454
+ */
455
+ function respond(event, response) {
456
+ event.res.status = response.status;
457
+ event.res.statusText = response.statusText;
458
+ response.headers.forEach((value, key) => {
459
+ event.res.headers.set(key, value);
460
+ });
461
+ return response.body ?? "";
462
+ }
463
+ //#endregion
464
+ export { createMcpFetchHandler, createMcpServer, mountMcpHttp };
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@devframes/agentic",
3
+ "type": "module",
4
+ "version": "0.9.19",
5
+ "description": "Agent-surface implementation for devframe: installing it next to devframe enables the MCP adapter (devframe/adapters/mcp) and the devframe connect gateway.",
6
+ "author": "Anthony Fu <anthonyfu117@hotmail.com>",
7
+ "license": "MIT",
8
+ "homepage": "https://github.com/devframes/devframe#readme",
9
+ "repository": {
10
+ "directory": "packages/agentic",
11
+ "type": "git",
12
+ "url": "git+https://github.com/devframes/devframe.git"
13
+ },
14
+ "bugs": "https://github.com/devframes/devframe/issues",
15
+ "keywords": [
16
+ "devtools",
17
+ "devframe",
18
+ "mcp",
19
+ "agent"
20
+ ],
21
+ "sideEffects": false,
22
+ "exports": {
23
+ ".": "./dist/index.mjs",
24
+ "./mcp": "./dist/mcp/index.mjs",
25
+ "./connect": "./dist/connect/index.mjs",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "types": "./dist/index.d.mts",
29
+ "files": [
30
+ "dist"
31
+ ],
32
+ "peerDependencies": {
33
+ "devframe": "0.9.20"
34
+ },
35
+ "dependencies": {
36
+ "@modelcontextprotocol/client": "^2.0.0",
37
+ "@modelcontextprotocol/server": "^2.0.0",
38
+ "h3": "^2.0.1-rc.31",
39
+ "pathe": "^2.0.3"
40
+ },
41
+ "devDependencies": {
42
+ "@standard-schema/spec": "^1.1.0",
43
+ "@types/node": "^26.5.1",
44
+ "devframe": "0.9.20",
45
+ "tsdown": "^0.23.0",
46
+ "valibot": "^1.5.0"
47
+ },
48
+ "scripts": {
49
+ "build": "tsdown",
50
+ "watch": "tsdown --watch",
51
+ "typecheck": "tsc --noEmit"
52
+ }
53
+ }