@sigrun/ext-mcp 0.2.1 → 0.2.3

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/CHANGELOG.md CHANGED
@@ -6,6 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  ## [Unreleased]
9
+ ## [0.2.3] - 2026-11-05
10
+
11
+ ### Added
12
+
13
+ - Tool annotations kept from `tools/list` and published as the
14
+ `mcp.tool-annotations` hook (readOnlyHint/destructiveHint per tool,
15
+ boolean-only, per-user catalog first then shared servers the calling
16
+ user may reach; unknown tools answer `{unknown: true}`). Downstream
17
+ safety consumers (@sigrun/warden) classify from real metadata instead
18
+ of the MCP default.
19
+
20
+ ### Changed
21
+ - Forward-compatible with pi-durable 1.4.0 (explicit `checkpointWhen`;
22
+ widened peer range). No behaviour change.
23
+ - Block-validator tests now compile against core's real generated
24
+ `@sigrun/sigrun/blocks` declarations instead of a local ambient shim.
25
+ A content-hash build stamp rejects missing or stale sibling output with
26
+ an explicit core rebuild instruction; the sibling CI path builds core
27
+ before the validator-form test.
28
+
9
29
  ### Security
10
30
  - **stdio MCP servers receive an allowlisted environment instead of sigrun's
11
31
  process environment.** `SIGRUN_API_TOKEN` and provider keys were inherited
