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 +22 -0
- package/README.md +19 -2
- package/package.json +1 -1
- package/src/connectors/connector-commands.ts +1 -1
- package/src/connectors/connector-registry.ts +1 -1
- package/src/connectors/connectors-plugin.ts +14 -2
- package/src/connectors/contextforge.ts +28 -13
- package/src/connectors/messages.ts +40 -0
- package/src/index.ts +8 -1
- package/src/remote-mcp/channel-tools.ts +13 -2
- package/src/remote-mcp/mcp-gateway.ts +1 -0
- package/src/remote-mcp/mcp-grant-commands.ts +5 -7
- package/src/remote-mcp/mcp-grant-flow.ts +3 -3
- package/src/remote-mcp/mcp-grant-panels.ts +5 -5
- package/src/remote-mcp/messages.ts +33 -0
- package/src/remote-mcp/remote-mcp-plugin.ts +4 -0
- package/src/remote-mcp/remote-mcp-service.ts +31 -0
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
|
@@ -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}
|
|
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
|
-
|
|
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
|
|
94
|
-
resolve
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
+
text.codeDetail(
|
|
58
|
+
"DISCORD_OUTCOME_UNRECORDED",
|
|
59
|
+
text.outcomeUnrecorded(receipt),
|
|
60
|
+
),
|
|
50
61
|
);
|
|
51
62
|
}
|
|
52
63
|
return result;
|
|
@@ -21,11 +21,9 @@ function mcpGroup(text: RemoteMcpMessages) {
|
|
|
21
21
|
.setMaxLength(80)
|
|
22
22
|
.setAutocomplete(true)
|
|
23
23
|
.setRequired(true);
|
|
24
|
-
const agentName =
|
|
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.
|
|
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)}
|
|
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
|
+
}
|