@devframes/agentic 1.2.6 → 1.3.1

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.
@@ -1,5 +1,5 @@
1
1
  import { Server } from "@modelcontextprotocol/server";
2
- import { diagnostics, listLiveDevframeInstances, probeDevframeOrigin } from "devframe/internal";
2
+ import { LIST_CLIENTS_TOOL, diagnostics, listLiveDevframeInstances, probeDevframeOrigin } from "devframe/internal";
3
3
  import { toAgentToolName } from "devframe/utils/agent-tool-name";
4
4
  import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
@@ -25,13 +25,15 @@ function buildInstanceRequestHeaders(url, token) {
25
25
  if (token) headers.authorization = `Bearer ${token}`;
26
26
  return headers;
27
27
  }
28
+ /** Wire name of the built-in tab-listing tool an instance exposes once a browser tab connects. */
29
+ const LIST_CLIENTS_NAME = toAgentToolName(LIST_CLIENTS_TOOL);
28
30
  const INDEX_TOOL = toAgentToolName("devframe:connect:list-instances");
29
31
  const CALL_TOOL = toAgentToolName("devframe:connect:call-tool");
30
32
  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
33
  const GATEWAY_TOOLS = [{
32
34
  name: INDEX_TOOL,
33
35
  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.",
36
+ description: "Discover every running devframe dev server on this machine and list each one's MCP tools, plus the browser tabs connected to it (`mcp.clients`). 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
37
  inputSchema: {
36
38
  type: "object",
37
39
  properties: {}
@@ -57,7 +59,7 @@ const GATEWAY_TOOLS = [{
57
59
  },
58
60
  args: {
59
61
  type: "object",
60
- description: "Arguments object for the tool. Omit for zero-argument tools."
62
+ description: "Arguments object for the tool. Omit for zero-argument tools. Tools forwarded from a browser tab accept `client_id` (from `mcp.clients` in list-instances) to target one tab; omitted, the most recently focused tab runs it."
61
63
  }
62
64
  },
63
65
  required: ["port", "tool"],
@@ -125,7 +127,7 @@ async function index(options) {
125
127
  try {
126
128
  entry.mcp = {
127
129
  url,
128
- tools: await listInstanceTools(url, resolveAuthToken(options.authToken, record))
130
+ ...await indexInstanceMcp(url, resolveAuthToken(options.authToken, record))
129
131
  };
130
132
  } catch (error) {
131
133
  entry.mcp = {
@@ -163,8 +165,18 @@ async function probePort(port, base = "/", timeoutMs) {
163
165
  startedAt: 0
164
166
  };
165
167
  }
166
- async function listInstanceTools(url, token) {
167
- return withInstanceClient(url, token, async (client) => (await client.listTools()).tools);
168
+ async function indexInstanceMcp(url, token) {
169
+ return withInstanceClient(url, token, async (client) => {
170
+ const tools = (await client.listTools()).tools;
171
+ if (!tools.some((tool) => tool.name === LIST_CLIENTS_NAME)) return { tools };
172
+ return {
173
+ tools,
174
+ clients: (await client.callTool({
175
+ name: LIST_CLIENTS_NAME,
176
+ arguments: {}
177
+ })).structuredContent?.clients ?? []
178
+ };
179
+ });
168
180
  }
169
181
  async function call(options, args) {
170
182
  if (typeof args.port !== "number" || typeof args.tool !== "string") throw diagnostics.DF0049();
@@ -1,6 +1,6 @@
1
1
  import { homedir } from "node:os";
2
2
  import process from "node:process";
3
- import { Server, createMcpHandler } from "@modelcontextprotocol/server";
3
+ import { ProtocolError, ProtocolErrorCode, Server, createMcpHandler } from "@modelcontextprotocol/server";
4
4
  import { DEVFRAME_EVENTS } from "devframe/constants";
5
5
  import { argsToJsonSchema, diagnostics, formatMcpError, getMcpContent, returnToJsonSchema, stringifyForMcp } from "devframe/internal";
6
6
  import { createHostContext } from "devframe/node";
@@ -29,19 +29,31 @@ function buildMcpServerFromContext(ctx, options) {
29
29
  }, {
30
30
  capabilities: {
31
31
  tools: { listChanged: true },
32
- resources: { listChanged: true }
32
+ resources: { listChanged: true },
33
+ ...options.prompts?.size ? { prompts: { listChanged: false } } : {}
33
34
  },
34
35
  ...options.instructions ? { instructions: options.instructions } : {}
35
36
  });
36
37
  registerToolHandlers(server, ctx, options.exposeSharedState);
37
- registerResourceHandlers(server, ctx, options.exposeSharedState);
38
+ const templates = options.resourceTemplates ?? [];
39
+ registerResourceHandlers(server, ctx, options.exposeSharedState, templates);
40
+ if (templates.length) server.setRequestHandler("resources/templates/list", async () => ({ resourceTemplates: templates.map(({ template: { uriTemplate, name, title, description, mimeType } }) => ({
41
+ uriTemplate,
42
+ name,
43
+ title,
44
+ description,
45
+ mimeType
46
+ })) }));
47
+ if (options.prompts?.size) registerPromptHandlers(server, options.prompts);
38
48
  return server;
39
49
  }
40
50
  /**
41
51
  * Publish devframe's `list_changed` events through a set of typed sinks:
42
52
  * `tools()` for tool-list changes and `resources()` for resource-list
43
- * changes (shared-state keys are surfaced as resources). Returns an
44
- * unsubscribe function.
53
+ * changes (shared-state keys are surfaced as resources). An optional host
54
+ * subscription (`onResourcesChanged`) also publishes through `resources()`
55
+ * each time it calls `notify()`. Returns an unsubscribe function, which also
56
+ * removes the host subscription; a `notify()` after that does nothing.
45
57
  *
46
58
  * The HTTP path passes the handler's `notify` bus sugar; the stdio path
47
59
  * passes the pinned server's `send*ListChanged` methods, which `serveStdio`
@@ -49,7 +61,7 @@ function buildMcpServerFromContext(ctx, options) {
49
61
  *
50
62
  * @internal
51
63
  */
52
- function bridgeListChanged(ctx, sinks) {
64
+ function bridgeListChanged(ctx, sinks, onResourcesChanged) {
53
65
  const offManifest = ctx.agent.events.on(DEVFRAME_EVENTS.bus.agentManifestChanged, () => {
54
66
  sinks.tools();
55
67
  sinks.resources();
@@ -57,9 +69,15 @@ function bridgeListChanged(ctx, sinks) {
57
69
  const offKeyAdded = ctx.rpc.sharedState.onKeyAdded(() => {
58
70
  sinks.resources();
59
71
  });
72
+ let active = true;
73
+ const offHost = onResourcesChanged?.(() => {
74
+ if (active) sinks.resources();
75
+ }, ctx);
60
76
  return () => {
77
+ active = false;
61
78
  offManifest();
62
79
  offKeyAdded();
80
+ offHost?.();
63
81
  };
64
82
  }
65
83
  /**
@@ -72,6 +90,8 @@ async function createMcpServer(definition, options = {}) {
72
90
  transport,
73
91
  reason: "Only stdio transport is supported in this release."
74
92
  });
93
+ const resourceTemplates = compileResourceTemplates(options.resourceTemplates);
94
+ const prompts = compilePrompts(options.prompts);
75
95
  const ctx = await createHostContext({
76
96
  cwd: process.cwd(),
77
97
  mode: "dev",
@@ -93,7 +113,9 @@ async function createMcpServer(definition, options = {}) {
93
113
  serverName: options.serverName ?? `${definition.id} (devframe)`,
94
114
  serverVersion: options.serverVersion ?? definition.version ?? "0.0.0",
95
115
  exposeSharedState: options.exposeSharedState ?? true,
96
- instructions: options.instructions
116
+ instructions: options.instructions,
117
+ resourceTemplates,
118
+ prompts
97
119
  };
98
120
  let handle;
99
121
  try {
@@ -107,7 +129,7 @@ async function createMcpServer(definition, options = {}) {
107
129
  resources: () => {
108
130
  server.sendResourceListChanged().catch(() => {});
109
131
  }
110
- });
132
+ }, options.onResourcesChanged);
111
133
  const priorOnClose = server.onclose;
112
134
  server.onclose = () => {
113
135
  unbridge();
@@ -238,7 +260,7 @@ function registerToolHandlers(server, ctx, exposeSharedState) {
238
260
  }
239
261
  });
240
262
  }
241
- function registerResourceHandlers(server, ctx, exposeSharedState) {
263
+ function registerResourceHandlers(server, ctx, exposeSharedState, templates) {
242
264
  const stateFilter = sharedStateFilter(exposeSharedState);
243
265
  server.setRequestHandler("resources/list", async () => {
244
266
  const resources = ctx.agent.list().resources.map((resource) => ({
@@ -256,6 +278,10 @@ function registerResourceHandlers(server, ctx, exposeSharedState) {
256
278
  mimeType: "application/json"
257
279
  });
258
280
  }
281
+ for (const { template } of templates) for (const entry of await template.list?.(ctx) ?? []) resources.push({
282
+ ...entry,
283
+ mimeType: entry.mimeType ?? template.mimeType
284
+ });
259
285
  return { resources };
260
286
  });
261
287
  server.setRequestHandler("resources/read", async (request) => {
@@ -278,10 +304,143 @@ function registerResourceHandlers(server, ctx, exposeSharedState) {
278
304
  text: stringifyForMcp(state.value())
279
305
  }] };
280
306
  }
307
+ for (const { template, match } of templates) {
308
+ const params = match(uri);
309
+ if (!params) continue;
310
+ const content = await template.read(params, uri, ctx);
311
+ return { contents: [{
312
+ uri,
313
+ mimeType: content.mimeType ?? template.mimeType ?? "application/json",
314
+ text: content.text ?? stringifyForMcp(content.json)
315
+ }] };
316
+ }
281
317
  throw new Error(`[devframe/mcp] unknown resource URI "${uri}"`);
282
318
  });
283
319
  }
284
320
  /**
321
+ * Index prompts by name, once per endpoint. A repeated name throws
322
+ * `DF0085`, since `prompts/get` finds a prompt by its name.
323
+ *
324
+ * @internal
325
+ */
326
+ function compilePrompts(prompts = []) {
327
+ const byName = /* @__PURE__ */ new Map();
328
+ for (const prompt of prompts) {
329
+ if (byName.has(prompt.name)) throw diagnostics.DF0085({ name: prompt.name });
330
+ byName.set(prompt.name, prompt);
331
+ }
332
+ return byName;
333
+ }
334
+ /**
335
+ * Serve `prompts/list` and `prompts/get`. An unknown name, or a required
336
+ * argument that is missing or empty, is an invalid-params error. An error
337
+ * thrown by `get` goes to the MCP client.
338
+ */
339
+ function registerPromptHandlers(server, prompts) {
340
+ server.setRequestHandler("prompts/list", async () => ({ prompts: [...prompts.values()].map(({ name, title, description, arguments: args }) => ({
341
+ name,
342
+ title,
343
+ description,
344
+ ...args?.length ? { arguments: args.map((argument) => ({
345
+ name: argument.name,
346
+ description: argument.description,
347
+ required: argument.required
348
+ })) } : {}
349
+ })) }));
350
+ server.setRequestHandler("prompts/get", async (request) => {
351
+ const { name, arguments: given = {} } = request.params;
352
+ const prompt = prompts.get(name);
353
+ if (!prompt) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `[devframe/mcp] unknown prompt "${name}"`);
354
+ const missing = (prompt.arguments ?? []).filter((argument) => argument.required && !(Object.hasOwn(given, argument.name) && given[argument.name])).map((argument) => argument.name);
355
+ if (missing.length) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `[devframe/mcp] prompt "${name}" needs the argument(s): ${missing.join(", ")}`);
356
+ const { description, messages } = await prompt.get(given);
357
+ return {
358
+ description,
359
+ messages
360
+ };
361
+ });
362
+ }
363
+ /** One `{name}` expression of a URI template. */
364
+ const TEMPLATE_EXPRESSION_RE = /\{([^{}]*)\}/;
365
+ /** RFC 6570 `varname`: letters, digits and `_`, with single dots between them. */
366
+ const TEMPLATE_VARNAME_RE = /^\w+(?:\.\w+)*$/;
367
+ /**
368
+ * Compile resource templates to URI matchers, once per endpoint. Only RFC
369
+ * 6570 level 1 is supported: plain `{name}` expressions. Each expression
370
+ * matches a non-empty value with no `/`, and its value is percent-decoded,
371
+ * so an encoded `%2F` reads back as `/`. A URI with malformed
372
+ * percent-encoding matches no template. Any other template syntax, and two
373
+ * expressions with nothing between them, throw `DF0084`.
374
+ *
375
+ * @internal
376
+ */
377
+ function compileResourceTemplates(templates = []) {
378
+ return templates.map((template) => {
379
+ const { name, uriTemplate } = template;
380
+ const invalid = (reason) => diagnostics.DF0084({
381
+ name,
382
+ uriTemplate,
383
+ reason
384
+ });
385
+ const parts = uriTemplate.split(TEMPLATE_EXPRESSION_RE);
386
+ const literals = parts.filter((_, index) => index % 2 === 0);
387
+ const names = parts.filter((_, index) => index % 2 === 1);
388
+ if (literals.some((literal) => /[{}]/.test(literal))) throw invalid("a `{` or `}` is outside an expression.");
389
+ names.forEach((varname, index) => {
390
+ if (!TEMPLATE_VARNAME_RE.test(varname)) throw invalid(`the expression "{${varname}}" is not a plain \`{name}\`.`);
391
+ if (names.indexOf(varname) !== index) throw invalid(`the name "${varname}" is used more than once.`);
392
+ if (index > 0 && literals[index] === "") throw invalid(`"{${names[index - 1]}}" and "{${varname}}" have nothing between them, so a URI cannot show where one ends.`);
393
+ });
394
+ return {
395
+ template,
396
+ match: (uri) => matchTemplate(uri, literals, names)
397
+ };
398
+ });
399
+ }
400
+ /**
401
+ * Match `uri` in one pass from left to right, with no regular expression, so
402
+ * the time grows with the URI length only and a crafted URI cannot block the
403
+ * process. Each value runs to the first place the next literal appears
404
+ * outside a `%XX` escape (the last value runs to the tail). A value must not
405
+ * be empty or contain `/`.
406
+ * The params object has no prototype, so a variable such as `__proto__`
407
+ * keeps its value.
408
+ */
409
+ function matchTemplate(uri, literals, names) {
410
+ const head = literals[0];
411
+ const tail = literals.at(-1);
412
+ const params = Object.create(null);
413
+ if (names.length === 0) return uri === head ? params : void 0;
414
+ if (!uri.startsWith(head) || !uri.endsWith(tail)) return void 0;
415
+ const end = uri.length - tail.length;
416
+ let position = head.length;
417
+ for (let index = 0; index < names.length; index++) {
418
+ const isLast = index === names.length - 1;
419
+ const next = literals[index + 1];
420
+ const stop = isLast ? end : findLiteral(uri, next, position);
421
+ if (stop <= position) return void 0;
422
+ const raw = uri.slice(position, stop);
423
+ if (raw.includes("/")) return void 0;
424
+ try {
425
+ params[names[index]] = decodeURIComponent(raw);
426
+ } catch {
427
+ return;
428
+ }
429
+ position = stop + (isLast ? 0 : next.length);
430
+ }
431
+ return params;
432
+ }
433
+ /**
434
+ * The first place `literal` appears after the value that starts at `start`
435
+ * (which must not be empty), or -1. A place inside a `%XX` escape of the
436
+ * value is skipped, so `{a}D{b}` reads `a%2DbDc` as `a-b` and `c`.
437
+ */
438
+ function findLiteral(uri, literal, start) {
439
+ let at = uri.indexOf(literal, start + 1);
440
+ while (at !== -1 && (uri[at - 1] === "%" || at - 2 >= start && uri[at - 2] === "%")) at = uri.indexOf(literal, at + 1);
441
+ return at;
442
+ }
443
+ /**
285
444
  * The content blocks a tool result carries through `withMcpContent`, checked
286
445
  * and copied field by field, or `undefined` when it carries none. Invalid
287
446
  * blocks throw DF0081, which the `tools/call` handler turns into an
@@ -429,11 +588,15 @@ async function isAuthorized(req, authorization) {
429
588
  function createMcpFetchHandler(ctx, options) {
430
589
  const allowedOrigins = options.allowedOrigins;
431
590
  const authorization = options.authorization ?? false;
591
+ const resourceTemplates = compileResourceTemplates(options.resourceTemplates);
592
+ const prompts = compilePrompts(options.prompts);
432
593
  const handler = createMcpHandler(() => buildMcpServerFromContext(ctx, {
433
594
  serverName: options.serverName,
434
595
  serverVersion: options.serverVersion,
435
596
  exposeSharedState: options.exposeSharedState,
436
- instructions: options.instructions
597
+ instructions: options.instructions,
598
+ resourceTemplates,
599
+ prompts
437
600
  }));
438
601
  const unbridge = bridgeListChanged(ctx, {
439
602
  tools: () => {
@@ -442,7 +605,7 @@ function createMcpFetchHandler(ctx, options) {
442
605
  resources: () => {
443
606
  handler.notify.resourcesChanged();
444
607
  }
445
- });
608
+ }, options.onResourcesChanged);
446
609
  const originOnlyDefault = allowedOrigins === void 0 && authorization === false;
447
610
  async function handle(req, connection) {
448
611
  const origin = req.headers.get("origin") ?? void 0;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@devframes/agentic",
3
3
  "type": "module",
4
- "version": "1.2.6",
4
+ "version": "1.3.1",
5
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
6
  "author": "Anthony Fu <anthonyfu117@hotmail.com>",
7
7
  "license": "MIT",
@@ -30,19 +30,19 @@
30
30
  "dist"
31
31
  ],
32
32
  "peerDependencies": {
33
- "devframe": "1.2.6"
33
+ "devframe": "1.3.1"
34
34
  },
35
35
  "dependencies": {
36
36
  "@modelcontextprotocol/client": "^2.3.1",
37
37
  "@modelcontextprotocol/server": "^2.3.1",
38
- "h3": "^2.0.1",
38
+ "h3": "^2.0.7",
39
39
  "pathe": "^2.0.3"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@standard-schema/spec": "^1.1.0",
43
- "@types/node": "^26.6.4",
44
- "devframe": "1.2.6",
45
- "tsdown": "^0.23.0",
43
+ "@types/node": "^26.6.5",
44
+ "devframe": "1.3.1",
45
+ "tsdown": "0.23.0",
46
46
  "valibot": "^1.5.0"
47
47
  },
48
48
  "scripts": {