pi-roundtable-mcp 0.3.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 +14 -0
- package/README.md +10 -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 +3 -2
- package/src/connectors/contextforge.ts +28 -13
- package/src/connectors/messages.ts +40 -0
- package/src/index.ts +1 -0
- 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/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@ 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
|
+
|
|
8
22
|
## [0.3.0] - 2026-10-01
|
|
9
23
|
|
|
10
24
|
### Added
|
package/README.md
CHANGED
|
@@ -186,6 +186,12 @@ The plugin provides the `REMOTE_MCP` service (`RemoteMcpService`) for a host tha
|
|
|
186
186
|
| `grants` | `bundles()` and `grants(bundleId?)`, read only: every bundle, and the channel grants of one bundle or of all |
|
|
187
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
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
|
+
|
|
189
195
|
#### The default conversation
|
|
190
196
|
|
|
191
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.
|
|
@@ -252,9 +258,11 @@ The host's agents then reach a connector through the connector's virtual server
|
|
|
252
258
|
|
|
253
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.
|
|
254
260
|
Entries that take values are functions.
|
|
255
|
-
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.
|
|
256
265
|
Operation labels (`read`, `send`, ...) come from pi-roundtable and follow the host's language.
|
|
257
|
-
The errors of the ContextForge client (a refused request) are English only.
|
|
258
266
|
|
|
259
267
|
## Database
|
|
260
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
|
);
|
|
@@ -93,8 +93,9 @@ export function mcpConnectors(options: McpConnectorsOptions): RoundtablePlugin {
|
|
|
93
93
|
setup: async ({ database, services, logger }) => {
|
|
94
94
|
const { jwtSecret, user } = options.contextForge;
|
|
95
95
|
const token = contextForgeToken(jwtSecret, user, TOKEN_TTL_SECONDS);
|
|
96
|
-
const admin = new ContextForgeAdmin(url, token);
|
|
97
|
-
const resolve = (name: string) =>
|
|
96
|
+
const admin = new ContextForgeAdmin(url, token, fetch, text);
|
|
97
|
+
const resolve = (name: string) =>
|
|
98
|
+
resolveVirtualServer(url, token, name, fetch, text);
|
|
98
99
|
const registry = await ConnectorRegistry.attach(database(), {
|
|
99
100
|
admin,
|
|
100
101
|
resolve,
|
|
@@ -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
|
@@ -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. */
|