pi-roundtable-mcp 0.1.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.
@@ -0,0 +1,348 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type {
3
+ AutocompleteInteraction,
4
+ ButtonInteraction,
5
+ ChatInputCommandInteraction,
6
+ StringSelectMenuInteraction,
7
+ } from "discord.js";
8
+ import {
9
+ ephemeralPanel,
10
+ isChannelOperation,
11
+ OwnerFacingError,
12
+ ownerPanel,
13
+ plain,
14
+ replyWithPanels,
15
+ } from "pi-roundtable/discord";
16
+ import {
17
+ type ChannelGrantStore,
18
+ newChannelEndpoint,
19
+ } from "./channel-grants.ts";
20
+ import {
21
+ authorizePanel,
22
+ describeGrant,
23
+ GRANT_PREFIX,
24
+ managedChannel,
25
+ opLabels,
26
+ PENDING_MS,
27
+ type Pending,
28
+ rotatePanel,
29
+ } from "./mcp-grant-panels.ts";
30
+ import type { RemoteMcpMessages } from "./messages.ts";
31
+
32
+ type ButtonOrMenu = ButtonInteraction | StringSelectMenuInteraction;
33
+
34
+ /**
35
+ * `/<root> mcp …`: which channels an outside agent may use through a bundle's MCP URL, and
36
+ * with which operations. A grant can never exceed what both the owner and the bot may do.
37
+ */
38
+ export class McpGrantFlow {
39
+ readonly #grants: ChannelGrantStore;
40
+ readonly #publicUrl: string;
41
+ readonly #root: string;
42
+ readonly #text: RemoteMcpMessages;
43
+ readonly #pending = new Map<string, Pending>();
44
+
45
+ constructor(
46
+ grants: ChannelGrantStore,
47
+ publicUrl: string,
48
+ root: string,
49
+ text: RemoteMcpMessages,
50
+ ) {
51
+ this.#grants = grants;
52
+ this.#publicUrl = publicUrl;
53
+ this.#root = root;
54
+ this.#text = text;
55
+ }
56
+
57
+ async autocomplete(interaction: AutocompleteInteraction): Promise<void> {
58
+ const typed = interaction.options.getFocused().trim().slice(0, 80);
59
+ const query = typed.toLocaleLowerCase();
60
+ const bundles = await this.#grants.bundles();
61
+ const choices = bundles
62
+ .filter((b) => b.name.toLocaleLowerCase().includes(query))
63
+ .map((b) => ({ name: b.name, value: b.name }));
64
+ // Only authorize creates bundles; the other subcommands need an existing one.
65
+ const creating =
66
+ interaction.options.getSubcommand() === "authorize" &&
67
+ typed !== "" &&
68
+ !bundles.some((b) => b.name === typed);
69
+ await interaction.respond(
70
+ [
71
+ ...(creating
72
+ ? [{ name: this.#text.newBundleChoice(typed), value: typed }]
73
+ : []),
74
+ ...choices,
75
+ ].slice(0, 25),
76
+ );
77
+ }
78
+
79
+ /** The interaction is deferred and ephemeral. */
80
+ async command(interaction: ChatInputCommandInteraction): Promise<void> {
81
+ const text = this.#text;
82
+ const sub = interaction.options.getSubcommand(true);
83
+ if (sub === "grants") return this.#showGrants(interaction);
84
+ if (!interaction.inGuild()) throw new OwnerFacingError(text.useInServer);
85
+ const bundleName = interaction.options.getString("bundle", true).trim();
86
+ if (sub === "authorize")
87
+ return this.#beginAuthorize(interaction, bundleName);
88
+ const bundle = await this.#grants.bundleByName(bundleName);
89
+ if (!bundle)
90
+ throw new OwnerFacingError(text.noBundle(plain(bundleName), this.#root));
91
+ if (sub === "token") {
92
+ const id = this.#hold(interaction, {
93
+ bundleName: bundle.name,
94
+ bundleId: bundle.id,
95
+ operations: [],
96
+ rotate: true,
97
+ });
98
+ await interaction.editReply(rotatePanel(bundle.name, id, text));
99
+ return;
100
+ }
101
+ const channelId =
102
+ interaction.options.getString("channel_id")?.trim() ??
103
+ interaction.channelId;
104
+ if (!/^\d{1,20}$/.test(channelId))
105
+ throw new OwnerFacingError(text.channelIdRule);
106
+ if (sub === "describe")
107
+ return this.#describe(interaction, bundle, channelId);
108
+ const removed = await this.#grants.revoke(bundle.id, channelId);
109
+ for (const [id, value] of this.#pending)
110
+ if (value.channelId === channelId) this.#pending.delete(id);
111
+ await interaction.editReply(
112
+ ownerPanel({
113
+ title: text.revokedTitle,
114
+ sections: [
115
+ removed
116
+ ? text.revoked(plain(bundle.name))
117
+ : text.notInBundle(plain(bundle.name)),
118
+ ],
119
+ }),
120
+ );
121
+ }
122
+
123
+ /** Buttons and the operations menu of a pending grant or rotation. */
124
+ async component(interaction: ButtonOrMenu): Promise<void> {
125
+ const text = this.#text;
126
+ const [action, id = ""] = interaction.customId
127
+ .slice(GRANT_PREFIX.length)
128
+ .split(":");
129
+ const pending = this.#pending.get(id);
130
+ if (
131
+ !pending ||
132
+ pending.userId !== interaction.user.id ||
133
+ pending.channelId !== interaction.channelId ||
134
+ pending.expiresAt <= Date.now()
135
+ ) {
136
+ await interaction.reply(
137
+ ephemeralPanel({
138
+ title: text.expiredTitle,
139
+ sections: [text.expiredBody],
140
+ }),
141
+ );
142
+ return;
143
+ }
144
+ await interaction.deferUpdate();
145
+ if (action === "cancel") {
146
+ this.#pending.delete(id);
147
+ await interaction.editReply(
148
+ ownerPanel({
149
+ title: text.cancelledTitle,
150
+ sections: [text.cancelledBody],
151
+ }),
152
+ );
153
+ return;
154
+ }
155
+ if (action === "select" && interaction.isStringSelectMenu()) {
156
+ pending.operations = interaction.values.filter(isChannelOperation);
157
+ await interaction.editReply(authorizePanel(id, pending, "chosen", text));
158
+ return;
159
+ }
160
+ // Consumed before any await, so a double click cannot act twice.
161
+ this.#pending.delete(id);
162
+ if (action === "rotate" && pending.rotate && pending.bundleId) {
163
+ await this.#rotate(interaction, pending, pending.bundleId);
164
+ return;
165
+ }
166
+ if (action === "confirm" && !pending.rotate && pending.operations.length)
167
+ await this.#confirm(interaction, pending);
168
+ }
169
+
170
+ async #describe(
171
+ interaction: ChatInputCommandInteraction,
172
+ bundle: { id: string; name: string },
173
+ channelId: string,
174
+ ): Promise<void> {
175
+ const text = this.#text;
176
+ const displayName = interaction.options.getString("name")?.trim();
177
+ const description = interaction.options.getString("description")?.trim();
178
+ if (displayName === undefined && description === undefined)
179
+ throw new OwnerFacingError(text.nameOrDescription);
180
+ const changed = await this.#grants.describe(bundle.id, channelId, {
181
+ ...(displayName === undefined ? {} : { displayName }),
182
+ ...(description === undefined ? {} : { description }),
183
+ });
184
+ await interaction.editReply(
185
+ ownerPanel({
186
+ title: text.describeTitle,
187
+ sections: [
188
+ changed ? text.described : text.notInBundle(plain(bundle.name)),
189
+ ],
190
+ }),
191
+ );
192
+ }
193
+
194
+ async #rotate(
195
+ interaction: ButtonOrMenu,
196
+ pending: Pending,
197
+ bundleId: string,
198
+ ): Promise<void> {
199
+ const text = this.#text;
200
+ const endpoint = newChannelEndpoint(this.#publicUrl);
201
+ await this.#grants.rotateToken(bundleId, endpoint.tokenHash);
202
+ await interaction.editReply(
203
+ ownerPanel({
204
+ title: text.rotatedTitle,
205
+ sections: [
206
+ text.rotated(plain(pending.bundleName)),
207
+ text.urlSection(endpoint.url),
208
+ ],
209
+ footer: text.urlFooter,
210
+ }),
211
+ );
212
+ }
213
+
214
+ async #beginAuthorize(
215
+ interaction: ChatInputCommandInteraction,
216
+ bundleName: string,
217
+ ): Promise<void> {
218
+ const text = this.#text;
219
+ if (!bundleName || bundleName.length > 80)
220
+ throw new OwnerFacingError(text.bundleNameRule);
221
+ await managedChannel(
222
+ interaction.client,
223
+ interaction.channelId,
224
+ interaction.user.id,
225
+ [],
226
+ text,
227
+ );
228
+ const bundle = await this.#grants.bundleByName(bundleName);
229
+ const existing = bundle
230
+ ? await this.#grants.grant(bundle.id, interaction.channelId)
231
+ : undefined;
232
+ for (const [key, value] of this.#pending)
233
+ if (value.channelId === interaction.channelId) this.#pending.delete(key);
234
+ const pending: Omit<Pending, "userId" | "channelId" | "expiresAt"> = {
235
+ bundleName,
236
+ operations: [
237
+ ...(existing?.operations ?? bundle?.defaultOperations ?? []),
238
+ ],
239
+ rotate: false,
240
+ };
241
+ const displayName = interaction.options.getString("name")?.trim();
242
+ const description = interaction.options.getString("description")?.trim();
243
+ if (displayName) pending.displayName = displayName;
244
+ if (description) pending.description = description;
245
+ const id = this.#hold(interaction, pending);
246
+ const held = this.#pending.get(id);
247
+ if (held)
248
+ await interaction.editReply(
249
+ authorizePanel(id, held, existing ? "existing" : "defaults", text),
250
+ );
251
+ }
252
+
253
+ async #confirm(interaction: ButtonOrMenu, pending: Pending): Promise<void> {
254
+ const text = this.#text;
255
+ const channel = await managedChannel(
256
+ interaction.client,
257
+ pending.channelId,
258
+ pending.userId,
259
+ pending.operations,
260
+ text,
261
+ );
262
+ const endpoint = newChannelEndpoint(this.#publicUrl);
263
+ const { bundle, created } = await this.#grants.ensureBundle(
264
+ pending.bundleName,
265
+ endpoint.tokenHash,
266
+ pending.operations,
267
+ );
268
+ const existing = await this.#grants.grant(bundle.id, channel.id);
269
+ await this.#grants.save({
270
+ bundleId: bundle.id,
271
+ channelId: channel.id,
272
+ guildId: channel.guildId,
273
+ operations: pending.operations,
274
+ displayName: pending.displayName ?? existing?.displayName ?? channel.name,
275
+ description: pending.description ?? existing?.description ?? "",
276
+ guildName: channel.guild.name,
277
+ channelName: channel.name,
278
+ authorizedBy: pending.userId,
279
+ authorizedAt: new Date(),
280
+ });
281
+ await interaction.editReply(
282
+ ownerPanel({
283
+ title: text.grantedTitle,
284
+ sections: [
285
+ text.granted(
286
+ plain(channel.name),
287
+ plain(bundle.name),
288
+ opLabels(pending.operations),
289
+ ),
290
+ created ? text.urlSection(endpoint.url) : text.existingUrl,
291
+ ],
292
+ footer: created ? text.urlFooter : text.urlLostFooter(this.#root),
293
+ }),
294
+ );
295
+ }
296
+
297
+ async #showGrants(interaction: ChatInputCommandInteraction): Promise<void> {
298
+ const text = this.#text;
299
+ const [grants, bundles] = await Promise.all([
300
+ this.#grants.grants(),
301
+ this.#grants.bundles(),
302
+ ]);
303
+ const sections: string[] = [];
304
+ for (const bundle of bundles) {
305
+ const own = grants.filter((g) => g.bundleId === bundle.id);
306
+ const lines = await Promise.all(
307
+ own.map((g) => describeGrant(interaction.client, g, text)),
308
+ );
309
+ sections.push(
310
+ `**${plain(bundle.name)}**\n${own.length ? lines.join("\n") : text.noChannels}`,
311
+ );
312
+ }
313
+ if (sections.length === 0) sections.push(text.noGrants);
314
+ if (interaction.inGuild()) {
315
+ const audit = await this.#grants.recentAudit(interaction.channelId);
316
+ if (audit.length)
317
+ sections.push(
318
+ `${text.recentAudit}\n${audit
319
+ .map(
320
+ (a) =>
321
+ `<t:${Math.floor(a.createdAt.getTime() / 1000)}:f> \`${a.tool}\` ${a.status}`,
322
+ )
323
+ .join("\n")}`,
324
+ );
325
+ }
326
+ await replyWithPanels(interaction, {
327
+ title: text.grantsTitle,
328
+ sections,
329
+ footer: text.grantsFooter(this.#root),
330
+ });
331
+ }
332
+
333
+ #hold(
334
+ interaction: ChatInputCommandInteraction,
335
+ pending: Omit<Pending, "userId" | "channelId" | "expiresAt">,
336
+ ): string {
337
+ for (const [key, value] of this.#pending)
338
+ if (value.expiresAt <= Date.now()) this.#pending.delete(key);
339
+ const id = randomUUID();
340
+ this.#pending.set(id, {
341
+ ...pending,
342
+ userId: interaction.user.id,
343
+ channelId: interaction.channelId,
344
+ expiresAt: Date.now() + PENDING_MS,
345
+ });
346
+ return id;
347
+ }
348
+ }
@@ -0,0 +1,174 @@
1
+ import {
2
+ ActionRowBuilder,
3
+ ButtonBuilder,
4
+ ButtonStyle,
5
+ type Client,
6
+ PermissionFlagsBits,
7
+ StringSelectMenuBuilder,
8
+ } from "discord.js";
9
+ import {
10
+ CHANNEL_OPERATIONS,
11
+ type ChannelOperation,
12
+ fetchManagedChannel,
13
+ type ManagedChannel,
14
+ OPERATION_PERMISSIONS,
15
+ OwnerFacingError,
16
+ operationLabel,
17
+ ownerPanel,
18
+ plain,
19
+ } from "pi-roundtable/discord";
20
+ import type { ChannelGrant } from "./channel-grants.ts";
21
+ import type { RemoteMcpMessages } from "./messages.ts";
22
+
23
+ /** What every button and menu of the grant flow starts with. */
24
+ export const GRANT_PREFIX = "rtmcp:grant:";
25
+
26
+ export const MCP_IDS = {
27
+ select: `${GRANT_PREFIX}select:`,
28
+ confirm: `${GRANT_PREFIX}confirm:`,
29
+ rotate: `${GRANT_PREFIX}rotate:`,
30
+ cancel: `${GRANT_PREFIX}cancel:`,
31
+ } as const;
32
+
33
+ export interface Pending {
34
+ userId: string;
35
+ channelId: string;
36
+ bundleName: string;
37
+ bundleId?: string;
38
+ displayName?: string;
39
+ description?: string;
40
+ operations: ChannelOperation[];
41
+ rotate: boolean;
42
+ expiresAt: number;
43
+ }
44
+
45
+ export const PENDING_MS = 5 * 60_000;
46
+ export const opLabels = (ops: ChannelOperation[]) =>
47
+ ops.map(operationLabel).join(", ");
48
+
49
+ export function authorizePanel(
50
+ id: string,
51
+ pending: Pending,
52
+ source: "existing" | "defaults" | "chosen",
53
+ text: RemoteMcpMessages,
54
+ ) {
55
+ const labels = opLabels(pending.operations);
56
+ const intro = pending.operations.length
57
+ ? {
58
+ existing: text.keepExisting,
59
+ defaults: text.keepDefaults,
60
+ chosen: text.chosen,
61
+ }[source](labels)
62
+ : text.chooseFirst;
63
+ return ownerPanel({
64
+ title: text.authorizeTitle(plain(pending.bundleName)),
65
+ sections: [intro],
66
+ rows: [
67
+ new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
68
+ new StringSelectMenuBuilder()
69
+ .setCustomId(`${MCP_IDS.select}${id}`)
70
+ .setPlaceholder(text.selectPlaceholder)
71
+ .setMinValues(1)
72
+ .setMaxValues(CHANNEL_OPERATIONS.length)
73
+ .addOptions(
74
+ CHANNEL_OPERATIONS.map((op) => ({
75
+ label: operationLabel(op),
76
+ value: op,
77
+ default: pending.operations.includes(op),
78
+ })),
79
+ ),
80
+ ),
81
+ new ActionRowBuilder<ButtonBuilder>().addComponents(
82
+ new ButtonBuilder()
83
+ .setCustomId(`${MCP_IDS.confirm}${id}`)
84
+ .setLabel(text.confirmButton)
85
+ .setStyle(ButtonStyle.Primary)
86
+ .setDisabled(pending.operations.length === 0),
87
+ new ButtonBuilder()
88
+ .setCustomId(`${MCP_IDS.cancel}${id}`)
89
+ .setLabel(text.cancelButton)
90
+ .setStyle(ButtonStyle.Secondary),
91
+ ),
92
+ ],
93
+ footer: text.authorizeFooter,
94
+ });
95
+ }
96
+
97
+ /** Asks to confirm replacing a bundle's MCP URL. */
98
+ export function rotatePanel(
99
+ bundleName: string,
100
+ id: string,
101
+ text: RemoteMcpMessages,
102
+ ) {
103
+ return ownerPanel({
104
+ title: text.rotateTitle,
105
+ sections: [text.rotateAsk(plain(bundleName))],
106
+ rows: [
107
+ new ActionRowBuilder<ButtonBuilder>().addComponents(
108
+ new ButtonBuilder()
109
+ .setCustomId(`${MCP_IDS.rotate}${id}`)
110
+ .setLabel(text.rotateButton)
111
+ .setStyle(ButtonStyle.Danger),
112
+ new ButtonBuilder()
113
+ .setCustomId(`${MCP_IDS.cancel}${id}`)
114
+ .setLabel(text.cancelButton)
115
+ .setStyle(ButtonStyle.Secondary),
116
+ ),
117
+ ],
118
+ footer: text.rotateFooter,
119
+ });
120
+ }
121
+
122
+ /** One grant as a line block: agent name, where it is, purpose, and operations. */
123
+ export async function describeGrant(
124
+ client: Client,
125
+ grant: ChannelGrant,
126
+ text: RemoteMcpMessages,
127
+ ): Promise<string> {
128
+ let where = `${plain(grant.guildName)} › #${plain(grant.channelName)}`;
129
+ try {
130
+ const channel = await fetchManagedChannel(client, grant.channelId);
131
+ where = `${plain(channel.guild.name)} › <#${channel.id}>`;
132
+ } catch {
133
+ where += text.invisibleChannel;
134
+ }
135
+ return [
136
+ `- **${plain(grant.displayName)}** ${where}`,
137
+ text.purposeLine(plain(grant.description) || text.notSet),
138
+ text.allowedLine(opLabels(grant.operations), grant.channelId),
139
+ ].join("\n");
140
+ }
141
+
142
+ /**
143
+ * The channel, after checking the owner may manage it and both the owner and the bot hold
144
+ * the Discord permissions behind each requested operation.
145
+ */
146
+ export async function managedChannel(
147
+ client: Client,
148
+ channelId: string,
149
+ userId: string,
150
+ operations: ChannelOperation[],
151
+ text: RemoteMcpMessages,
152
+ ): Promise<ManagedChannel> {
153
+ let channel: ManagedChannel;
154
+ try {
155
+ channel = await fetchManagedChannel(client, channelId);
156
+ } catch {
157
+ throw new OwnerFacingError(text.onlyTextChannels);
158
+ }
159
+ const member = await channel.guild.members.fetch({
160
+ user: userId,
161
+ force: true,
162
+ });
163
+ if (!channel.permissionsFor(member).has(PermissionFlagsBits.ManageChannels))
164
+ throw new OwnerFacingError(text.needManageChannels);
165
+ const bot = await channel.guild.members.fetchMe({ force: true });
166
+ for (const op of operations) {
167
+ const needed = OPERATION_PERMISSIONS[op];
168
+ if (!channel.permissionsFor(member).has(needed))
169
+ throw new OwnerFacingError(text.youLackPermission(operationLabel(op)));
170
+ if (!channel.permissionsFor(bot).has(needed))
171
+ throw new OwnerFacingError(text.botLacksPermission(operationLabel(op)));
172
+ }
173
+ return channel;
174
+ }