pi-roundtable-mcp 0.2.0 → 0.4.0

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
@@ -5,6 +5,28 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.4.0] - 2026-10-01
9
+
10
+ ### Added
11
+
12
+ - `ChannelGrantStore` is exported, with its `migration`, `attach(sql)`, `ensureBundle`, `save` and the rest, for host-side imports and tools. The plugin still owns the tables.
13
+ - `ConnectorMessages`: the ContextForge errors are messages now (`upstreamUrlUnreadable`, `contextForgeNoGatewayId`, `contextForgeNoServerId`, `contextForgeRefused`, `contextForgeNotJson`, `virtualServerRequestFailed`, `virtualServerMissing`, `virtualServerNoTools`), and so are the description of a new connector's virtual server (`serverDescription`) and the separator after a connector's name in the list (`labelSeparator`). The values they receive are masked as before.
14
+ - `RemoteMcpMessages`: the human part of a granted tool's error (`operationFailed`, `outcomeUnrecorded`, and `codeDetail`, which joins it to the fixed code), the label and list separators of the grants list (`labelSeparator`, `listSeparator`), and the wording of an audit entry's status (`auditStatus`).
15
+ - `RemoteMcpMessages.describeAgentNameOption` and `revokeNotInBundle`: the `name` option of `describe` and the refusal of `revoke` have their own wording. They default to the text of `agentNameOption` and `notInBundle`.
16
+
17
+ ### Changed
18
+
19
+ - `agentNameOption` now words only the `name` option of `authorize`, and `notInBundle` only what `describe` says when the channel is not in the bundle. The English defaults are unchanged, so a host that overrides `messages` keeps working; it sets the new keys where it wants a different wording.
20
+ - `runGrantedTool` takes the messages as its last argument. It is internal and not exported from the package.
21
+
22
+ ## [0.3.0] - 2026-10-01
23
+
24
+ ### Added
25
+
26
+ - `Connectors.resolve(serverName)` reads a virtual server by name, and `Connectors.admin` (`gateways()`, `servers()`) shows what ContextForge holds, for a host that manages some servers itself or shows their state.
27
+ - The `REMOTE_MCP` service of `remoteMcp`: the owner's bundles and grants, read only, and `describeGrant(client, grant)`, so a host can show them in its own status view.
28
+ - The types `GatewayState`, `ChannelBundle`, `ChannelGrant`, and `RemoteMcpService`.
29
+
8
30
  ## [0.2.0] - 2026-10-01
9
31
 
10
32
  ### Added
package/README.md CHANGED
@@ -90,6 +90,8 @@ It provides the `CONNECTORS` service (`Connectors`):
90
90
  | `servers()` | The virtual servers (name, URL, tool names) of the connectors that could be read |
91
91
  | `profileSources()` | One `{ name, description, serverName }` per connector, the shape a host builds a per-agent MCP profile from |
92
92
  | `token` | The bearer token that the virtual servers' URLs expect; one per process, valid for a year |
93
+ | `resolve(serverName)` | Reads one virtual server's URL and tools by name, for servers the host manages itself rather than as connectors |
94
+ | `admin` | `gateways()` and `servers()`: what ContextForge holds, upstream gateways with their state and every virtual server with its tool names, for a host's status view |
93
95
 
94
96
  The plugin does not decide which agent uses which connector.
95
97
  The host reads the service, builds its own profiles, and compares `version` with the one it built at.
