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,325 @@
1
+ import {
2
+ type AutocompleteInteraction,
3
+ type ChatInputCommandInteraction,
4
+ LabelBuilder,
5
+ MessageFlags,
6
+ ModalBuilder,
7
+ type ModalSubmitInteraction,
8
+ type SlashCommandStringOption,
9
+ type SlashCommandSubcommandGroupBuilder,
10
+ TextInputBuilder,
11
+ TextInputStyle,
12
+ } from "discord.js";
13
+ import {
14
+ type CommandGuard,
15
+ groupOption,
16
+ type InteractionContribution,
17
+ OwnerFacingError,
18
+ ownerCommandModule,
19
+ ownerPanel,
20
+ plain,
21
+ replyWithPanels,
22
+ } from "pi-roundtable/discord";
23
+ import {
24
+ type Connector,
25
+ ConnectorError,
26
+ type ConnectorRegistry,
27
+ } from "./connector-registry.ts";
28
+ import type { UpstreamAuth } from "./contextforge.ts";
29
+ import type { ConnectorMessages } from "./messages.ts";
30
+
31
+ export const CONNECTOR_MODAL_ID = "rtmcp:connector:add";
32
+
33
+ const FIELD = {
34
+ name: "name",
35
+ url: "url",
36
+ description: "description",
37
+ header: "header",
38
+ token: "token",
39
+ } as const;
40
+
41
+ const HEADER_NAME = /^[A-Za-z0-9-]{1,100}$/;
42
+ const TOOL_PREVIEW = 12;
43
+
44
+ function connectorGroup(text: ConnectorMessages) {
45
+ const connector = (o: SlashCommandStringOption) =>
46
+ o
47
+ .setName("name")
48
+ .setDescription(text.nameOption)
49
+ .setRequired(true)
50
+ .setAutocomplete(true);
51
+ return (group: SlashCommandSubcommandGroupBuilder) =>
52
+ group
53
+ .setName("connector")
54
+ .setDescription(text.groupDescription)
55
+ .addSubcommand((sub) =>
56
+ sub.setName("add").setDescription(text.addDescription),
57
+ )
58
+ .addSubcommand((sub) =>
59
+ sub.setName("list").setDescription(text.listDescription),
60
+ )
61
+ .addSubcommand((sub) =>
62
+ sub
63
+ .setName("describe")
64
+ .setDescription(text.describeDescription)
65
+ .addStringOption(connector)
66
+ .addStringOption((o) =>
67
+ o
68
+ .setName("description")
69
+ .setDescription(text.newPurposeOption)
70
+ .setRequired(true)
71
+ .setMaxLength(1000),
72
+ ),
73
+ )
74
+ .addSubcommand((sub) =>
75
+ sub
76
+ .setName("remove")
77
+ .setDescription(text.removeDescription)
78
+ .addStringOption(connector),
79
+ );
80
+ }
81
+
82
+ /** The private form `/<root> connector add` opens; Discord never shows its values to anyone else. */
83
+ export function connectorModal(text: ConnectorMessages): ModalBuilder {
84
+ const input = (
85
+ id: string,
86
+ label: string,
87
+ description: string,
88
+ style: TextInputStyle,
89
+ options: { required: boolean; max: number; placeholder?: string },
90
+ ) => {
91
+ const field = new TextInputBuilder()
92
+ .setCustomId(id)
93
+ .setStyle(style)
94
+ .setRequired(options.required)
95
+ .setMaxLength(options.max);
96
+ if (options.placeholder) field.setPlaceholder(options.placeholder);
97
+ return new LabelBuilder()
98
+ .setLabel(label)
99
+ .setDescription(description)
100
+ .setTextInputComponent(field);
101
+ };
102
+ return new ModalBuilder()
103
+ .setCustomId(CONNECTOR_MODAL_ID)
104
+ .setTitle(text.modalTitle)
105
+ .addLabelComponents(
106
+ input(FIELD.name, text.nameLabel, text.nameHelp, TextInputStyle.Short, {
107
+ required: true,
108
+ max: 12,
109
+ placeholder: "notion",
110
+ }),
111
+ input(FIELD.url, text.urlLabel, text.urlHelp, TextInputStyle.Short, {
112
+ required: true,
113
+ max: 500,
114
+ placeholder: "https://mcp.example.com/mcp",
115
+ }),
116
+ input(
117
+ FIELD.description,
118
+ text.purposeLabel,
119
+ text.purposeHelp,
120
+ TextInputStyle.Paragraph,
121
+ { required: true, max: 1000 },
122
+ ),
123
+ input(
124
+ FIELD.header,
125
+ text.headerLabel,
126
+ text.headerHelp,
127
+ TextInputStyle.Short,
128
+ { required: false, max: 100, placeholder: "X-API-Key" },
129
+ ),
130
+ input(
131
+ FIELD.token,
132
+ text.tokenLabel,
133
+ text.tokenHelp,
134
+ TextInputStyle.Short,
135
+ { required: false, max: 4000 },
136
+ ),
137
+ );
138
+ }
139
+
140
+ function authFrom(
141
+ header: string,
142
+ token: string,
143
+ text: ConnectorMessages,
144
+ ): UpstreamAuth {
145
+ if (!token) {
146
+ if (header) throw new OwnerFacingError(text.headerNeedsToken);
147
+ return { type: "none" };
148
+ }
149
+ if (!header) return { type: "bearer", token };
150
+ if (!HEADER_NAME.test(header)) throw new OwnerFacingError(text.headerRule);
151
+ return { type: "header", name: header, value: token };
152
+ }
153
+
154
+ /** Scheme and host only: some servers take their credential in the path, query, or user part. */
155
+ export function displayUrl(url: string, unreadable: string): string {
156
+ const parsed = URL.parse(url);
157
+ if (!parsed) return unreadable;
158
+ const hidden =
159
+ parsed.pathname !== "/" ||
160
+ parsed.search ||
161
+ parsed.hash ||
162
+ parsed.username ||
163
+ parsed.password;
164
+ return `${parsed.origin}${hidden ? "/…" : ""}`;
165
+ }
166
+
167
+ function section(connector: Connector, text: ConnectorMessages): string {
168
+ const tools = connector.server?.tools ?? [];
169
+ const listed = tools
170
+ .slice(0, TOOL_PREVIEW)
171
+ .map((t) => `\`${t}\``)
172
+ .join(" ");
173
+ return [
174
+ `**${connector.name}** ${plain(displayUrl(connector.url, text.urlUnreadable))}`,
175
+ plain(connector.description),
176
+ connector.server
177
+ ? text.toolCount(
178
+ tools.length,
179
+ listed,
180
+ Math.max(0, tools.length - TOOL_PREVIEW),
181
+ )
182
+ : text.toolsUnreadable,
183
+ ].join("\n");
184
+ }
185
+
186
+ /** `/<root> connector`: the owner's token-based MCP connectors. */
187
+ export class ConnectorCommands {
188
+ readonly #registry: ConnectorRegistry;
189
+ readonly #root: string;
190
+ readonly #text: ConnectorMessages;
191
+
192
+ constructor(
193
+ registry: ConnectorRegistry,
194
+ root: string,
195
+ text: ConnectorMessages,
196
+ ) {
197
+ this.#registry = registry;
198
+ this.#root = root;
199
+ this.#text = text;
200
+ }
201
+
202
+ async autocomplete(interaction: AutocompleteInteraction): Promise<void> {
203
+ const query = interaction.options.getFocused().trim().toLowerCase();
204
+ await interaction.respond(
205
+ this.#registry
206
+ .list()
207
+ .filter((c) => c.name.includes(query))
208
+ .slice(0, 25)
209
+ .map((c) => ({ name: c.name, value: c.name })),
210
+ );
211
+ }
212
+
213
+ /** Every subcommand but `add`, whose form opens before the reply is deferred. */
214
+ async command(interaction: ChatInputCommandInteraction): Promise<void> {
215
+ const sub = interaction.options.getSubcommand(true);
216
+ if (sub === "list") return this.#list(interaction);
217
+ const name = interaction.options.getString("name", true);
218
+ const text = this.#text;
219
+ if (sub === "describe") {
220
+ const connector = await this.#owned(() =>
221
+ this.#registry.describe(
222
+ name,
223
+ interaction.options.getString("description", true),
224
+ ),
225
+ );
226
+ await interaction.editReply(
227
+ ownerPanel({
228
+ title: text.purposeUpdatedTitle,
229
+ sections: [section(connector, text)],
230
+ footer: text.purposeUpdatedFooter,
231
+ }),
232
+ );
233
+ return;
234
+ }
235
+ await this.#owned(() => this.#registry.remove(name));
236
+ await interaction.editReply(
237
+ ownerPanel({
238
+ title: text.removedTitle,
239
+ sections: [text.removed(plain(name))],
240
+ }),
241
+ );
242
+ }
243
+
244
+ async submit(interaction: ModalSubmitInteraction): Promise<void> {
245
+ const text = this.#text;
246
+ const value = (id: string) =>
247
+ interaction.fields.getTextInputValue(id).trim();
248
+ const { connector, skipped } = await this.#owned(() =>
249
+ this.#registry.add({
250
+ name: value(FIELD.name),
251
+ url: value(FIELD.url),
252
+ description: value(FIELD.description),
253
+ auth: authFrom(value(FIELD.header), value(FIELD.token), text),
254
+ }),
255
+ );
256
+ await interaction.editReply(
257
+ ownerPanel({
258
+ title: text.addedTitle,
259
+ sections: [
260
+ section(connector, text),
261
+ ...(skipped.length
262
+ ? [text.skippedTools(skipped.map((t) => `\`${t}\``).join(" "))]
263
+ : []),
264
+ ],
265
+ footer: text.addedFooter,
266
+ }),
267
+ );
268
+ }
269
+
270
+ async #list(interaction: ChatInputCommandInteraction): Promise<void> {
271
+ const text = this.#text;
272
+ const connectors = this.#registry.list();
273
+ await replyWithPanels(interaction, {
274
+ title: text.listTitle,
275
+ sections: connectors.length
276
+ ? connectors.map((c) => section(c, text))
277
+ : [text.listEmpty(this.#root)],
278
+ footer: text.listFooter(this.#root),
279
+ });
280
+ }
281
+
282
+ async #owned<T>(call: () => Promise<T>): Promise<T> {
283
+ try {
284
+ return await call();
285
+ } catch (error) {
286
+ if (error instanceof ConnectorError)
287
+ throw new OwnerFacingError(error.message);
288
+ throw error;
289
+ }
290
+ }
291
+ }
292
+
293
+ /** `/<root> connector …` and the form `add` opens. */
294
+ export function connectorCommands(
295
+ guard: CommandGuard,
296
+ registry: ConnectorRegistry,
297
+ text: ConnectorMessages,
298
+ ): InteractionContribution {
299
+ const commands = new ConnectorCommands(registry, guard.root, text);
300
+ return {
301
+ module: ownerCommandModule(guard, {
302
+ owns: (group) => group === "connector",
303
+ autocomplete: (interaction) => commands.autocomplete(interaction),
304
+ // The form must be the interaction's first answer, before any deferral.
305
+ open: async (interaction) => {
306
+ if (interaction.options.getSubcommand() !== "add") return false;
307
+ await interaction.showModal(connectorModal(text));
308
+ return true;
309
+ },
310
+ command: (interaction) => commands.command(interaction),
311
+ component: async (interaction) => {
312
+ if (
313
+ !interaction.isModalSubmit() ||
314
+ interaction.customId !== CONNECTOR_MODAL_ID
315
+ )
316
+ return false;
317
+ if (!guard.isOwner(interaction)) return true;
318
+ await interaction.deferReply({ flags: MessageFlags.Ephemeral });
319
+ await guard.run(interaction, () => commands.submit(interaction));
320
+ return true;
321
+ },
322
+ }),
323
+ rootOptions: [groupOption(connectorGroup(text))],
324
+ };
325
+ }
@@ -0,0 +1,340 @@
1
+ import type { SQL } from "bun";
2
+ import type { Logger, Migration } from "pi-roundtable";
3
+ import type { VirtualServer } from "pi-roundtable/kit";
4
+ import {
5
+ type ContextForgeAdmin,
6
+ ContextForgeError,
7
+ type UpstreamAuth,
8
+ } from "./contextforge.ts";
9
+ import { CONNECTOR_MESSAGES, type ConnectorMessages } from "./messages.ts";
10
+
11
+ /** Lowercase, short enough that `<name>-<tool>` usually fits the tool name limit. */
12
+ export const CONNECTOR_NAME = /^[a-z][a-z0-9-]{0,11}$/;
13
+ /** The longest tool name kept: Claude allows 64 characters, and a client may add a 19-character prefix. */
14
+ export const DEFAULT_MAX_TOOL_NAME = 45;
15
+ /** What a virtual server's name starts with, so ContextForge's own servers are never taken for a connector's. */
16
+ export const DEFAULT_SERVER_PREFIX = "roundtable-conn-";
17
+
18
+ /** A request the owner can fix; the message is shown to the owner as is. */
19
+ export class ConnectorError extends Error {}
20
+
21
+ export interface Connector {
22
+ name: string;
23
+ url: string;
24
+ description: string;
25
+ gatewayId: string;
26
+ serverId: string;
27
+ createdAt: Date;
28
+ /** The virtual server's endpoint and tools; absent when it could not be read. */
29
+ server?: VirtualServer;
30
+ }
31
+
32
+ export interface NewConnector {
33
+ name: string;
34
+ url: string;
35
+ description: string;
36
+ auth: UpstreamAuth;
37
+ }
38
+
39
+ /** An owner connector as a routing profile source: the general tools plus every tool of its virtual server. */
40
+ export interface ConnectorProfileSource {
41
+ name: string;
42
+ description: string;
43
+ /** The connector's virtual server, which a profile lists among its MCP servers. */
44
+ serverName: string;
45
+ }
46
+
47
+ interface Row {
48
+ name: string;
49
+ url: string;
50
+ description: string;
51
+ gateway_id: string;
52
+ server_id: string;
53
+ created_at: Date;
54
+ }
55
+
56
+ export interface ConnectorRegistryOptions {
57
+ admin: Pick<
58
+ ContextForgeAdmin,
59
+ | "createGateway"
60
+ | "tools"
61
+ | "createServer"
62
+ | "deleteServer"
63
+ | "deleteGateway"
64
+ >;
65
+ /** Reads a virtual server's endpoint and tools by name. */
66
+ resolve: (serverName: string) => Promise<VirtualServer>;
67
+ logger: Logger;
68
+ /** The start of every connector's virtual server name; default `roundtable-conn-`. */
69
+ serverPrefix?: string;
70
+ /** Tools whose names are longer are left out of a connector's server; default 45. */
71
+ maxToolName?: number;
72
+ messages?: ConnectorMessages;
73
+ }
74
+
75
+ /**
76
+ * The owner's MCP connectors. ContextForge holds each upstream server and its token; the
77
+ * `owner_connectors` table holds what the app needs to route to it. `version` changes with
78
+ * every add, change, or removal, so a host knows to pick up the new tool set.
79
+ */
80
+ export class ConnectorRegistry {
81
+ readonly #sql: SQL;
82
+ readonly #options: ConnectorRegistryOptions;
83
+ readonly #text: ConnectorMessages;
84
+ readonly #connectors = new Map<string, Connector>();
85
+ #version = 0;
86
+
87
+ private constructor(sql: SQL, options: ConnectorRegistryOptions) {
88
+ this.#sql = sql;
89
+ this.#options = options;
90
+ this.#text = options.messages ?? CONNECTOR_MESSAGES;
91
+ }
92
+
93
+ /** The registry's table; the host runs this before the registry attaches. */
94
+ static readonly migration: Migration = {
95
+ name: "owner-connectors",
96
+ up: async (sql) => {
97
+ await sql`
98
+ CREATE TABLE IF NOT EXISTS owner_connectors (
99
+ name text PRIMARY KEY,
100
+ url text NOT NULL,
101
+ description text NOT NULL,
102
+ gateway_id text NOT NULL,
103
+ server_id text NOT NULL,
104
+ created_at timestamptz NOT NULL DEFAULT now()
105
+ )`;
106
+ },
107
+ };
108
+
109
+ /** The registry over the host's migrated pool, with each connector's server resolved. */
110
+ static async attach(
111
+ sql: SQL,
112
+ options: ConnectorRegistryOptions,
113
+ ): Promise<ConnectorRegistry> {
114
+ const registry = new ConnectorRegistry(sql, options);
115
+ const rows: Row[] = await sql`SELECT * FROM owner_connectors ORDER BY name`;
116
+ for (const row of rows) {
117
+ const connector = fromRow(row);
118
+ // One unreachable connector must not keep the host from starting.
119
+ try {
120
+ connector.server = await options.resolve(registry.serverName(row.name));
121
+ } catch (error) {
122
+ options.logger.error(
123
+ { connector: row.name, err: error },
124
+ "connector could not be read; its profile is off",
125
+ );
126
+ }
127
+ registry.#connectors.set(row.name, connector);
128
+ }
129
+ return registry;
130
+ }
131
+
132
+ /** The name of the connector's virtual server in ContextForge. */
133
+ serverName(name: string): string {
134
+ return `${this.#options.serverPrefix ?? DEFAULT_SERVER_PREFIX}${name}`;
135
+ }
136
+
137
+ get version(): number {
138
+ return this.#version;
139
+ }
140
+
141
+ list(): Connector[] {
142
+ return [...this.#connectors.values()];
143
+ }
144
+
145
+ /** Virtual servers of the connectors whose tools are known. */
146
+ servers(): VirtualServer[] {
147
+ return this.list().flatMap((c) => (c.server ? [c.server] : []));
148
+ }
149
+
150
+ profileSources(): ConnectorProfileSource[] {
151
+ return this.list().flatMap((c) =>
152
+ c.server
153
+ ? [
154
+ {
155
+ name: c.name,
156
+ description: c.description,
157
+ serverName: c.server.name,
158
+ },
159
+ ]
160
+ : [],
161
+ );
162
+ }
163
+
164
+ /**
165
+ * Registers the server in ContextForge, exposes its usable tools on a virtual server, and
166
+ * records the connector. Anything half made is removed again when a later step fails.
167
+ */
168
+ async add(
169
+ input: NewConnector,
170
+ ): Promise<{ connector: Connector; skipped: string[] }> {
171
+ const name = await this.#checked(input);
172
+ const { admin } = this.#options;
173
+ const gateway = await this.#contextForge(() =>
174
+ admin.createGateway({
175
+ name,
176
+ url: input.url,
177
+ description: input.description,
178
+ auth: input.auth,
179
+ }),
180
+ );
181
+ const made: { serverId?: string } = {};
182
+ try {
183
+ return await this.#publish(name, input, gateway, made);
184
+ } catch (error) {
185
+ await this.#removeFromContextForge(name, gateway.id, made.serverId);
186
+ throw error;
187
+ }
188
+ }
189
+
190
+ async describe(name: string, description: string): Promise<Connector> {
191
+ const connector = this.#get(name);
192
+ const text = description.trim();
193
+ if (!text) throw new ConnectorError(this.#text.purposeEmpty);
194
+ await this
195
+ .#sql`UPDATE owner_connectors SET description = ${text} WHERE name = ${name}`;
196
+ connector.description = text;
197
+ this.#version += 1;
198
+ return connector;
199
+ }
200
+
201
+ /** Deletes the connector, its virtual server, and the upstream server with its token. */
202
+ async remove(name: string): Promise<void> {
203
+ const connector = this.#get(name);
204
+ await this.#sql`DELETE FROM owner_connectors WHERE name = ${name}`;
205
+ this.#connectors.delete(name);
206
+ this.#version += 1;
207
+ await this.#removeFromContextForge(
208
+ name,
209
+ connector.gatewayId,
210
+ connector.serverId,
211
+ );
212
+ this.#options.logger.info({ connector: name }, "connector removed");
213
+ }
214
+
215
+ /** The normalized name, once the input and the name are known to be acceptable. */
216
+ async #checked(input: NewConnector): Promise<string> {
217
+ const name = input.name.trim().toLowerCase();
218
+ if (!CONNECTOR_NAME.test(name))
219
+ throw new ConnectorError(this.#text.nameRule);
220
+ if (!/^https?:\/\//.test(input.url) || !URL.canParse(input.url))
221
+ throw new ConnectorError(this.#text.urlRule);
222
+ if (!input.description.trim())
223
+ throw new ConnectorError(this.#text.purposeRequired);
224
+ const taken = new Set([
225
+ ...this.#connectors.keys(),
226
+ ...(await this.#options.admin.tools()).map((tool) => tool.gatewaySlug),
227
+ ]);
228
+ if (taken.has(name)) throw new ConnectorError(this.#text.nameTaken(name));
229
+ return name;
230
+ }
231
+
232
+ /** Builds the virtual server over the gateway's usable tools and records the connector. */
233
+ async #publish(
234
+ name: string,
235
+ input: NewConnector,
236
+ gateway: { id: string; slug: string },
237
+ made: { serverId?: string },
238
+ ): Promise<{ connector: Connector; skipped: string[] }> {
239
+ const {
240
+ admin,
241
+ resolve,
242
+ logger,
243
+ maxToolName = DEFAULT_MAX_TOOL_NAME,
244
+ } = this.#options;
245
+ const tools = (await admin.tools()).filter(
246
+ (tool) => tool.gatewaySlug === gateway.slug,
247
+ );
248
+ const usable = tools.filter((tool) => tool.name.length <= maxToolName);
249
+ const skipped = tools
250
+ .filter((tool) => tool.name.length > maxToolName)
251
+ .map((tool) => tool.name);
252
+ if (usable.length === 0)
253
+ throw new ConnectorError(
254
+ tools.length === 0
255
+ ? this.#text.noTools
256
+ : this.#text.toolsTooLong(maxToolName, skipped),
257
+ );
258
+ const serverId = await this.#contextForge(() =>
259
+ admin.createServer(
260
+ this.serverName(name),
261
+ `Connector ${name}: ${input.description}`,
262
+ usable.map((tool) => tool.id),
263
+ ),
264
+ );
265
+ made.serverId = serverId;
266
+ const server = await resolve(this.serverName(name));
267
+ const rows: Row[] = await this.#sql`
268
+ INSERT INTO owner_connectors (name, url, description, gateway_id, server_id)
269
+ VALUES (${name}, ${input.url}, ${input.description.trim()}, ${gateway.id}, ${serverId})
270
+ RETURNING *`;
271
+ const row = rows[0];
272
+ if (!row) throw new Error("connector row was not written");
273
+ const connector = { ...fromRow(row), server };
274
+ this.#connectors.set(name, connector);
275
+ this.#version += 1;
276
+ logger.info(
277
+ { connector: name, tools: server.tools.length, skipped },
278
+ "connector added",
279
+ );
280
+ return { connector, skipped };
281
+ }
282
+
283
+ #get(name: string): Connector {
284
+ const connector = this.#connectors.get(name);
285
+ if (!connector) throw new ConnectorError(this.#text.unknownConnector(name));
286
+ return connector;
287
+ }
288
+
289
+ /** ContextForge's refusals become messages the owner can act on. */
290
+ async #contextForge<T>(call: () => Promise<T>): Promise<T> {
291
+ try {
292
+ return await call();
293
+ } catch (error) {
294
+ if (error instanceof ContextForgeError)
295
+ throw new ConnectorError(error.message);
296
+ throw error;
297
+ }
298
+ }
299
+
300
+ /** Best effort: a leftover is logged, since ContextForge's admin UI can still remove it. */
301
+ async #removeFromContextForge(
302
+ name: string,
303
+ gatewayId: string,
304
+ serverId: string | undefined,
305
+ ): Promise<void> {
306
+ const { admin, logger } = this.#options;
307
+ const steps: [string, () => Promise<void>][] = [
308
+ ...(serverId
309
+ ? [
310
+ ["server", () => admin.deleteServer(serverId)] as [
311
+ string,
312
+ () => Promise<void>,
313
+ ],
314
+ ]
315
+ : []),
316
+ ["gateway", () => admin.deleteGateway(gatewayId)],
317
+ ];
318
+ for (const [what, step] of steps) {
319
+ try {
320
+ await step();
321
+ } catch (error) {
322
+ logger.error(
323
+ { connector: name, what, err: error },
324
+ "connector leftover in ContextForge",
325
+ );
326
+ }
327
+ }
328
+ }
329
+ }
330
+
331
+ function fromRow(row: Row): Connector {
332
+ return {
333
+ name: row.name,
334
+ url: row.url,
335
+ description: row.description,
336
+ gatewayId: row.gateway_id,
337
+ serverId: row.server_id,
338
+ createdAt: row.created_at,
339
+ };
340
+ }