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.
- package/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +273 -0
- package/examples/connector-profiles.ts +36 -0
- package/examples/host-conversation.ts +22 -0
- package/examples/roundtable.config.ts +33 -0
- package/package.json +50 -0
- package/src/connectors/connector-commands.ts +325 -0
- package/src/connectors/connector-registry.ts +340 -0
- package/src/connectors/connectors-plugin.ts +114 -0
- package/src/connectors/contextforge.ts +371 -0
- package/src/connectors/messages.ts +119 -0
- package/src/index.ts +23 -0
- package/src/remote-mcp/channel-grants.ts +308 -0
- package/src/remote-mcp/channel-tools.ts +53 -0
- package/src/remote-mcp/default-conversation.ts +75 -0
- package/src/remote-mcp/mcp-gateway.ts +345 -0
- package/src/remote-mcp/mcp-grant-commands.ts +100 -0
- package/src/remote-mcp/mcp-grant-flow.ts +348 -0
- package/src/remote-mcp/mcp-grant-panels.ts +174 -0
- package/src/remote-mcp/messages.ts +195 -0
- package/src/remote-mcp/remote-agent.ts +127 -0
- package/src/remote-mcp/remote-claim.ts +51 -0
- package/src/remote-mcp/remote-mcp-plugin.ts +148 -0
- package/src/remote-mcp/remote-session-store.ts +55 -0
- package/src/remote-mcp/session-sweeper.ts +78 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
4
|
+
import {
|
|
5
|
+
CallToolRequestSchema,
|
|
6
|
+
ListToolsRequestSchema,
|
|
7
|
+
type Tool,
|
|
8
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
9
|
+
import type { HttpRoute, Logger } from "pi-roundtable";
|
|
10
|
+
import {
|
|
11
|
+
CHANNEL_TOOLS,
|
|
12
|
+
type ChannelExecutor,
|
|
13
|
+
ChannelToolError,
|
|
14
|
+
parseChannelTool,
|
|
15
|
+
} from "pi-roundtable/discord";
|
|
16
|
+
import { type TSchema, Type } from "typebox";
|
|
17
|
+
import Value from "typebox/value";
|
|
18
|
+
import packageJson from "../../package.json" with { type: "json" };
|
|
19
|
+
import {
|
|
20
|
+
CHANNEL_TOKEN_PATTERN,
|
|
21
|
+
type ChannelBundle,
|
|
22
|
+
type ChannelGrantStore,
|
|
23
|
+
hashChannelToken,
|
|
24
|
+
} from "./channel-grants.ts";
|
|
25
|
+
import { runGrantedTool } from "./channel-tools.ts";
|
|
26
|
+
import { REMOTE_MCP_MESSAGES, type RemoteMcpMessages } from "./messages.ts";
|
|
27
|
+
import { type RemoteAgent, RemoteAgentError } from "./remote-agent.ts";
|
|
28
|
+
|
|
29
|
+
/** Requests may carry uploads; anything larger is refused before it is parsed. */
|
|
30
|
+
const MAX_BODY_BYTES = 12 * 1024 * 1024;
|
|
31
|
+
|
|
32
|
+
export const DISPATCH_TOOL = "agent_dispatch";
|
|
33
|
+
export const RESULT_TOOL = "agent_result";
|
|
34
|
+
const LIST_CHANNELS_TOOL = "discord_list_authorized_channels";
|
|
35
|
+
|
|
36
|
+
export interface McpGatewayOptions {
|
|
37
|
+
/** Bearer token for /mcp/personal. */
|
|
38
|
+
dispatchToken: string;
|
|
39
|
+
agent: Pick<RemoteAgent, "dispatch" | "result">;
|
|
40
|
+
grants: ChannelGrantStore;
|
|
41
|
+
/** Undefined until the Discord connection is ready. */
|
|
42
|
+
executor(): ChannelExecutor | undefined;
|
|
43
|
+
logger: Logger;
|
|
44
|
+
messages?: RemoteMcpMessages;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface ToolSpec {
|
|
48
|
+
tool: Tool;
|
|
49
|
+
call(args: Record<string, unknown>): Promise<unknown>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The round trip drops TypeBox's symbol keys and leaves plain JSON Schema.
|
|
53
|
+
const jsonSchema = (schema: Readonly<TSchema>): Tool["inputSchema"] =>
|
|
54
|
+
JSON.parse(JSON.stringify(schema));
|
|
55
|
+
|
|
56
|
+
const textResult = (value: unknown, isError = false) => ({
|
|
57
|
+
content: [{ type: "text" as const, text: JSON.stringify(value ?? {}) }],
|
|
58
|
+
...(isError ? { isError: true } : {}),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
class BadRequest extends Error {}
|
|
62
|
+
|
|
63
|
+
const ResultInput = Type.Object(
|
|
64
|
+
{ runId: Type.String({ minLength: 1 }) },
|
|
65
|
+
{ additionalProperties: false },
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The owner's MCP endpoints for outside agents, on a listener the public hostname reaches:
|
|
70
|
+
* /mcp/personal relays turns to the owner's agent, /mcp/discord/<token> exposes one bundle's
|
|
71
|
+
* granted channels. Each request gets a fresh stateless MCP server.
|
|
72
|
+
*/
|
|
73
|
+
export class McpGateway {
|
|
74
|
+
readonly #options: McpGatewayOptions;
|
|
75
|
+
readonly #text: RemoteMcpMessages;
|
|
76
|
+
readonly #dispatchInput: TSchema;
|
|
77
|
+
|
|
78
|
+
constructor(options: McpGatewayOptions) {
|
|
79
|
+
this.#options = options;
|
|
80
|
+
this.#text = options.messages ?? REMOTE_MCP_MESSAGES;
|
|
81
|
+
this.#dispatchInput = Type.Object(
|
|
82
|
+
{
|
|
83
|
+
message: Type.String({ minLength: 1 }),
|
|
84
|
+
sessionId: Type.Optional(
|
|
85
|
+
Type.String({ description: this.#text.sessionIdDescription }),
|
|
86
|
+
),
|
|
87
|
+
},
|
|
88
|
+
{ additionalProperties: false },
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Both endpoints take every method and answer the ones they refuse themselves. */
|
|
93
|
+
routes(listener: string): HttpRoute[] {
|
|
94
|
+
const handle = (request: Request) => this.handle(request);
|
|
95
|
+
return [
|
|
96
|
+
{
|
|
97
|
+
name: "mcp-personal",
|
|
98
|
+
listener,
|
|
99
|
+
path: { exact: "/mcp/personal" },
|
|
100
|
+
handle,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: "mcp-discord",
|
|
104
|
+
listener,
|
|
105
|
+
path: { prefix: "/mcp/discord/" },
|
|
106
|
+
handle,
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Never throws; error responses never repeat the URL, which may hold a token. */
|
|
112
|
+
async handle(request: Request): Promise<Response> {
|
|
113
|
+
const path = new URL(request.url).pathname;
|
|
114
|
+
const discord = path.startsWith("/mcp/discord/");
|
|
115
|
+
if (path !== "/mcp/personal" && !discord)
|
|
116
|
+
return new Response("Not found", { status: 404 });
|
|
117
|
+
if (request.headers.get("Origin"))
|
|
118
|
+
return new Response("Forbidden origin", { status: 403 });
|
|
119
|
+
if (request.method !== "POST")
|
|
120
|
+
return new Response("Method not allowed", {
|
|
121
|
+
status: 405,
|
|
122
|
+
headers: { Allow: "POST" },
|
|
123
|
+
});
|
|
124
|
+
try {
|
|
125
|
+
const tools = discord
|
|
126
|
+
? await this.#toolsForToken(path.slice("/mcp/discord/".length))
|
|
127
|
+
: this.#personalTools(request);
|
|
128
|
+
if (!tools) return new Response("Unauthorized", { status: 401 });
|
|
129
|
+
const response = await serveMcp(
|
|
130
|
+
await boundedBody(request),
|
|
131
|
+
discord ? "roundtable-discord-channel" : "roundtable-personal-agent",
|
|
132
|
+
tools,
|
|
133
|
+
this.#text,
|
|
134
|
+
);
|
|
135
|
+
response.headers.set("Cache-Control", "no-store");
|
|
136
|
+
response.headers.set("Referrer-Policy", "no-referrer");
|
|
137
|
+
return response;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (error instanceof BadRequest)
|
|
140
|
+
return new Response("Request too large", { status: 413 });
|
|
141
|
+
this.#options.logger.error(
|
|
142
|
+
{ endpoint: discord ? "discord" : "personal", err: error },
|
|
143
|
+
"mcp request failed",
|
|
144
|
+
);
|
|
145
|
+
return new Response("Unavailable", { status: 503 });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async #toolsForToken(token: string): Promise<ToolSpec[] | undefined> {
|
|
150
|
+
if (!CHANNEL_TOKEN_PATTERN.test(token)) return undefined;
|
|
151
|
+
const bundle = await this.#options.grants.bundleByTokenHash(
|
|
152
|
+
hashChannelToken(token),
|
|
153
|
+
);
|
|
154
|
+
return bundle && this.#channelTools(bundle);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The relay tools for a request that carries the dispatch token; undefined otherwise. */
|
|
158
|
+
#personalTools(request: Request): ToolSpec[] | undefined {
|
|
159
|
+
return this.#bearerMatches(request) ? this.#dispatchTools() : undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#bearerMatches(request: Request): boolean {
|
|
163
|
+
const header = request.headers.get("Authorization") ?? "";
|
|
164
|
+
if (!header.startsWith("Bearer ")) return false;
|
|
165
|
+
const given = Buffer.from(header.slice("Bearer ".length));
|
|
166
|
+
const expected = Buffer.from(this.#options.dispatchToken);
|
|
167
|
+
return given.length === expected.length && timingSafeEqual(given, expected);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
#dispatchTools(): ToolSpec[] {
|
|
171
|
+
const { agent } = this.#options;
|
|
172
|
+
return [
|
|
173
|
+
{
|
|
174
|
+
tool: {
|
|
175
|
+
name: DISPATCH_TOOL,
|
|
176
|
+
description: this.#text.dispatchDescription,
|
|
177
|
+
inputSchema: jsonSchema(this.#dispatchInput),
|
|
178
|
+
},
|
|
179
|
+
call: async (args) => {
|
|
180
|
+
if (!Value.Check(this.#dispatchInput, args))
|
|
181
|
+
return textResult({ error: "INVALID_ARGUMENTS" }, true);
|
|
182
|
+
const input = args as { message: string; sessionId?: string };
|
|
183
|
+
return textResult(
|
|
184
|
+
await agent.dispatch(input.message, input.sessionId),
|
|
185
|
+
);
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
tool: {
|
|
190
|
+
name: RESULT_TOOL,
|
|
191
|
+
description: this.#text.resultDescription,
|
|
192
|
+
inputSchema: jsonSchema(ResultInput),
|
|
193
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
194
|
+
},
|
|
195
|
+
call: async (args) => {
|
|
196
|
+
if (!Value.Check(ResultInput, args))
|
|
197
|
+
return textResult({ error: "INVALID_ARGUMENTS" }, true);
|
|
198
|
+
return textResult(agent.result(String(args.runId)));
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async #channelTools(bundle: ChannelBundle): Promise<ToolSpec[]> {
|
|
205
|
+
const { grants } = this.#options;
|
|
206
|
+
const executor = () => {
|
|
207
|
+
const current = this.#options.executor();
|
|
208
|
+
if (!current) throw new ChannelToolError("DISCORD_UNAVAILABLE");
|
|
209
|
+
return current;
|
|
210
|
+
};
|
|
211
|
+
const granted = new Set(
|
|
212
|
+
(await grants.grants(bundle.id)).flatMap((grant) => grant.operations),
|
|
213
|
+
);
|
|
214
|
+
const tools: ToolSpec[] = [this.#listChannelsTool(bundle, executor)];
|
|
215
|
+
for (const [name, spec] of Object.entries(CHANNEL_TOOLS)) {
|
|
216
|
+
if (!granted.has(spec.operation)) continue;
|
|
217
|
+
const read = spec.operation === "read";
|
|
218
|
+
tools.push({
|
|
219
|
+
tool: {
|
|
220
|
+
name,
|
|
221
|
+
description: `${spec.description}${this.#text.channelToolNote(bundle.name)}`,
|
|
222
|
+
inputSchema: jsonSchema(spec.schema),
|
|
223
|
+
annotations: {
|
|
224
|
+
readOnlyHint: read,
|
|
225
|
+
destructiveHint: !["read", "pin"].includes(spec.operation),
|
|
226
|
+
// Writes may upload files or notify people; only reads are safe to repeat.
|
|
227
|
+
idempotentHint: read,
|
|
228
|
+
openWorldHint: true,
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
call: async (args) =>
|
|
232
|
+
textResult(
|
|
233
|
+
await runGrantedTool(
|
|
234
|
+
bundle,
|
|
235
|
+
name,
|
|
236
|
+
parseChannelTool(name, args),
|
|
237
|
+
grants,
|
|
238
|
+
executor(),
|
|
239
|
+
),
|
|
240
|
+
),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
return tools;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The bundle's channels with their names and operations; always offered. */
|
|
247
|
+
#listChannelsTool(
|
|
248
|
+
bundle: ChannelBundle,
|
|
249
|
+
executor: () => ChannelExecutor,
|
|
250
|
+
): ToolSpec {
|
|
251
|
+
const { grants } = this.#options;
|
|
252
|
+
return {
|
|
253
|
+
tool: {
|
|
254
|
+
name: LIST_CHANNELS_TOOL,
|
|
255
|
+
description: this.#text.listChannelsDescription,
|
|
256
|
+
inputSchema: { type: "object", properties: {} },
|
|
257
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
258
|
+
},
|
|
259
|
+
call: async () => {
|
|
260
|
+
const current = await grants.bundleByTokenHash(bundle.tokenHash);
|
|
261
|
+
if (current?.id !== bundle.id)
|
|
262
|
+
return textResult({ error: "ENDPOINT_REVOKED" }, true);
|
|
263
|
+
const run = executor();
|
|
264
|
+
const channels = await Promise.all(
|
|
265
|
+
(await grants.grants(bundle.id)).map(async (grant) => {
|
|
266
|
+
const names = await run.names(grant.channelId);
|
|
267
|
+
return {
|
|
268
|
+
channelId: grant.channelId,
|
|
269
|
+
guildId: grant.guildId,
|
|
270
|
+
name: grant.displayName,
|
|
271
|
+
description: grant.description,
|
|
272
|
+
guildName: names?.guildName ?? grant.guildName,
|
|
273
|
+
channelName: names?.channelName ?? grant.channelName,
|
|
274
|
+
operations: grant.operations,
|
|
275
|
+
accessible: names !== undefined,
|
|
276
|
+
};
|
|
277
|
+
}),
|
|
278
|
+
);
|
|
279
|
+
return textResult({ bundle: bundle.name, channels });
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function serveMcp(
|
|
286
|
+
request: Request,
|
|
287
|
+
name: string,
|
|
288
|
+
tools: ToolSpec[],
|
|
289
|
+
text: RemoteMcpMessages,
|
|
290
|
+
): Promise<Response> {
|
|
291
|
+
const server = new McpServer(
|
|
292
|
+
{ name, version: packageJson.version },
|
|
293
|
+
{ capabilities: { tools: {} } },
|
|
294
|
+
);
|
|
295
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
296
|
+
tools: tools.map((spec) => spec.tool),
|
|
297
|
+
}));
|
|
298
|
+
server.setRequestHandler(CallToolRequestSchema, async (call) => {
|
|
299
|
+
const spec = tools.find((t) => t.tool.name === call.params.name);
|
|
300
|
+
if (!spec) return textResult({ error: "UNKNOWN_TOOL" }, true);
|
|
301
|
+
try {
|
|
302
|
+
return (await spec.call(call.params.arguments ?? {})) as ReturnType<
|
|
303
|
+
typeof textResult
|
|
304
|
+
>;
|
|
305
|
+
} catch (error) {
|
|
306
|
+
if (
|
|
307
|
+
error instanceof ChannelToolError ||
|
|
308
|
+
error instanceof RemoteAgentError
|
|
309
|
+
)
|
|
310
|
+
return textResult({ error: error.code }, true);
|
|
311
|
+
return textResult({ error: text.operationUnfinished }, true);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
315
|
+
sessionIdGenerator: undefined,
|
|
316
|
+
enableJsonResponse: true,
|
|
317
|
+
});
|
|
318
|
+
await server.connect(transport);
|
|
319
|
+
try {
|
|
320
|
+
return await transport.handleRequest(request);
|
|
321
|
+
} finally {
|
|
322
|
+
await server.close();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** The request with its body read to memory, refusing more than MAX_BODY_BYTES. */
|
|
327
|
+
async function boundedBody(request: Request): Promise<Request> {
|
|
328
|
+
if (Number(request.headers.get("Content-Length")) > MAX_BODY_BYTES)
|
|
329
|
+
throw new BadRequest();
|
|
330
|
+
const reader = request.body?.getReader();
|
|
331
|
+
if (!reader) return request;
|
|
332
|
+
const chunks: Uint8Array[] = [];
|
|
333
|
+
let size = 0;
|
|
334
|
+
for (;;) {
|
|
335
|
+
const { value, done } = await reader.read();
|
|
336
|
+
if (done) break;
|
|
337
|
+
size += value.byteLength;
|
|
338
|
+
if (size > MAX_BODY_BYTES) {
|
|
339
|
+
await reader.cancel();
|
|
340
|
+
throw new BadRequest();
|
|
341
|
+
}
|
|
342
|
+
chunks.push(value);
|
|
343
|
+
}
|
|
344
|
+
return new Request(request, { body: Buffer.concat(chunks, size) });
|
|
345
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SlashCommandStringOption,
|
|
3
|
+
SlashCommandSubcommandGroupBuilder,
|
|
4
|
+
} from "discord.js";
|
|
5
|
+
import {
|
|
6
|
+
type CommandGuard,
|
|
7
|
+
groupOption,
|
|
8
|
+
type InteractionContribution,
|
|
9
|
+
ownerCommandModule,
|
|
10
|
+
} from "pi-roundtable/discord";
|
|
11
|
+
import type { ChannelGrantStore } from "./channel-grants.ts";
|
|
12
|
+
import { McpGrantFlow } from "./mcp-grant-flow.ts";
|
|
13
|
+
import { GRANT_PREFIX } from "./mcp-grant-panels.ts";
|
|
14
|
+
import type { RemoteMcpMessages } from "./messages.ts";
|
|
15
|
+
|
|
16
|
+
function mcpGroup(text: RemoteMcpMessages) {
|
|
17
|
+
const bundle = (option: SlashCommandStringOption) =>
|
|
18
|
+
option
|
|
19
|
+
.setName("bundle")
|
|
20
|
+
.setDescription(text.bundleOption)
|
|
21
|
+
.setMaxLength(80)
|
|
22
|
+
.setAutocomplete(true)
|
|
23
|
+
.setRequired(true);
|
|
24
|
+
const agentName = (option: SlashCommandStringOption) =>
|
|
25
|
+
option
|
|
26
|
+
.setName("name")
|
|
27
|
+
.setDescription(text.agentNameOption)
|
|
28
|
+
.setMaxLength(100);
|
|
29
|
+
const purpose = (option: SlashCommandStringOption) =>
|
|
30
|
+
option
|
|
31
|
+
.setName("description")
|
|
32
|
+
.setDescription(text.purposeOption)
|
|
33
|
+
.setMaxLength(1000);
|
|
34
|
+
const channelId = (option: SlashCommandStringOption) =>
|
|
35
|
+
option.setName("channel_id").setDescription(text.channelIdOption);
|
|
36
|
+
return (group: SlashCommandSubcommandGroupBuilder) =>
|
|
37
|
+
group
|
|
38
|
+
.setName("mcp")
|
|
39
|
+
.setDescription(text.groupDescription)
|
|
40
|
+
.addSubcommand((sub) =>
|
|
41
|
+
sub
|
|
42
|
+
.setName("authorize")
|
|
43
|
+
.setDescription(text.authorizeDescription)
|
|
44
|
+
.addStringOption(bundle)
|
|
45
|
+
.addStringOption(agentName)
|
|
46
|
+
.addStringOption(purpose),
|
|
47
|
+
)
|
|
48
|
+
.addSubcommand((sub) =>
|
|
49
|
+
sub.setName("grants").setDescription(text.grantsDescription),
|
|
50
|
+
)
|
|
51
|
+
.addSubcommand((sub) =>
|
|
52
|
+
sub
|
|
53
|
+
.setName("revoke")
|
|
54
|
+
.setDescription(text.revokeDescription)
|
|
55
|
+
.addStringOption(bundle)
|
|
56
|
+
.addStringOption(channelId),
|
|
57
|
+
)
|
|
58
|
+
.addSubcommand((sub) =>
|
|
59
|
+
sub
|
|
60
|
+
.setName("describe")
|
|
61
|
+
.setDescription(text.describeDescription)
|
|
62
|
+
.addStringOption(bundle)
|
|
63
|
+
.addStringOption(agentName)
|
|
64
|
+
.addStringOption(purpose)
|
|
65
|
+
.addStringOption(channelId),
|
|
66
|
+
)
|
|
67
|
+
.addSubcommand((sub) =>
|
|
68
|
+
sub
|
|
69
|
+
.setName("token")
|
|
70
|
+
.setDescription(text.tokenDescription)
|
|
71
|
+
.addStringOption(bundle),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `/<root> mcp …` and the buttons and menus its answers post. */
|
|
76
|
+
export function mcpGrantCommands(
|
|
77
|
+
guard: CommandGuard,
|
|
78
|
+
grants: ChannelGrantStore,
|
|
79
|
+
publicUrl: string,
|
|
80
|
+
text: RemoteMcpMessages,
|
|
81
|
+
): InteractionContribution {
|
|
82
|
+
const flow = new McpGrantFlow(grants, publicUrl, guard.root, text);
|
|
83
|
+
return {
|
|
84
|
+
module: ownerCommandModule(guard, {
|
|
85
|
+
owns: (group) => group === "mcp",
|
|
86
|
+
autocomplete: (interaction) => flow.autocomplete(interaction),
|
|
87
|
+
command: (interaction) => flow.command(interaction),
|
|
88
|
+
component: async (interaction) => {
|
|
89
|
+
if (
|
|
90
|
+
!(interaction.isButton() || interaction.isStringSelectMenu()) ||
|
|
91
|
+
!interaction.customId.startsWith(GRANT_PREFIX)
|
|
92
|
+
)
|
|
93
|
+
return false;
|
|
94
|
+
await guard.run(interaction, () => flow.component(interaction));
|
|
95
|
+
return true;
|
|
96
|
+
},
|
|
97
|
+
}),
|
|
98
|
+
rootOptions: [groupOption(mcpGroup(text))],
|
|
99
|
+
};
|
|
100
|
+
}
|