@@ -177,6 +179,19 @@ The owner grants channels on Discord with `/<root> mcp`:
177
179
  | `describe` | Changes the channel name and purpose that the agent sees (Discord's own name and topic stay) |
178
180
  | `token` | Replaces a bundle's MCP URL; the old one stops working at once and the channel settings stay |
179
181
 
182
+ The plugin provides the `REMOTE_MCP` service (`RemoteMcpService`) for a host that shows the owner what outside agents may reach:
183
+
184
+ | Member | What it is |
185
+ | --- | --- |
186
+ | `grants` | `bundles()` and `grants(bundleId?)`, read only: every bundle, and the channel grants of one bundle or of all |
187
+ | `describeGrant(client, grant)` | One grant as lines of text for a Discord message: the name the agent sees, where the channel is, its purpose, and the allowed operations, in the plugin's wording |
188
+
189
+ #### `ChannelGrantStore`
190
+
191
+ The package also exports the `ChannelGrantStore` class, for host-side imports and tools; the plugin owns the tables.
192
+ `ChannelGrantStore.migration` creates them, `ChannelGrantStore.attach(sql)` opens the store over a migrated pool, and `ensureBundle`, `save` and the other methods read and write bundles and grants, so an offline script can move an existing set of grants into the database.
193
+ The tables belong to the plugin: change them through the plugin on Discord, or through this class, not by hand.
194
+
180
195
  #### The default conversation
181
196
 
182
197
  Without `answer` and `claim`, a relayed turn runs on the core: `context.turns.run` of kind `remote`, for an owner-tier speaker, in the channel `mcp:<session>`, through the agent server's runtime.
@@ -243,9 +258,11 @@ The host's agents then reach a connector through the connector's virtual server
243
258
 
244
259
  pi-roundtable's message catalog is closed to plugins, so these plugins keep their text in English and take a `messages` option of their own: a partial object laid over the defaults.
245
260
  Entries that take values are functions.
246
- The types `ConnectorMessages` and `RemoteMcpMessages` are exported and list every key.
261
+ The types `ConnectorMessages` and `RemoteMcpMessages` are exported and list every key, each with a comment that says where it is read.
262
+ Every sentence a Discord user or an outside agent reads is a key, including the errors of the ContextForge client, the description of a connector's virtual server (`serverDescription`), the human part of a granted tool's error (`operationFailed`, `outcomeUnrecorded`, joined to the fixed error code by `codeDetail`), and the separators (`labelSeparator`, `listSeparator`).
263
+ The machine error codes (`CHANNEL_NOT_AUTHORIZED`, `DISCORD_OPERATION_FAILED`, ...) stay fixed, since outside agents match on them.
264
+ `contextForgeRefused` and `contextForgeNotJson` receive ContextForge's answer with the request's token and any credential in the upstream URL already masked, so a wording of your own cannot leak them.
247
265
  Operation labels (`read`, `send`, ...) come from pi-roundtable and follow the host's language.
248
- The errors of the ContextForge client (a refused request) are English only.
249
266
 
250
267
  ## Database
251
268
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "MCP connectors through ContextForge and a remote MCP endpoint for pi-roundtable",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -171,7 +171,7 @@ function section(connector: Connector, text: ConnectorMessages): string {
171
171
  .map((t) => `\`${t}\``)
172
172
  .join(" ");
173
173
  return [
174
- `**${connector.name}** ${plain(displayUrl(connector.url, text.urlUnreadable))}`,
174
+ `**${connector.name}**${text.labelSeparator}${plain(displayUrl(connector.url, text.urlUnreadable))}`,
175
175
  plain(connector.description),
176
176
  connector.server
177
177
  ? text.toolCount(
@@ -258,7 +258,7 @@ export class ConnectorRegistry {
258
258
  const serverId = await this.#contextForge(() =>
259
259
  admin.createServer(
260
260
  this.serverName(name),
261
- `Connector ${name}: ${input.description}`,
261
+ this.#text.serverDescription(name, input.description),
262
262
  usable.map((tool) => tool.id),
263
263
  ),
264
264
  );
@@ -54,6 +54,10 @@ export interface Connectors {
54
54
  profileSources(): ConnectorProfileSource[];
55
55
  /** The bearer token that ContextForge's virtual server URLs expect; one per process. */
56
56
  readonly token: string;
57
+ /** Reads a virtual server's endpoint and tools by name, for the servers a host manages itself. */
58
+ resolve(serverName: string): Promise<VirtualServer>;
59
+ /** What ContextForge holds, upstream gateways and virtual servers, for a host's status view. */
60
+ readonly admin: Pick<ContextForgeAdmin, "gateways" | "servers">;
57
61
  }
58
62
 
59
63
  export const CONNECTORS = serviceKey<Connectors>(
@@ -89,9 +93,12 @@ export function mcpConnectors(options: McpConnectorsOptions): RoundtablePlugin {
89
93
  setup: async ({ database, services, logger }) => {
90
94
  const { jwtSecret, user } = options.contextForge;
91
95
  const token = contextForgeToken(jwtSecret, user, TOKEN_TTL_SECONDS);
96
+ const admin = new ContextForgeAdmin(url, token, fetch, text);
97
+ const resolve = (name: string) =>
98
+ resolveVirtualServer(url, token, name, fetch, text);
92
99
  const registry = await ConnectorRegistry.attach(database(), {
93
- admin: new ContextForgeAdmin(url, token),
94
- resolve: (name) => resolveVirtualServer(url, token, name),
100
+ admin,
101
+ resolve,
95
102
  logger,
96
103
  serverPrefix: options.serverPrefix ?? DEFAULT_SERVER_PREFIX,
97
104
  maxToolName: options.maxToolName ?? DEFAULT_MAX_TOOL_NAME,
@@ -107,6 +114,11 @@ export function mcpConnectors(options: McpConnectorsOptions): RoundtablePlugin {
107
114
  servers: () => registry.servers(),
108
115
  profileSources: () => registry.profileSources(),
109
116
  token,
117
+ resolve,
118
+ admin: {
119
+ gateways: () => admin.gateways(),
120
+ servers: () => admin.servers(),
121
+ },
110
122
  });
111
123
  return {};
112
124
  },
@@ -1,6 +1,7 @@
1
1
  import { createHmac, randomUUID } from "node:crypto";
2
2
  import { ConfigError } from "pi-roundtable";
3
3
  import type { VirtualServer } from "pi-roundtable/kit";
4
+ import { CONNECTOR_MESSAGES, type ConnectorMessages } from "./messages.ts";
4
5
 
5
6
  function base64url(value: string | Buffer): string {
6
7
  return Buffer.from(value).toString("base64url");
@@ -50,6 +51,7 @@ async function getJson(
50
51
  url: string,
51
52
  token: string,
52
53
  fetchImpl: typeof fetch,
54
+ text: ConnectorMessages,
53
55
  ): Promise<unknown> {
54
56
  const response = await fetchImpl(url, {
55
57
  headers: { authorization: `Bearer ${token}` },
@@ -57,7 +59,11 @@ async function getJson(
57
59
  });
58
60
  if (!response.ok) {
59
61
  throw new ConfigError(
60
- `ContextForge ${url} answered ${response.status}: ${(await response.text()).slice(0, 200)}`,
62
+ text.virtualServerRequestFailed(
63
+ url,
64
+ response.status,
65
+ (await response.text()).slice(0, 200),
66
+ ),
61
67
  );
62
68
  }
63
69
  return response.json();
@@ -69,22 +75,25 @@ export async function resolveVirtualServer(
69
75
  token: string,
70
76
  name: string,
71
77
  fetchImpl: typeof fetch = fetch,
78
+ text: ConnectorMessages = CONNECTOR_MESSAGES,
72
79
  ): Promise<VirtualServer> {
73
80
  const servers = await getJson(
74
81
  `${baseUrl}/servers?include_pagination=false`,
75
82
  token,
76
83
  fetchImpl,
84
+ text,
77
85
  );
78
86
  const server = Array.isArray(servers)
79
87
  ? servers.find((entry) => isRecord(entry) && entry.name === name)
80
88
  : undefined;
81
89
  if (!isRecord(server) || typeof server.id !== "string") {
82
- throw new ConfigError(`ContextForge has no virtual server named ${name}`);
90
+ throw new ConfigError(text.virtualServerMissing(name));
83
91
  }
84
92
  const tools = await getJson(
85
93
  `${baseUrl}/servers/${server.id}/tools?include_pagination=false`,
86
94
  token,
87
95
  fetchImpl,
96
+ text,
88
97
  );
89
98
  const names = Array.isArray(tools)
90
99
  ? tools.flatMap((tool) =>
@@ -92,7 +101,7 @@ export async function resolveVirtualServer(
92
101
  )
93
102
  : [];
94
103
  if (names.length === 0)
95
- throw new ConfigError(`virtual server ${name} serves no tools`);
104
+ throw new ConfigError(text.virtualServerNoTools(name));
96
105
  return { name, url: `${baseUrl}/servers/${server.id}/mcp`, tools: names };
97
106
  }
98
107
 
@@ -143,11 +152,18 @@ export class ContextForgeAdmin {
143
152
  readonly #baseUrl: string;
144
153
  readonly #token: string;
145
154
  readonly #fetch: typeof fetch;
155
+ readonly #text: ConnectorMessages;
146
156
 
147
- constructor(baseUrl: string, token: string, fetchImpl: typeof fetch = fetch) {
157
+ constructor(
158
+ baseUrl: string,
159
+ token: string,
160
+ fetchImpl: typeof fetch = fetch,
161
+ text: ConnectorMessages = CONNECTOR_MESSAGES,
162
+ ) {
148
163
  this.#baseUrl = baseUrl;
149
164
  this.#token = token;
150
165
  this.#fetch = fetchImpl;
166
+ this.#text = text;
151
167
  }
152
168
 
153
169
  /** Registers an upstream server; ContextForge connects and lists its tools before answering. */
@@ -161,7 +177,7 @@ export class ContextForgeAdmin {
161
177
  const upstream = URL.parse(gateway.url);
162
178
  if (!upstream)
163
179
  throw new ContextForgeError(
164
- `Cannot parse the upstream URL: ${gateway.url}`,
180
+ this.#text.upstreamUrlUnreadable(gateway.url),
165
181
  );
166
182
  const body = {
167
183
  name: gateway.name,
@@ -179,9 +195,7 @@ export class ContextForgeAdmin {
179
195
  secrets,
180
196
  );
181
197
  if (!isRecord(created) || typeof created.id !== "string")
182
- throw new ContextForgeError(
183
- "ContextForge did not return the gateway ID.",
184
- );
198
+ throw new ContextForgeError(this.#text.contextForgeNoGatewayId);
185
199
  return {
186
200
  id: created.id,
187
201
  slug: typeof created.slug === "string" ? created.slug : gateway.name,
@@ -276,9 +290,7 @@ export class ContextForgeAdmin {
276
290
  server: { name, description, associated_tools: toolIds },
277
291
  });
278
292
  if (!isRecord(created) || typeof created.id !== "string")
279
- throw new ContextForgeError(
280
- "ContextForge did not return the virtual server ID.",
281
- );
293
+ throw new ContextForgeError(this.#text.contextForgeNoServerId);
282
294
  return created.id;
283
295
  }
284
296
 
@@ -310,14 +322,17 @@ export class ContextForgeAdmin {
310
322
  const text = await response.text();
311
323
  if (!response.ok)
312
324
  throw new ContextForgeError(
313
- `ContextForge answered ${response.status}: ${detailOf(masked(text, secrets))}`,
325
+ this.#text.contextForgeRefused(
326
+ response.status,
327
+ detailOf(masked(text, secrets)),
328
+ ),
314
329
  );
315
330
  if (!text) return undefined;
316
331
  try {
317
332
  return JSON.parse(text);
318
333
  } catch {
319
334
  throw new ContextForgeError(
320
- `ContextForge did not answer with JSON: ${detailOf(masked(text, secrets))}`,
335
+ this.#text.contextForgeNotJson(detailOf(masked(text, secrets))),
321
336
  );
322
337
  }
323
338
  }
@@ -46,6 +46,31 @@ export interface ConnectorMessages {
46
46
  addedTitle: string;
47
47
  addedFooter: string;
48
48
  skippedTools(names: string): string;
49
+
50
+ /** Between a connector's name and its address in the list; the English default is two spaces. */
51
+ labelSeparator: string;
52
+ /** The description ContextForge shows on a new connector's virtual server. */
53
+ serverDescription(name: string, description: string): string;
54
+ /** Refuses an upstream URL that cannot be parsed; `url` is the address as the owner gave it. */
55
+ upstreamUrlUnreadable(url: string): string;
56
+ /** ContextForge accepted a new upstream server but returned no gateway ID. */
57
+ contextForgeNoGatewayId: string;
58
+ /** ContextForge accepted a new virtual server but returned no ID. */
59
+ contextForgeNoServerId: string;
60
+ /** ContextForge refused an admin request; `detail` is its reason, with credentials masked. */
61
+ contextForgeRefused(status: number, detail: string): string;
62
+ /** ContextForge answered an admin request with something other than JSON; `detail` is masked. */
63
+ contextForgeNotJson(detail: string): string;
64
+ /** Reading a virtual server's endpoint and tools failed with `status`; raised at startup. */
65
+ virtualServerRequestFailed(
66
+ url: string,
67
+ status: number,
68
+ detail: string,
69
+ ): string;
70
+ /** ContextForge has no virtual server of this name; raised at startup. */
71
+ virtualServerMissing(name: string): string;
72
+ /** The virtual server exists but serves no tools; raised at startup. */
73
+ virtualServerNoTools(name: string): string;
49
74
  }
50
75
 
51
76
  export const CONNECTOR_MESSAGES: ConnectorMessages = {
@@ -109,6 +134,21 @@ export const CONNECTOR_MESSAGES: ConnectorMessages = {
109
134
  "From the next message on, questions that need it go to this connector's tools.",
110
135
  skippedTools: (names) =>
111
136
  `**Skipped tools** Their names are too long for the model: ${names}`,
137
+
138
+ labelSeparator: " ",
139
+ serverDescription: (name, description) => `Connector ${name}: ${description}`,
140
+ upstreamUrlUnreadable: (url) => `Cannot parse the upstream URL: ${url}`,
141
+ contextForgeNoGatewayId: "ContextForge did not return the gateway ID.",
142
+ contextForgeNoServerId: "ContextForge did not return the virtual server ID.",
143
+ contextForgeRefused: (status, detail) =>
144
+ `ContextForge answered ${status}: ${detail}`,
145
+ contextForgeNotJson: (detail) =>
146
+ `ContextForge did not answer with JSON: ${detail}`,
147
+ virtualServerRequestFailed: (url, status, detail) =>
148
+ `ContextForge ${url} answered ${status}: ${detail}`,
149
+ virtualServerMissing: (name) =>
150
+ `ContextForge has no virtual server named ${name}`,
151
+ virtualServerNoTools: (name) => `virtual server ${name} serves no tools`,
112
152
  };
113
153
 
114
154
  /** The English text with the host's own wording laid over it. */
package/src/index.ts CHANGED
@@ -11,8 +11,13 @@ export type {
11
11
  McpConnectorsOptions,
12
12
  } from "./connectors/connectors-plugin.ts";
13
13
  export { CONNECTORS, mcpConnectors } from "./connectors/connectors-plugin.ts";
14
- export type { UpstreamAuth } from "./connectors/contextforge.ts";
14
+ export type { GatewayState, UpstreamAuth } from "./connectors/contextforge.ts";
15
15
  export type { ConnectorMessages } from "./connectors/messages.ts";
16
+ export type {
17
+ ChannelBundle,
18
+ ChannelGrant,
19
+ } from "./remote-mcp/channel-grants.ts";
20
+ export { ChannelGrantStore } from "./remote-mcp/channel-grants.ts";
16
21
  export type {
17
22
  RemoteMcpMessages,
18
23
  RemoteToolNames,
@@ -24,3 +29,5 @@ export type {
24
29
  RemoteMcpOptions,
25
30
  } from "./remote-mcp/remote-mcp-plugin.ts";
26
31
  export { remoteMcp } from "./remote-mcp/remote-mcp-plugin.ts";
32
+ export type { RemoteMcpService } from "./remote-mcp/remote-mcp-service.ts";
33
+ export { REMOTE_MCP } from "./remote-mcp/remote-mcp-service.ts";
@@ -4,6 +4,7 @@ import {
4
4
  ChannelToolError,
5
5
  } from "pi-roundtable/discord";
6
6
  import type { ChannelBundle, ChannelGrantStore } from "./channel-grants.ts";
7
+ import type { RemoteMcpMessages } from "./messages.ts";
7
8
 
8
9
  /**
9
10
  * Runs one tool call for a bundle. The grant and the bundle's token are read after Discord
@@ -15,6 +16,10 @@ export async function runGrantedTool(
15
16
  args: Record<string, unknown>,
16
17
  store: ChannelGrantStore,
17
18
  executor: ChannelExecutor,
19
+ text: Pick<
20
+ RemoteMcpMessages,
21
+ "codeDetail" | "operationFailed" | "outcomeUnrecorded"
22
+ >,
18
23
  ): Promise<unknown> {
19
24
  const spec = CHANNEL_TOOLS[tool];
20
25
  const channelId = args.channelId;
@@ -39,14 +44,20 @@ export async function runGrantedTool(
39
44
  } catch {
40
45
  await store.finishCall(receipt, "failed");
41
46
  throw new ChannelToolError(
42
- `DISCORD_OPERATION_FAILED: audit entry ${receipt}`,
47
+ text.codeDetail(
48
+ "DISCORD_OPERATION_FAILED",
49
+ text.operationFailed(receipt),
50
+ ),
43
51
  );
44
52
  }
45
53
  try {
46
54
  await store.finishCall(receipt, "succeeded");
47
55
  } catch {
48
56
  throw new ChannelToolError(
49
- `DISCORD_OUTCOME_UNRECORDED: the operation may have completed, so do not retry automatically. Audit entry ${receipt}`,
57
+ text.codeDetail(
58
+ "DISCORD_OUTCOME_UNRECORDED",
59
+ text.outcomeUnrecorded(receipt),
60
+ ),
50
61
  );
51
62
  }
52
63
  return result;
@@ -243,6 +243,7 @@ export class McpGateway {
243
243
  parseChannelTool(name, args),
244
244
  grants,
245
245
  executor(),
246
+ this.#text,
246
247
  ),
247
248
  ),
248
249
  });
@@ -21,11 +21,9 @@ function mcpGroup(text: RemoteMcpMessages) {
21
21
  .setMaxLength(80)
22
22
  .setAutocomplete(true)
23
23
  .setRequired(true);
24
- const agentName = (option: SlashCommandStringOption) =>
25
- option
26
- .setName("name")
27
- .setDescription(text.agentNameOption)
28
- .setMaxLength(100);
24
+ const agentName =
25
+ (description: string) => (option: SlashCommandStringOption) =>
26
+ option.setName("name").setDescription(description).setMaxLength(100);
29
27
  const purpose = (option: SlashCommandStringOption) =>
30
28
  option
31
29
  .setName("description")
@@ -42,7 +40,7 @@ function mcpGroup(text: RemoteMcpMessages) {
42
40
  .setName("authorize")
43
41
  .setDescription(text.authorizeDescription)
44
42
  .addStringOption(bundle)
45
- .addStringOption(agentName)
43
+ .addStringOption(agentName(text.agentNameOption))
46
44
  .addStringOption(purpose),
47
45
  )
48
46
  .addSubcommand((sub) =>
@@ -60,7 +58,7 @@ function mcpGroup(text: RemoteMcpMessages) {
60
58
  .setName("describe")
61
59
  .setDescription(text.describeDescription)
62
60
  .addStringOption(bundle)
63
- .addStringOption(agentName)
61
+ .addStringOption(agentName(text.describeAgentNameOption))
64
62
  .addStringOption(purpose)
65
63
  .addStringOption(channelId),
66
64
  )
@@ -114,7 +114,7 @@ export class McpGrantFlow {
114
114
  sections: [
115
115
  removed
116
116
  ? text.revoked(plain(bundle.name))
117
- : text.notInBundle(plain(bundle.name)),
117
+ : text.revokeNotInBundle(plain(bundle.name)),
118
118
  ],
119
119
  }),
120
120
  );
@@ -285,7 +285,7 @@ export class McpGrantFlow {
285
285
  text.granted(
286
286
  plain(channel.name),
287
287
  plain(bundle.name),
288
- opLabels(pending.operations),
288
+ opLabels(pending.operations, text),
289
289
  ),
290
290
  created ? text.urlSection(endpoint.url) : text.existingUrl,
291
291
  ],
@@ -318,7 +318,7 @@ export class McpGrantFlow {
318
318
  `${text.recentAudit}\n${audit
319
319
  .map(
320
320
  (a) =>
321
- `<t:${Math.floor(a.createdAt.getTime() / 1000)}:f> \`${a.tool}\` ${a.status}`,
321
+ `<t:${Math.floor(a.createdAt.getTime() / 1000)}:f> \`${a.tool}\` ${text.auditStatus(a.status)}`,
322
322
  )
323
323
  .join("\n")}`,
324
324
  );
@@ -43,8 +43,8 @@ export interface Pending {
43
43
  }
44
44
 
45
45
  export const PENDING_MS = 5 * 60_000;
46
- export const opLabels = (ops: ChannelOperation[]) =>
47
- ops.map(operationLabel).join(", ");
46
+ export const opLabels = (ops: ChannelOperation[], text: RemoteMcpMessages) =>
47
+ ops.map(operationLabel).join(text.listSeparator);
48
48
 
49
49
  export function authorizePanel(
50
50
  id: string,
@@ -52,7 +52,7 @@ export function authorizePanel(
52
52
  source: "existing" | "defaults" | "chosen",
53
53
  text: RemoteMcpMessages,
54
54
  ) {
55
- const labels = opLabels(pending.operations);
55
+ const labels = opLabels(pending.operations, text);
56
56
  const intro = pending.operations.length
57
57
  ? {
58
58
  existing: text.keepExisting,
@@ -133,9 +133,9 @@ export async function describeGrant(
133
133
  where += text.invisibleChannel;
134
134
  }
135
135
  return [
136
- `- **${plain(grant.displayName)}** ${where}`,
136
+ `- **${plain(grant.displayName)}**${text.labelSeparator}${where}`,
137
137
  text.purposeLine(plain(grant.description) || text.notSet),
138
- text.allowedLine(opLabels(grant.operations), grant.channelId),
138
+ text.allowedLine(opLabels(grant.operations, text), grant.channelId),
139
139
  ].join("\n");
140
140
  }
141
141
 
@@ -20,6 +20,15 @@ export interface RemoteMcpMessages {
20
20
  runFailed: string;
21
21
  /** Sent for a granted tool call that failed in a way the agent must not retry. */
22
22
  operationUnfinished: string;
23
+ /**
24
+ * A granted tool's error as the outside agent reads it: the fixed machine `code` it matches
25
+ * on, then the human `detail`. Keep the code in the text; the default joins them with `: `.
26
+ */
27
+ codeDetail(code: string, detail: string): string;
28
+ /** The detail after `DISCORD_OPERATION_FAILED`: the call failed, and where to look it up. */
29
+ operationFailed(audit: string): string;
30
+ /** The detail after `DISCORD_OUTCOME_UNRECORDED`: the call may have completed but was not recorded. */
31
+ outcomeUnrecorded(audit: string): string;
23
32
 
24
33
  /** The description of the dispatch tool; `tools` holds the names the host chose. */
25
34
  dispatchDescription(tools: RemoteToolNames): string;
@@ -33,7 +42,10 @@ export interface RemoteMcpMessages {
33
42
  groupDescription: string;
34
43
  bundleOption: string;
35
44
  authorizeDescription: string;
45
+ /** The `name` option of `authorize`. */
36
46
  agentNameOption: string;
47
+ /** The `name` option of `describe`, which changes a name that already exists. */
48
+ describeAgentNameOption: string;
37
49
  purposeOption: string;
38
50
  grantsDescription: string;
39
51
  revokeDescription: string;
@@ -48,7 +60,10 @@ export interface RemoteMcpMessages {
48
60
  nameOrDescription: string;
49
61
  describeTitle: string;
50
62
  described: string;
63
+ /** Said by `describe` when the channel is not in the bundle. */
51
64
  notInBundle(bundle: string): string;
65
+ /** Said by `revoke` when the channel was not in the bundle to begin with. */
66
+ revokeNotInBundle(bundle: string): string;
52
67
  revokedTitle: string;
53
68
  revoked(bundle: string): string;
54
69
  expiredTitle: string;
@@ -91,6 +106,13 @@ export interface RemoteMcpMessages {
91
106
  needManageChannels: string;
92
107
  youLackPermission(operation: string): string;
93
108
  botLacksPermission(operation: string): string;
109
+
110
+ /** Between a grant's name and where its channel is, in the grants list; two spaces by default. */
111
+ labelSeparator: string;
112
+ /** Between the operations of a grant; `, ` by default. */
113
+ listSeparator: string;
114
+ /** How an audit entry's status reads in the grants list; `started`, `succeeded` or `failed`. */
115
+ auditStatus(status: string): string;
94
116
  }
95
117
 
96
118
  export const REMOTE_MCP_MESSAGES: RemoteMcpMessages = {
@@ -99,6 +121,10 @@ export const REMOTE_MCP_MESSAGES: RemoteMcpMessages = {
99
121
  runFailed: "This run did not complete. Try again later.",
100
122
  operationUnfinished:
101
123
  "The operation did not complete. Check the audit log of the grants on Discord; do not retry automatically.",
124
+ codeDetail: (code, detail) => `${code}: ${detail}`,
125
+ operationFailed: (audit) => `audit entry ${audit}`,
126
+ outcomeUnrecorded: (audit) =>
127
+ `the operation may have completed, so do not retry automatically. Audit entry ${audit}`,
102
128
 
103
129
  dispatchDescription: (tools) =>
104
130
  "Start or continue a conversation turn with the owner's personal agent, on the owner's behalf. " +
@@ -119,6 +145,8 @@ export const REMOTE_MCP_MESSAGES: RemoteMcpMessages = {
119
145
  "Add this channel to a bundle and choose the allowed operations",
120
146
  agentNameOption:
121
147
  "The channel name the agent sees; Discord's name is unchanged",
148
+ describeAgentNameOption:
149
+ "The channel name the agent sees; Discord's name is unchanged",
122
150
  purposeOption: "What this channel is for",
123
151
  grantsDescription: "List every grant and this channel's audit log",
124
152
  revokeDescription: "Remove a channel from a bundle",
@@ -138,6 +166,7 @@ export const REMOTE_MCP_MESSAGES: RemoteMcpMessages = {
138
166
  described:
139
167
  "Updated the name and purpose the agent sees; the name and topic on Discord are unchanged.",
140
168
  notInBundle: (bundle) => `This channel is not in bundle **${bundle}**.`,
169
+ revokeNotInBundle: (bundle) => `This channel is not in bundle **${bundle}**.`,
141
170
  revokedTitle: "Channel removed",
142
171
  revoked: (bundle) =>
143
172
  `Removed the channel from bundle **${bundle}**; its other channels and its URL keep working. Operations already sent are not undone.`,
@@ -198,6 +227,10 @@ export const REMOTE_MCP_MESSAGES: RemoteMcpMessages = {
198
227
  `You lack the Discord permission behind "${operation}".`,
199
228
  botLacksPermission: (operation) =>
200
229
  `The bot lacks the Discord permission behind "${operation}"; grant it in the server settings first.`,
230
+
231
+ labelSeparator: " ",
232
+ listSeparator: ", ",
233
+ auditStatus: (status) => status,
201
234
  };
202
235
 
203
236
  /** The English text with the host's own wording laid over it. */
@@ -26,6 +26,7 @@ import {
26
26
  } from "./messages.ts";
27
27
  import { RemoteAgent } from "./remote-agent.ts";
28
28
  import { type RemoteClaimHooks, remoteClaim } from "./remote-claim.ts";
29
+ import { REMOTE_MCP, remoteMcpService } from "./remote-mcp-service.ts";
29
30
  import { RemoteSessionStore } from "./remote-session-store.ts";
30
31
  import { RemoteSessionSweeper } from "./session-sweeper.ts";
31
32
 
@@ -104,6 +105,7 @@ function checkOptions(options: RemoteMcpOptions): void {
104
105
  * An MCP server over HTTP for outside agents: `/mcp/personal` relays turns to the owner's agent
105
106
  * and returns the result when polled, and `/mcp/discord/<token>` offers the Discord channel
106
107
  * tools granted to one bundle. Grants are approved on Discord with `/<root> mcp`.
108
+ * Provides `REMOTE_MCP`.
107
109
  */
108
110
  export function remoteMcp(options: RemoteMcpOptions): RoundtablePlugin {
109
111
  checkOptions(options);
@@ -112,6 +114,7 @@ export function remoteMcp(options: RemoteMcpOptions): RoundtablePlugin {
112
114
  return definePlugin({
113
115
  name: "remote-mcp",
114
116
  requires: [DISCORD],
117
+ provides: [REMOTE_MCP],
115
118
  migrations: [ChannelGrantStore.migration, RemoteSessionStore.migration],
116
119
  setup: async (context) => {
117
120
  const { services, logger, conversations, database } = context;
@@ -142,6 +145,7 @@ export function remoteMcp(options: RemoteMcpOptions): RoundtablePlugin {
142
145
  discord.commands.add(
143
146
  mcpGrantCommands(discord.guard, grants, options.publicUrl, text),
144
147
  );
148
+ services.provide(REMOTE_MCP, remoteMcpService(grants, text));
145
149
  return {
146
150
  services: [
147
151
  {
@@ -0,0 +1,31 @@
1
+ import type { Client } from "discord.js";
2
+ import { serviceKey } from "pi-roundtable";
3
+ import type { ChannelGrant, ChannelGrantStore } from "./channel-grants.ts";
4
+ import { describeGrant } from "./mcp-grant-panels.ts";
5
+ import type { RemoteMcpMessages } from "./messages.ts";
6
+
7
+ /** What a host reads to show the owner which channels outside agents may use. */
8
+ export interface RemoteMcpService {
9
+ /** The owner's bundles and their channel grants, read only. */
10
+ grants: Pick<ChannelGrantStore, "bundles" | "grants">;
11
+ /** One grant as lines of text: the agent's name for the channel, where it is, its purpose, and the allowed operations. */
12
+ describeGrant(client: Client, grant: ChannelGrant): Promise<string>;
13
+ }
14
+
15
+ export const REMOTE_MCP = serviceKey<RemoteMcpService>(
16
+ "pi-roundtable-mcp.remote-mcp",
17
+ );
18
+
19
+ /** The service over the plugin's grant store and its wording. */
20
+ export function remoteMcpService(
21
+ grants: ChannelGrantStore,
22
+ text: RemoteMcpMessages,
23
+ ): RemoteMcpService {
24
+ return {
25
+ grants: {
26
+ bundles: () => grants.bundles(),
27
+ grants: (...args) => grants.grants(...args),
28
+ },
29
+ describeGrant: (client, grant) => describeGrant(client, grant, text),
30
+ };
31
+ }