@@ -0,0 +1,36 @@
1
+ /** The `mcp.tool-annotations` published hook: a downstream safety extension
2
+ * asks "what did this tool's server declare about it?" and this extension
3
+ * answers from its live connection maps — the caller's per-user catalog
4
+ * first, then shared servers the caller may reach, else unknown. */
5
+ import { Type, type Static } from "@earendil-works/pi-ai";
6
+ import type { PublishedHook } from "@sigrun/sigrun/extension";
7
+ import type { McpServerConfig } from "./config.js";
8
+ import { type SigrunHost, type ToolAnnotationEntry, type UserCatalog } from "./user-servers.js";
9
+ declare const Input: Type.TObject<{
10
+ tool: Type.TString;
11
+ }>;
12
+ export declare const TOOL_ANNOTATIONS_INPUT: Type.TObject<{
13
+ tool: Type.TString;
14
+ }>;
15
+ export declare const TOOL_ANNOTATIONS_OUTPUT: Type.TUnion<[Type.TObject<{
16
+ server: Type.TString;
17
+ mcpName: Type.TString;
18
+ annotations: Type.TObject<{
19
+ readOnlyHint: Type.TOptional<Type.TBoolean>;
20
+ destructiveHint: Type.TOptional<Type.TBoolean>;
21
+ idempotentHint: Type.TOptional<Type.TBoolean>;
22
+ openWorldHint: Type.TOptional<Type.TBoolean>;
23
+ }>;
24
+ }>, Type.TObject<{
25
+ unknown: Type.TLiteral<true>;
26
+ }>]>;
27
+ export type ToolAnnotationsPayload = Static<typeof Input>;
28
+ export type ToolAnnotationsResult = ToolAnnotationEntry | {
29
+ readonly unknown: true;
30
+ };
31
+ export declare const TOOL_ANNOTATIONS_HOOK = "mcp.tool-annotations";
32
+ export declare function createToolAnnotationsHook(host: SigrunHost, catalog: UserCatalog, shared: ReadonlyMap<string, {
33
+ readonly server: McpServerConfig;
34
+ readonly annotations: ReadonlyMap<string, ToolAnnotationEntry>;
35
+ }>): PublishedHook;
36
+ export {};
@@ -0,0 +1,67 @@
1
+ /** The `mcp.tool-annotations` published hook: a downstream safety extension
2
+ * asks "what did this tool's server declare about it?" and this extension
3
+ * answers from its live connection maps — the caller's per-user catalog
4
+ * first, then shared servers the caller may reach, else unknown. */
5
+ import { Type } from "@earendil-works/pi-ai";
6
+ import { sharedServerAllowed, } from "./user-servers.js";
7
+ const Input = Type.Object({
8
+ tool: Type.String({ minLength: 1, maxLength: 512 }),
9
+ });
10
+ const Hints = Type.Object({
11
+ readOnlyHint: Type.Optional(Type.Boolean()),
12
+ destructiveHint: Type.Optional(Type.Boolean()),
13
+ idempotentHint: Type.Optional(Type.Boolean()),
14
+ openWorldHint: Type.Optional(Type.Boolean()),
15
+ });
16
+ const Output = Type.Union([
17
+ Type.Object({
18
+ server: Type.String(),
19
+ mcpName: Type.String(),
20
+ annotations: Hints,
21
+ }),
22
+ Type.Object({ unknown: Type.Literal(true) }),
23
+ ]);
24
+ // Exported for the compile round-trip test (the hook exposes the same
25
+ // schemas via `input`/`output`; test code imports these constants to
26
+ // avoid re-deriving them from the wiring).
27
+ export const TOOL_ANNOTATIONS_INPUT = Input;
28
+ export const TOOL_ANNOTATIONS_OUTPUT = Output;
29
+ export const TOOL_ANNOTATIONS_HOOK = "mcp.tool-annotations";
30
+ export function createToolAnnotationsHook(host, catalog, shared) {
31
+ const lookup = async (tool, call) => {
32
+ if (call.user !== undefined) {
33
+ const own = catalog.annotationsFor(call.user).get(tool);
34
+ if (own !== undefined)
35
+ return own;
36
+ // Shared servers lend operator authority, so reachability is gated
37
+ // by the acting user of the conversation; an unidentified caller
38
+ // fails closed, matching sharedServerAllowed's own posture.
39
+ if (call.conversationId !== undefined) {
40
+ for (const [name, entry] of shared) {
41
+ const found = entry.annotations.get(tool);
42
+ if (found === undefined)
43
+ continue;
44
+ try {
45
+ if (await sharedServerAllowed(host, call.conversationId, name, entry.server)) {
46
+ return found;
47
+ }
48
+ }
49
+ catch {
50
+ // A failed access check is not a reachability grant.
51
+ }
52
+ }
53
+ }
54
+ }
55
+ return { unknown: true };
56
+ };
57
+ return {
58
+ name: TOOL_ANNOTATIONS_HOOK,
59
+ description: "Resolve a sigrun MCP tool name to its server-declared annotations.",
60
+ input: Input,
61
+ output: Output,
62
+ run: async (payload, call) => {
63
+ const { tool } = payload;
64
+ return await lookup(tool, call);
65
+ },
66
+ };
67
+ }
package/dist/config.js CHANGED
@@ -144,6 +144,13 @@ export function parseMcpConfig(text, source) {
144
144
  if (name.startsWith("u_")) {
145
145
  throw new Error(`mcp config ${source}: server "${name}" uses the reserved "u_" prefix`);
146
146
  }
147
+ // The tool namespace is `<server>/<tool>` and settings keys use dots
148
+ // for paths, so a server named `acme.tools` or `acme/tools` would
149
+ // produce ambiguous or conflicting keys. Reject at the source, before
150
+ // any settings section can be generated from the bad name.
151
+ if (name.includes(".") || name.includes("/")) {
152
+ throw new Error(`mcp config ${source}: server "${name}" contains forbidden characters ("." or "/") — dots conflict with settings key paths and slashes conflict with the <server>/<tool> namespace`);
153
+ }
147
154
  if (!isRecord(entry)) {
148
155
  throw new Error(`mcp config ${source}: server "${name}" must be an object`);
149
156
  }
@@ -2,6 +2,7 @@
2
2
  * per-user entries connect and publish through the structural sigrun host. */
3
3
  import { type Extension } from "@earendil-works/pi-durable";
4
4
  import { type CatalogSettingsSection, type OwnerEvent, type SigrunHost, type UserService } from "./user-servers.js";
5
+ import type { PublishedHook } from "@sigrun/sigrun/extension";
5
6
  export declare const MCP_EXTENSION_NAME = "sigrun.mcp";
6
7
  export interface ExtensionRouteContribution {
7
8
  readonly method: "POST";
@@ -22,6 +23,7 @@ export interface McpWiring {
22
23
  readonly surfaces: {
23
24
  readonly settings: readonly CatalogSettingsSection[];
24
25
  };
26
+ readonly publishedHooks: readonly PublishedHook[];
25
27
  conversationCreated(event: OwnerEvent): Promise<void>;
26
28
  runSettled(event: OwnerEvent): Promise<void>;
27
29
  onSettingsChanged(keys: readonly string[], user?: string): Promise<void>;
package/dist/extension.js CHANGED
@@ -7,6 +7,7 @@ import { createUserOauthFlows, exchangeCode, extractCode, } from "./oauth.js";
7
7
  import { storeUserMcpToken } from "./tokens.js";
8
8
  import { MCP_SERVERS_SECTION_KEY, partitionForSection, renderMcpServersSection, } from "./section.js";
9
9
  import { catalogEnabledFromSettings, createUserCatalog, sharedServerAllowed, discoverMcpTools, } from "./user-servers.js";
10
+ import { createToolAnnotationsHook } from "./annotations.js";
10
11
  export const MCP_EXTENSION_NAME = "sigrun.mcp";
11
12
  /** Standalone tokens need no registry install; the kind/version must match
12
13
  * core's session-meta document so its committed owner is readable here. */
@@ -17,6 +18,7 @@ const SessionOwnerDoc = defineDoc({
17
18
  history: "latest",
18
19
  fork: "current",
19
20
  initial: () => ({}),
21
+ checkpointWhen: () => false,
20
22
  });
21
23
  function denied(message) {
22
24
  return {
@@ -57,6 +59,7 @@ export function createMcpExtension(host) {
57
59
  const clients = new Map();
58
60
  const serverExposures = new Map();
59
61
  const shared = Object.entries(config.servers).filter(([, server]) => server.credentials === "shared" && serverEnabled(server));
62
+ const sharedAnnotations = new Map();
60
63
  const catalog = createUserCatalog(host, config.servers, config.userServers ?? "none");
61
64
  const oauthFlows = createUserOauthFlows();
62
65
  let closed = false;
@@ -115,12 +118,17 @@ export function createMcpExtension(host) {
115
118
  return;
116
119
  }
117
120
  tools.push(...discovered.tools.map((tool) => sharedTool(host, name, server, tool)));
121
+ sharedAnnotations.set(name, {
122
+ server,
123
+ annotations: discovered.annotations,
124
+ });
118
125
  if (discovered.exposure !== undefined) {
119
126
  serverExposures.set(name, discovered.exposure);
120
127
  }
121
128
  }
122
129
  catch (cause) {
123
130
  clients.delete(name);
131
+ sharedAnnotations.delete(name);
124
132
  client.close();
125
133
  console.warn(`mcp server "${name}" quarantined during discovery: ${errorMessage(cause)}`);
126
134
  }
@@ -240,6 +248,7 @@ export function createMcpExtension(host) {
240
248
  clients.clear();
241
249
  tools.length = 0;
242
250
  serverExposures.clear();
251
+ sharedAnnotations.clear();
243
252
  await Promise.all(pending);
244
253
  oauthFlows.clear();
245
254
  catalog.close();
@@ -318,6 +327,7 @@ export function createMcpExtension(host) {
318
327
  routes,
319
328
  userServices: catalog.userServices,
320
329
  surfaces: { settings: catalog.settings },
330
+ publishedHooks: [createToolAnnotationsHook(host, catalog, sharedAnnotations)],
321
331
  conversationCreated: activate,
322
332
  runSettled: activate,
323
333
  onSettingsChanged: async (keys, user) => {
package/dist/types.d.ts CHANGED
@@ -1,9 +1,22 @@
1
1
  /** Wire types for the MCP stdio protocol surface this extension uses. */
2
2
  import type { McpServerConfig } from "./config.js";
3
+ /** The MCP-declared hint surface, narrowed to the four boolean hints a
4
+ * downstream safety classifier reads. Non-boolean values are dropped at
5
+ * discovery time rather than trusted blindly. */
6
+ export interface ToolAnnotations {
7
+ readonly readOnlyHint?: boolean;
8
+ readonly destructiveHint?: boolean;
9
+ readonly idempotentHint?: boolean;
10
+ readonly openWorldHint?: boolean;
11
+ }
3
12
  /** One tool as `tools/list` reports it. */
4
13
  export interface McpToolDefinition {
5
14
  readonly name: string;
6
15
  readonly description?: string;
16
+ /** Server-advertised hints; only boolean values are retained. */
17
+ readonly annotations?: {
18
+ readonly [key: string]: unknown;
19
+ };
7
20
  /** JSON Schema for the tool's arguments object. */
8
21
  readonly inputSchema?: {
9
22
  readonly type?: unknown;
@@ -4,6 +4,7 @@
4
4
  import { type ConversationId, type ToolRegistration } from "@earendil-works/pi-durable";
5
5
  import { type McpClient, type McpServiceHandle } from "./client.js";
6
6
  import { type McpServerConfig, type UserServersMode } from "./config.js";
7
+ import type { ToolAnnotations } from "./types.js";
7
8
  export interface HandlerContext {
8
9
  readonly abortSignal: AbortSignal | undefined;
9
10
  }
@@ -61,9 +62,18 @@ export interface ServerExposure {
61
62
  readonly description: string;
62
63
  readonly count: number;
63
64
  }
65
+ /** One discovered tool's annotation record, keyed by the sigrun tool name. */
66
+ export interface ToolAnnotationEntry {
67
+ readonly server: string;
68
+ readonly mcpName: string;
69
+ readonly annotations: ToolAnnotations;
70
+ }
64
71
  export interface DiscoveredTools {
65
72
  readonly tools: ToolRegistration[];
66
73
  readonly exposure: ServerExposure | undefined;
74
+ /** Annotations keyed by the sigrun tool name (`<server>/<tool>`); only
75
+ * tools that advertised at least one boolean hint appear. */
76
+ readonly annotations: ReadonlyMap<string, ToolAnnotationEntry>;
67
77
  }
68
78
  /** The one discovery-to-registration mapping shared by system and user tools. */
69
79
  export declare function discoverMcpTools(name: string, server: McpServerConfig, client: McpClient): Promise<DiscoveredTools>;
@@ -86,6 +96,9 @@ export interface UserCatalog {
86
96
  activate(user: string, ctx?: HandlerContext): Promise<void>;
87
97
  retire(user: string, server: string): Promise<void>;
88
98
  exposures(user: string): ReadonlyMap<string, ServerExposure>;
99
+ /** Live annotation maps for this user's current connections; retirement
100
+ * removes entries because the underlying connections map is the source. */
101
+ annotationsFor(user: string): ReadonlyMap<string, ToolAnnotationEntry>;
89
102
  reconcileAll(mode?: UserServersMode): Promise<void>;
90
103
  close(): void;
91
104
  }
@@ -5,6 +5,23 @@ import { Type } from "@earendil-works/pi-ai";
5
5
  import { defineTool, } from "@earendil-works/pi-durable";
6
6
  import { connectMcpServer, } from "./client.js";
7
7
  import { isStdioServer, parseMcpConfig, resolveToolExposure, serverEnabled, } from "./config.js";
8
+ /** Copy only the four boolean hint values; anything else is untrusted noise. */
9
+ function keepBooleanAnnotations(annotations) {
10
+ if (annotations === undefined)
11
+ return undefined;
12
+ const kept = {};
13
+ for (const hint of [
14
+ "readOnlyHint",
15
+ "destructiveHint",
16
+ "idempotentHint",
17
+ "openWorldHint",
18
+ ]) {
19
+ const value = annotations[hint];
20
+ if (typeof value === "boolean")
21
+ kept[hint] = value;
22
+ }
23
+ return Object.keys(kept).length > 0 ? kept : undefined;
24
+ }
8
25
  function toContent(block) {
9
26
  return block.type === "text" && typeof block.text === "string"
10
27
  ? { type: "text", text: block.text }
@@ -14,11 +31,20 @@ function toContent(block) {
14
31
  export async function discoverMcpTools(name, server, client) {
15
32
  const discovered = await client.listTools();
16
33
  const tools = [];
34
+ const annotations = new Map();
17
35
  const mix = { codemode: false, deferred: false, count: 0 };
18
36
  for (const tool of discovered) {
19
37
  const exposure = resolveToolExposure(server, tool.name);
20
38
  if (exposure === "hidden")
21
39
  continue;
40
+ const kept = keepBooleanAnnotations(tool.annotations);
41
+ if (kept !== undefined) {
42
+ annotations.set(`${name}/${tool.name}`, {
43
+ server: name,
44
+ mcpName: tool.name,
45
+ annotations: kept,
46
+ });
47
+ }
22
48
  if (exposure === "codemode")
23
49
  mix.codemode = true;
24
50
  if (exposure === "deferred")
@@ -61,7 +87,7 @@ export async function discoverMcpTools(name, server, client) {
61
87
  }));
62
88
  }
63
89
  if (mix.count === 0 || (!mix.codemode && !mix.deferred)) {
64
- return { tools, exposure: undefined };
90
+ return { tools, exposure: undefined, annotations };
65
91
  }
66
92
  const origin = isStdioServer(server)
67
93
  ? `${server.command} ${server.args.join(" ")}`.trim()
@@ -72,6 +98,7 @@ export async function discoverMcpTools(name, server, client) {
72
98
  ...mix,
73
99
  description: `${mix.count} tool${mix.count === 1 ? "" : "s"} from ${origin}`,
74
100
  },
101
+ annotations,
75
102
  };
76
103
  }
77
104
  export function catalogEnabledFromSettings(name, server, settings) {
@@ -132,6 +159,7 @@ function trackConnection(name, client, discovered) {
132
159
  return {
133
160
  registrations,
134
161
  exposure: discovered.exposure,
162
+ annotations: discovered.annotations,
135
163
  retire: () => {
136
164
  retired = true;
137
165
  drain();
@@ -407,6 +435,7 @@ export function createUserCatalog(host, servers, initialMode = "none") {
407
435
  retire,
408
436
  reconcileAll,
409
437
  exposures: (user) => new Map([...(states.get(user)?.connections ?? [])].flatMap(([name, connection]) => connection.exposure === undefined ? [] : [[name, connection.exposure]])),
438
+ annotationsFor: (user) => new Map([...(states.get(user)?.connections ?? [])].flatMap(([, connection]) => [...connection.annotations])),
410
439
  close: () => {
411
440
  if (closed)
412
441
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigrun/ext-mcp",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "MCP extension for sigrun (pi-durable): registers MCP server tools as <server>/<toolname> tools",
5
5
  "type": "module",
6
6
  "engines": {
@@ -28,15 +28,15 @@
28
28
  "prepublishOnly": "npm run build"
29
29
  },
30
30
  "peerDependencies": {
31
- "@earendil-works/chord": "1.3.0",
32
- "@earendil-works/pi-ai": "1.3.0",
33
- "@earendil-works/pi-durable": "1.3.0",
31
+ "@earendil-works/chord": "1.3.0 || 1.4.0",
32
+ "@earendil-works/pi-ai": "1.3.0 || 1.4.0",
33
+ "@earendil-works/pi-durable": "1.3.0 || 1.4.0",
34
34
  "typebox": "1.3.27"
35
35
  },
36
36
  "devDependencies": {
37
- "@earendil-works/chord": "1.3.0",
38
- "@earendil-works/pi-ai": "1.3.0",
39
- "@earendil-works/pi-durable": "1.3.0",
37
+ "@earendil-works/chord": "1.4.0",
38
+ "@earendil-works/pi-ai": "1.4.0",
39
+ "@earendil-works/pi-durable": "1.4.0",
40
40
  "@sigrun/sigrun": "file:../sigrun",
41
41
  "@types/node": "^26.6.4",
42
42
  "tsx": "^4.23.15",