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,114 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConfigError,
|
|
3
|
+
definePlugin,
|
|
4
|
+
type RoundtablePlugin,
|
|
5
|
+
serviceKey,
|
|
6
|
+
} from "pi-roundtable";
|
|
7
|
+
import { DISCORD } from "pi-roundtable/discord";
|
|
8
|
+
import type { VirtualServer } from "pi-roundtable/kit";
|
|
9
|
+
import { connectorCommands } from "./connector-commands.ts";
|
|
10
|
+
import {
|
|
11
|
+
type Connector,
|
|
12
|
+
type ConnectorProfileSource,
|
|
13
|
+
ConnectorRegistry,
|
|
14
|
+
DEFAULT_MAX_TOOL_NAME,
|
|
15
|
+
DEFAULT_SERVER_PREFIX,
|
|
16
|
+
} from "./connector-registry.ts";
|
|
17
|
+
import {
|
|
18
|
+
ContextForgeAdmin,
|
|
19
|
+
contextForgeToken,
|
|
20
|
+
resolveVirtualServer,
|
|
21
|
+
} from "./contextforge.ts";
|
|
22
|
+
import { type ConnectorMessages, connectorMessages } from "./messages.ts";
|
|
23
|
+
|
|
24
|
+
/** One token per process, valid for a year; every restart mints a fresh one. */
|
|
25
|
+
const TOKEN_TTL_SECONDS = 365 * 24 * 3600;
|
|
26
|
+
|
|
27
|
+
export interface ContextForgeOptions {
|
|
28
|
+
/** The gateway's base URL, such as `http://localhost:4444`. */
|
|
29
|
+
url: string;
|
|
30
|
+
/** The secret ContextForge signs and verifies its JWTs with. */
|
|
31
|
+
jwtSecret: string;
|
|
32
|
+
/** The admin user the plugin acts as, an email address such as `admin@example.com`. */
|
|
33
|
+
user: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface McpConnectorsOptions {
|
|
37
|
+
contextForge: ContextForgeOptions;
|
|
38
|
+
/** The start of every connector's virtual server name in ContextForge; default `roundtable-conn-`. */
|
|
39
|
+
serverPrefix?: string;
|
|
40
|
+
/** Tools whose names are longer are left out of a connector's server; default 45. */
|
|
41
|
+
maxToolName?: number;
|
|
42
|
+
/** The Discord text and the registry's refusals, in your wording; English by default. */
|
|
43
|
+
messages?: Partial<ConnectorMessages>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What a host reads to give its agents the owner's connectors. */
|
|
47
|
+
export interface Connectors {
|
|
48
|
+
/** Changes on every add, description change, and removal, so a host knows its tool sets are stale. */
|
|
49
|
+
readonly version: number;
|
|
50
|
+
list(): Connector[];
|
|
51
|
+
/** Virtual servers of the connectors whose tools are known. */
|
|
52
|
+
servers(): VirtualServer[];
|
|
53
|
+
/** Each connector as the profile source a host builds a per-agent MCP profile from. */
|
|
54
|
+
profileSources(): ConnectorProfileSource[];
|
|
55
|
+
/** The bearer token that ContextForge's virtual server URLs expect; one per process. */
|
|
56
|
+
readonly token: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const CONNECTORS = serviceKey<Connectors>(
|
|
60
|
+
"pi-roundtable-mcp.connectors",
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
function checkOptions({ contextForge }: McpConnectorsOptions): string {
|
|
64
|
+
const base = URL.parse(contextForge.url);
|
|
65
|
+
if (base?.protocol !== "http:" && base?.protocol !== "https:")
|
|
66
|
+
throw new ConfigError(
|
|
67
|
+
"mcp-connectors: contextForge.url must be an http(s) URL",
|
|
68
|
+
);
|
|
69
|
+
if (!contextForge.jwtSecret)
|
|
70
|
+
throw new ConfigError("mcp-connectors: contextForge.jwtSecret is empty");
|
|
71
|
+
if (!contextForge.user)
|
|
72
|
+
throw new ConfigError("mcp-connectors: contextForge.user is empty");
|
|
73
|
+
return contextForge.url.replace(/\/+$/, "");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The owner's MCP connectors: ContextForge holds each upstream server and its token, a table
|
|
78
|
+
* holds what the host needs to route to it, and `/<root> connector` adds and removes them.
|
|
79
|
+
* Provides `CONNECTORS`.
|
|
80
|
+
*/
|
|
81
|
+
export function mcpConnectors(options: McpConnectorsOptions): RoundtablePlugin {
|
|
82
|
+
const url = checkOptions(options);
|
|
83
|
+
const text = connectorMessages(options.messages);
|
|
84
|
+
return definePlugin({
|
|
85
|
+
name: "mcp-connectors",
|
|
86
|
+
requires: [DISCORD],
|
|
87
|
+
provides: [CONNECTORS],
|
|
88
|
+
migrations: [ConnectorRegistry.migration],
|
|
89
|
+
setup: async ({ database, services, logger }) => {
|
|
90
|
+
const { jwtSecret, user } = options.contextForge;
|
|
91
|
+
const token = contextForgeToken(jwtSecret, user, TOKEN_TTL_SECONDS);
|
|
92
|
+
const registry = await ConnectorRegistry.attach(database(), {
|
|
93
|
+
admin: new ContextForgeAdmin(url, token),
|
|
94
|
+
resolve: (name) => resolveVirtualServer(url, token, name),
|
|
95
|
+
logger,
|
|
96
|
+
serverPrefix: options.serverPrefix ?? DEFAULT_SERVER_PREFIX,
|
|
97
|
+
maxToolName: options.maxToolName ?? DEFAULT_MAX_TOOL_NAME,
|
|
98
|
+
messages: text,
|
|
99
|
+
});
|
|
100
|
+
const { commands, guard } = services.get(DISCORD);
|
|
101
|
+
commands.add(connectorCommands(guard, registry, text));
|
|
102
|
+
services.provide(CONNECTORS, {
|
|
103
|
+
get version() {
|
|
104
|
+
return registry.version;
|
|
105
|
+
},
|
|
106
|
+
list: () => registry.list(),
|
|
107
|
+
servers: () => registry.servers(),
|
|
108
|
+
profileSources: () => registry.profileSources(),
|
|
109
|
+
token,
|
|
110
|
+
});
|
|
111
|
+
return {};
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
}
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { createHmac, randomUUID } from "node:crypto";
|
|
2
|
+
import { ConfigError } from "pi-roundtable";
|
|
3
|
+
import type { VirtualServer } from "pi-roundtable/kit";
|
|
4
|
+
|
|
5
|
+
function base64url(value: string | Buffer): string {
|
|
6
|
+
return Buffer.from(value).toString("base64url");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* An admin token with the claims ContextForge's own `create_jwt_token --admin` issues,
|
|
11
|
+
* signed HS256 with its JWT secret. `teams: null` gives the admin bypass.
|
|
12
|
+
*/
|
|
13
|
+
export function contextForgeToken(
|
|
14
|
+
secret: string,
|
|
15
|
+
user: string,
|
|
16
|
+
ttlSeconds: number,
|
|
17
|
+
now = Math.floor(Date.now() / 1000),
|
|
18
|
+
): string {
|
|
19
|
+
const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
|
20
|
+
const payload = base64url(
|
|
21
|
+
JSON.stringify({
|
|
22
|
+
username: user,
|
|
23
|
+
sub: user,
|
|
24
|
+
iat: now,
|
|
25
|
+
exp: now + ttlSeconds,
|
|
26
|
+
iss: "mcpgateway",
|
|
27
|
+
aud: "mcpgateway-api",
|
|
28
|
+
jti: randomUUID(),
|
|
29
|
+
env: "development",
|
|
30
|
+
user: {
|
|
31
|
+
email: user,
|
|
32
|
+
full_name: "pi-roundtable-mcp",
|
|
33
|
+
is_admin: true,
|
|
34
|
+
auth_provider: "cli",
|
|
35
|
+
},
|
|
36
|
+
teams: null,
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
const signature = createHmac("sha256", secret)
|
|
40
|
+
.update(`${header}.${payload}`)
|
|
41
|
+
.digest("base64url");
|
|
42
|
+
return `${header}.${payload}.${signature}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
46
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function getJson(
|
|
50
|
+
url: string,
|
|
51
|
+
token: string,
|
|
52
|
+
fetchImpl: typeof fetch,
|
|
53
|
+
): Promise<unknown> {
|
|
54
|
+
const response = await fetchImpl(url, {
|
|
55
|
+
headers: { authorization: `Bearer ${token}` },
|
|
56
|
+
signal: AbortSignal.timeout(15_000),
|
|
57
|
+
});
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
throw new ConfigError(
|
|
60
|
+
`ContextForge ${url} answered ${response.status}: ${(await response.text()).slice(0, 200)}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return response.json();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Finds a virtual server by name and lists the tool names it serves. */
|
|
67
|
+
export async function resolveVirtualServer(
|
|
68
|
+
baseUrl: string,
|
|
69
|
+
token: string,
|
|
70
|
+
name: string,
|
|
71
|
+
fetchImpl: typeof fetch = fetch,
|
|
72
|
+
): Promise<VirtualServer> {
|
|
73
|
+
const servers = await getJson(
|
|
74
|
+
`${baseUrl}/servers?include_pagination=false`,
|
|
75
|
+
token,
|
|
76
|
+
fetchImpl,
|
|
77
|
+
);
|
|
78
|
+
const server = Array.isArray(servers)
|
|
79
|
+
? servers.find((entry) => isRecord(entry) && entry.name === name)
|
|
80
|
+
: undefined;
|
|
81
|
+
if (!isRecord(server) || typeof server.id !== "string") {
|
|
82
|
+
throw new ConfigError(`ContextForge has no virtual server named ${name}`);
|
|
83
|
+
}
|
|
84
|
+
const tools = await getJson(
|
|
85
|
+
`${baseUrl}/servers/${server.id}/tools?include_pagination=false`,
|
|
86
|
+
token,
|
|
87
|
+
fetchImpl,
|
|
88
|
+
);
|
|
89
|
+
const names = Array.isArray(tools)
|
|
90
|
+
? tools.flatMap((tool) =>
|
|
91
|
+
isRecord(tool) && typeof tool.name === "string" ? [tool.name] : [],
|
|
92
|
+
)
|
|
93
|
+
: [];
|
|
94
|
+
if (names.length === 0)
|
|
95
|
+
throw new ConfigError(`virtual server ${name} serves no tools`);
|
|
96
|
+
return { name, url: `${baseUrl}/servers/${server.id}/mcp`, tools: names };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A request ContextForge refused or could not complete; the message is safe to show the owner. */
|
|
100
|
+
export class ContextForgeError extends Error {}
|
|
101
|
+
|
|
102
|
+
/** How ContextForge authenticates to an upstream MCP server. */
|
|
103
|
+
export type UpstreamAuth =
|
|
104
|
+
| { type: "none" }
|
|
105
|
+
| { type: "bearer"; token: string }
|
|
106
|
+
| { type: "header"; name: string; value: string };
|
|
107
|
+
|
|
108
|
+
/** The credential as ContextForge's gateway form takes it. */
|
|
109
|
+
function authFields(auth: UpstreamAuth): Record<string, string> {
|
|
110
|
+
if (auth.type === "bearer")
|
|
111
|
+
return { auth_type: "bearer", auth_token: auth.token };
|
|
112
|
+
if (auth.type === "header")
|
|
113
|
+
return {
|
|
114
|
+
auth_type: "authheaders",
|
|
115
|
+
auth_header_key: auth.name,
|
|
116
|
+
auth_header_value: auth.value,
|
|
117
|
+
};
|
|
118
|
+
return {};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The parts of the credential that error text must not repeat. */
|
|
122
|
+
function authSecrets(auth: UpstreamAuth): string[] {
|
|
123
|
+
if (auth.type === "bearer") return [auth.token];
|
|
124
|
+
if (auth.type === "header") return [auth.value];
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface GatewayTool {
|
|
129
|
+
id: string;
|
|
130
|
+
name: string;
|
|
131
|
+
gatewaySlug: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The admin API calls that add and remove owner connectors. */
|
|
135
|
+
export interface GatewayState {
|
|
136
|
+
name: string;
|
|
137
|
+
enabled: boolean;
|
|
138
|
+
reachable: boolean;
|
|
139
|
+
tools: number;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export class ContextForgeAdmin {
|
|
143
|
+
readonly #baseUrl: string;
|
|
144
|
+
readonly #token: string;
|
|
145
|
+
readonly #fetch: typeof fetch;
|
|
146
|
+
|
|
147
|
+
constructor(baseUrl: string, token: string, fetchImpl: typeof fetch = fetch) {
|
|
148
|
+
this.#baseUrl = baseUrl;
|
|
149
|
+
this.#token = token;
|
|
150
|
+
this.#fetch = fetchImpl;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Registers an upstream server; ContextForge connects and lists its tools before answering. */
|
|
154
|
+
async createGateway(gateway: {
|
|
155
|
+
name: string;
|
|
156
|
+
url: string;
|
|
157
|
+
description: string;
|
|
158
|
+
auth: UpstreamAuth;
|
|
159
|
+
}): Promise<{ id: string; slug: string }> {
|
|
160
|
+
const { auth } = gateway;
|
|
161
|
+
const upstream = URL.parse(gateway.url);
|
|
162
|
+
if (!upstream)
|
|
163
|
+
throw new ContextForgeError(
|
|
164
|
+
`Cannot parse the upstream URL: ${gateway.url}`,
|
|
165
|
+
);
|
|
166
|
+
const body = {
|
|
167
|
+
name: gateway.name,
|
|
168
|
+
url: gateway.url,
|
|
169
|
+
description: gateway.description,
|
|
170
|
+
transport: /\/sse\/?$/.test(upstream.pathname) ? "SSE" : "STREAMABLEHTTP",
|
|
171
|
+
...authFields(auth),
|
|
172
|
+
};
|
|
173
|
+
const secrets = [...authSecrets(auth), ...urlSecrets(gateway.url)];
|
|
174
|
+
const created = await this.#send(
|
|
175
|
+
"POST",
|
|
176
|
+
"/gateways",
|
|
177
|
+
body,
|
|
178
|
+
90_000,
|
|
179
|
+
secrets,
|
|
180
|
+
);
|
|
181
|
+
if (!isRecord(created) || typeof created.id !== "string")
|
|
182
|
+
throw new ContextForgeError(
|
|
183
|
+
"ContextForge did not return the gateway ID.",
|
|
184
|
+
);
|
|
185
|
+
return {
|
|
186
|
+
id: created.id,
|
|
187
|
+
slug: typeof created.slug === "string" ? created.slug : gateway.name,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async tools(): Promise<GatewayTool[]> {
|
|
192
|
+
const tools = await this.#send(
|
|
193
|
+
"GET",
|
|
194
|
+
"/tools?include_pagination=false&limit=0",
|
|
195
|
+
);
|
|
196
|
+
return Array.isArray(tools)
|
|
197
|
+
? tools.flatMap((tool) =>
|
|
198
|
+
isRecord(tool) &&
|
|
199
|
+
typeof tool.id === "string" &&
|
|
200
|
+
typeof tool.name === "string" &&
|
|
201
|
+
typeof tool.gatewaySlug === "string"
|
|
202
|
+
? [{ id: tool.id, name: tool.name, gatewaySlug: tool.gatewaySlug }]
|
|
203
|
+
: [],
|
|
204
|
+
)
|
|
205
|
+
: [];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Every upstream gateway with its state and tool count; URLs and auth are never read. */
|
|
209
|
+
async gateways(): Promise<GatewayState[]> {
|
|
210
|
+
const [gateways, tools] = await Promise.all([
|
|
211
|
+
this.#send("GET", "/gateways?include_pagination=false"),
|
|
212
|
+
this.tools(),
|
|
213
|
+
]);
|
|
214
|
+
return Array.isArray(gateways)
|
|
215
|
+
? gateways.flatMap((gateway) =>
|
|
216
|
+
isRecord(gateway) && typeof gateway.name === "string"
|
|
217
|
+
? [
|
|
218
|
+
{
|
|
219
|
+
name: gateway.name,
|
|
220
|
+
enabled: gateway.enabled === true,
|
|
221
|
+
reachable: gateway.reachable === true,
|
|
222
|
+
tools: tools.filter(
|
|
223
|
+
(tool) =>
|
|
224
|
+
tool.gatewaySlug ===
|
|
225
|
+
(typeof gateway.slug === "string"
|
|
226
|
+
? gateway.slug
|
|
227
|
+
: gateway.name),
|
|
228
|
+
).length,
|
|
229
|
+
},
|
|
230
|
+
]
|
|
231
|
+
: [],
|
|
232
|
+
)
|
|
233
|
+
: [];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Every virtual server with the names of the tools it serves. */
|
|
237
|
+
async servers(): Promise<{ name: string; tools: string[] }[]> {
|
|
238
|
+
const servers = await this.#send(
|
|
239
|
+
"GET",
|
|
240
|
+
"/servers?include_pagination=false",
|
|
241
|
+
);
|
|
242
|
+
const named = Array.isArray(servers)
|
|
243
|
+
? servers.filter(
|
|
244
|
+
(server): server is { id: string; name: string } =>
|
|
245
|
+
isRecord(server) &&
|
|
246
|
+
typeof server.id === "string" &&
|
|
247
|
+
typeof server.name === "string",
|
|
248
|
+
)
|
|
249
|
+
: [];
|
|
250
|
+
return Promise.all(
|
|
251
|
+
named.map(async (server) => {
|
|
252
|
+
const tools = await this.#send(
|
|
253
|
+
"GET",
|
|
254
|
+
`/servers/${encodeURIComponent(server.id)}/tools?include_pagination=false`,
|
|
255
|
+
);
|
|
256
|
+
return {
|
|
257
|
+
name: server.name,
|
|
258
|
+
tools: Array.isArray(tools)
|
|
259
|
+
? tools.flatMap((tool) =>
|
|
260
|
+
isRecord(tool) && typeof tool.name === "string"
|
|
261
|
+
? [tool.name]
|
|
262
|
+
: [],
|
|
263
|
+
)
|
|
264
|
+
: [],
|
|
265
|
+
};
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async createServer(
|
|
271
|
+
name: string,
|
|
272
|
+
description: string,
|
|
273
|
+
toolIds: string[],
|
|
274
|
+
): Promise<string> {
|
|
275
|
+
const created = await this.#send("POST", "/servers", {
|
|
276
|
+
server: { name, description, associated_tools: toolIds },
|
|
277
|
+
});
|
|
278
|
+
if (!isRecord(created) || typeof created.id !== "string")
|
|
279
|
+
throw new ContextForgeError(
|
|
280
|
+
"ContextForge did not return the virtual server ID.",
|
|
281
|
+
);
|
|
282
|
+
return created.id;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async deleteServer(id: string): Promise<void> {
|
|
286
|
+
await this.#send("DELETE", `/servers/${encodeURIComponent(id)}`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async deleteGateway(id: string): Promise<void> {
|
|
290
|
+
await this.#send("DELETE", `/gateways/${encodeURIComponent(id)}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async #send(
|
|
294
|
+
method: string,
|
|
295
|
+
path: string,
|
|
296
|
+
body?: unknown,
|
|
297
|
+
timeoutMs = 30_000,
|
|
298
|
+
/** Masked in error text, since a validation error may echo the request. */
|
|
299
|
+
secrets: readonly string[] = [],
|
|
300
|
+
): Promise<unknown> {
|
|
301
|
+
const response = await this.#fetch(`${this.#baseUrl}${path}`, {
|
|
302
|
+
method,
|
|
303
|
+
headers: {
|
|
304
|
+
authorization: `Bearer ${this.#token}`,
|
|
305
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
306
|
+
},
|
|
307
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
308
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
309
|
+
});
|
|
310
|
+
const text = await response.text();
|
|
311
|
+
if (!response.ok)
|
|
312
|
+
throw new ContextForgeError(
|
|
313
|
+
`ContextForge answered ${response.status}: ${detailOf(masked(text, secrets))}`,
|
|
314
|
+
);
|
|
315
|
+
if (!text) return undefined;
|
|
316
|
+
try {
|
|
317
|
+
return JSON.parse(text);
|
|
318
|
+
} catch {
|
|
319
|
+
throw new ContextForgeError(
|
|
320
|
+
`ContextForge did not answer with JSON: ${detailOf(masked(text, secrets))}`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The parts of an upstream URL that may carry a credential: the URL itself, what follows
|
|
328
|
+
* its host, user and password, and query values. Short parts are left, since masking them
|
|
329
|
+
* would garble the rest of the message.
|
|
330
|
+
*/
|
|
331
|
+
export function urlSecrets(url: string): string[] {
|
|
332
|
+
const parsed = URL.parse(url);
|
|
333
|
+
if (!parsed) return [url];
|
|
334
|
+
const rest = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
335
|
+
return [
|
|
336
|
+
url,
|
|
337
|
+
parsed.href,
|
|
338
|
+
rest === "/" ? "" : rest,
|
|
339
|
+
parsed.username,
|
|
340
|
+
parsed.password,
|
|
341
|
+
...parsed.searchParams.values(),
|
|
342
|
+
...[...parsed.searchParams.values()].map(encodeURIComponent),
|
|
343
|
+
].filter((part) => part.length >= 4);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** The text with the secrets removed, raw or JSON-escaped once or twice; longest first. */
|
|
347
|
+
function masked(text: string, secrets: readonly string[]): string {
|
|
348
|
+
const forms = secrets.flatMap((secret) => {
|
|
349
|
+
const once = JSON.stringify(secret).slice(1, -1);
|
|
350
|
+
return [secret, once, JSON.stringify(once).slice(1, -1)];
|
|
351
|
+
});
|
|
352
|
+
return [...new Set(forms)]
|
|
353
|
+
.sort((a, b) => b.length - a.length)
|
|
354
|
+
.reduce((result, form) => result.replaceAll(form, "***"), text);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** ContextForge's error detail, shortened. */
|
|
358
|
+
function detailOf(text: string): string {
|
|
359
|
+
try {
|
|
360
|
+
const parsed: unknown = JSON.parse(text);
|
|
361
|
+
if (isRecord(parsed)) {
|
|
362
|
+
const detail = parsed.detail ?? parsed.message;
|
|
363
|
+
return (
|
|
364
|
+
typeof detail === "string" ? detail : JSON.stringify(detail)
|
|
365
|
+
).slice(0, 300);
|
|
366
|
+
}
|
|
367
|
+
} catch {
|
|
368
|
+
// Not JSON: the raw text below is the detail.
|
|
369
|
+
}
|
|
370
|
+
return text.slice(0, 300);
|
|
371
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/** The text the connectors plugin shows the owner on Discord, and the refusals the registry raises. */
|
|
2
|
+
export interface ConnectorMessages {
|
|
3
|
+
nameRule: string;
|
|
4
|
+
urlRule: string;
|
|
5
|
+
purposeRequired: string;
|
|
6
|
+
purposeEmpty: string;
|
|
7
|
+
nameTaken(name: string): string;
|
|
8
|
+
noTools: string;
|
|
9
|
+
toolsTooLong(max: number, names: readonly string[]): string;
|
|
10
|
+
unknownConnector(name: string): string;
|
|
11
|
+
|
|
12
|
+
groupDescription: string;
|
|
13
|
+
addDescription: string;
|
|
14
|
+
listDescription: string;
|
|
15
|
+
describeDescription: string;
|
|
16
|
+
removeDescription: string;
|
|
17
|
+
nameOption: string;
|
|
18
|
+
newPurposeOption: string;
|
|
19
|
+
|
|
20
|
+
modalTitle: string;
|
|
21
|
+
nameLabel: string;
|
|
22
|
+
nameHelp: string;
|
|
23
|
+
urlLabel: string;
|
|
24
|
+
urlHelp: string;
|
|
25
|
+
purposeLabel: string;
|
|
26
|
+
purposeHelp: string;
|
|
27
|
+
headerLabel: string;
|
|
28
|
+
headerHelp: string;
|
|
29
|
+
tokenLabel: string;
|
|
30
|
+
tokenHelp: string;
|
|
31
|
+
|
|
32
|
+
headerNeedsToken: string;
|
|
33
|
+
headerRule: string;
|
|
34
|
+
urlUnreadable: string;
|
|
35
|
+
|
|
36
|
+
/** The tool line of a connector: how many it has, the first few, and how many are left out of the list. */
|
|
37
|
+
toolCount(total: number, listed: string, hidden: number): string;
|
|
38
|
+
toolsUnreadable: string;
|
|
39
|
+
listTitle: string;
|
|
40
|
+
listEmpty(root: string): string;
|
|
41
|
+
listFooter(root: string): string;
|
|
42
|
+
purposeUpdatedTitle: string;
|
|
43
|
+
purposeUpdatedFooter: string;
|
|
44
|
+
removedTitle: string;
|
|
45
|
+
removed(name: string): string;
|
|
46
|
+
addedTitle: string;
|
|
47
|
+
addedFooter: string;
|
|
48
|
+
skippedTools(names: string): string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const CONNECTOR_MESSAGES: ConnectorMessages = {
|
|
52
|
+
nameRule:
|
|
53
|
+
"Use lowercase letters, digits and `-`, start with a letter, and keep it to 12 characters.",
|
|
54
|
+
urlRule: "The URL must start with `https://` or `http://`.",
|
|
55
|
+
purposeRequired:
|
|
56
|
+
"Describe what this connector is for; the agent uses it to decide when to call it.",
|
|
57
|
+
purposeEmpty: "The purpose cannot be empty.",
|
|
58
|
+
nameTaken: (name) => `The name \`${name}\` is already taken. Pick another.`,
|
|
59
|
+
noTools: "This MCP server offers no tools.",
|
|
60
|
+
toolsTooLong: (max, names) =>
|
|
61
|
+
`Every tool name is longer than ${max} characters, which the model cannot use: ${names.join(", ")}`,
|
|
62
|
+
unknownConnector: (name) => `There is no connector named \`${name}\`.`,
|
|
63
|
+
|
|
64
|
+
groupDescription: "External MCP connectors the agent can use",
|
|
65
|
+
addDescription: "Add an MCP connector that connects with a token",
|
|
66
|
+
listDescription: "List every connector and its tools",
|
|
67
|
+
describeDescription:
|
|
68
|
+
"Change a connector's purpose, which the agent uses to decide when to call it",
|
|
69
|
+
removeDescription: "Delete a connector and its token",
|
|
70
|
+
nameOption: "Connector",
|
|
71
|
+
newPurposeOption: "The new purpose",
|
|
72
|
+
|
|
73
|
+
modalTitle: "Add an MCP connector",
|
|
74
|
+
nameLabel: "Name",
|
|
75
|
+
nameHelp:
|
|
76
|
+
"Lowercase letters, digits and -, up to 12; it becomes the prefix of its tool names",
|
|
77
|
+
urlLabel: "MCP URL",
|
|
78
|
+
urlHelp:
|
|
79
|
+
"A Streamable HTTP endpoint; one ending in /sse uses SSE. A credential may be part of the URL",
|
|
80
|
+
purposeLabel: "Purpose",
|
|
81
|
+
purposeHelp: "The agent decides from this text when to use the connector",
|
|
82
|
+
headerLabel: "Header name (optional)",
|
|
83
|
+
headerHelp: "Leave empty to send Authorization: Bearer <token>",
|
|
84
|
+
tokenLabel: "Token (optional)",
|
|
85
|
+
tokenHelp:
|
|
86
|
+
"Handed to ContextForge to store encrypted; leave empty if the credential is in the URL",
|
|
87
|
+
|
|
88
|
+
headerNeedsToken: "A header name needs a token with it.",
|
|
89
|
+
headerRule: "A header name may use letters, digits and `-` only.",
|
|
90
|
+
urlUnreadable: "(the URL cannot be parsed)",
|
|
91
|
+
|
|
92
|
+
toolCount: (total, listed, hidden) =>
|
|
93
|
+
`-# ${total} tools: ${listed}${hidden > 0 ? ` and ${hidden} more` : ""}`,
|
|
94
|
+
toolsUnreadable:
|
|
95
|
+
"-# The tools cannot be read, so the agent will not use it for now; see the log for the reason.",
|
|
96
|
+
listTitle: "MCP connectors",
|
|
97
|
+
listEmpty: (root) =>
|
|
98
|
+
`No connectors yet. Add one with \`/${root} connector add\`.`,
|
|
99
|
+
listFooter: (root) =>
|
|
100
|
+
`\`/${root} connector add\` adds one; \`describe\` changes its purpose; \`remove\` deletes the connector and its token.`,
|
|
101
|
+
purposeUpdatedTitle: "Purpose updated",
|
|
102
|
+
purposeUpdatedFooter:
|
|
103
|
+
"From the next message on, the agent follows the new purpose.",
|
|
104
|
+
removedTitle: "Connector removed",
|
|
105
|
+
removed: (name) =>
|
|
106
|
+
`**${name}** and its token are deleted; from the next message on, the agent no longer uses it.`,
|
|
107
|
+
addedTitle: "Connector added",
|
|
108
|
+
addedFooter:
|
|
109
|
+
"From the next message on, questions that need it go to this connector's tools.",
|
|
110
|
+
skippedTools: (names) =>
|
|
111
|
+
`**Skipped tools** Their names are too long for the model: ${names}`,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** The English text with the host's own wording laid over it. */
|
|
115
|
+
export function connectorMessages(
|
|
116
|
+
overrides: Partial<ConnectorMessages> = {},
|
|
117
|
+
): ConnectorMessages {
|
|
118
|
+
return { ...CONNECTOR_MESSAGES, ...overrides };
|
|
119
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The package's one entry: two plugins, their options, and the service the connectors plugin provides.
|
|
2
|
+
|
|
3
|
+
export type {
|
|
4
|
+
Connector,
|
|
5
|
+
ConnectorProfileSource,
|
|
6
|
+
NewConnector,
|
|
7
|
+
} from "./connectors/connector-registry.ts";
|
|
8
|
+
export type {
|
|
9
|
+
Connectors,
|
|
10
|
+
ContextForgeOptions,
|
|
11
|
+
McpConnectorsOptions,
|
|
12
|
+
} from "./connectors/connectors-plugin.ts";
|
|
13
|
+
export { CONNECTORS, mcpConnectors } from "./connectors/connectors-plugin.ts";
|
|
14
|
+
export type { UpstreamAuth } from "./connectors/contextforge.ts";
|
|
15
|
+
export type { ConnectorMessages } from "./connectors/messages.ts";
|
|
16
|
+
export type { RemoteMcpMessages } from "./remote-mcp/messages.ts";
|
|
17
|
+
export type { RemoteClaimHooks } from "./remote-mcp/remote-claim.ts";
|
|
18
|
+
export type {
|
|
19
|
+
DefaultConversationOptions,
|
|
20
|
+
HostConversationOptions,
|
|
21
|
+
RemoteMcpOptions,
|
|
22
|
+
} from "./remote-mcp/remote-mcp-plugin.ts";
|
|
23
|
+
export { remoteMcp } from "./remote-mcp/remote-mcp-plugin.ts";
|