@hachej/boring-mcp 0.0.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/README.md +114 -0
- package/dist/front/index.d.ts +51 -0
- package/dist/front/index.js +638 -0
- package/dist/server/index.d.ts +374 -0
- package/dist/server/index.js +1623 -0
- package/dist/shared/index.d.ts +213 -0
- package/dist/shared/index.js +194 -0
- package/package.json +78 -0
|
@@ -0,0 +1,1623 @@
|
|
|
1
|
+
// src/server/index.ts
|
|
2
|
+
import { defineServerPlugin } from "@hachej/boring-workspace/server";
|
|
3
|
+
|
|
4
|
+
// src/shared/index.ts
|
|
5
|
+
var BORING_MCP_PLUGIN_ID = "boring-mcp";
|
|
6
|
+
var BORING_MCP_SOURCES_TAB_PANEL_ID = "boring-mcp.sources.tab";
|
|
7
|
+
var BORING_MCP_SOURCES_PANEL_ID = "boring-mcp.sources.panel";
|
|
8
|
+
var MCP_ERROR_CODES = {
|
|
9
|
+
SOURCE_NOT_FOUND: "MCP_SOURCE_NOT_FOUND",
|
|
10
|
+
SOURCE_FORBIDDEN: "MCP_SOURCE_FORBIDDEN",
|
|
11
|
+
SOURCE_UNAVAILABLE: "MCP_SOURCE_UNAVAILABLE",
|
|
12
|
+
PROVIDER_CONFIG_INVALID: "MCP_PROVIDER_CONFIG_INVALID",
|
|
13
|
+
PROVIDER_TIMEOUT: "MCP_PROVIDER_TIMEOUT",
|
|
14
|
+
PROVIDER_ERROR: "MCP_PROVIDER_ERROR",
|
|
15
|
+
TOOL_NOT_FOUND: "MCP_TOOL_NOT_FOUND",
|
|
16
|
+
TOOL_NOT_ALLOWED: "MCP_TOOL_NOT_ALLOWED",
|
|
17
|
+
PROVIDER_TOOL_DRIFT: "MCP_PROVIDER_TOOL_DRIFT",
|
|
18
|
+
RESOURCE_LIMIT_EXCEEDED: "MCP_RESOURCE_LIMIT_EXCEEDED",
|
|
19
|
+
SECRET_LEAK_GUARD: "MCP_SECRET_LEAK_GUARD",
|
|
20
|
+
INPUT_INVALID: "MCP_INPUT_INVALID",
|
|
21
|
+
RESOURCE_URI_INVALID: "MCP_RESOURCE_URI_INVALID"
|
|
22
|
+
};
|
|
23
|
+
function toMcpSourceDto(source) {
|
|
24
|
+
return {
|
|
25
|
+
id: source.id,
|
|
26
|
+
provider: source.provider,
|
|
27
|
+
displayName: source.displayName,
|
|
28
|
+
status: source.status,
|
|
29
|
+
ownerKind: source.ownerKind,
|
|
30
|
+
credentialProvider: source.credentialProvider,
|
|
31
|
+
scopes: source.scopes,
|
|
32
|
+
providerAccountLabel: source.providerAccountLabel,
|
|
33
|
+
lastVerifiedAt: source.lastVerifiedAt,
|
|
34
|
+
createdAt: source.createdAt,
|
|
35
|
+
updatedAt: source.updatedAt
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
var McpError = class extends Error {
|
|
39
|
+
code;
|
|
40
|
+
details;
|
|
41
|
+
constructor(code, message, details) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = "McpError";
|
|
44
|
+
this.code = code;
|
|
45
|
+
this.details = details;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
var NOTION_MCP_TEMPLATE = {
|
|
49
|
+
id: "notion",
|
|
50
|
+
displayName: "Notion",
|
|
51
|
+
readOnlyDefault: true,
|
|
52
|
+
allowedTools: ["NOTION_SEARCH_NOTION_PAGE", "NOTION_GET_PAGE_MARKDOWN", "NOTION_RETRIEVE_PAGE"],
|
|
53
|
+
deniedTools: ["create_*", "update_*", "delete_*", "publish_*", "admin_*"],
|
|
54
|
+
allowedResourceUriPrefixes: ["notion:", "notion://"]
|
|
55
|
+
};
|
|
56
|
+
var AIRTABLE_MCP_TEMPLATE = {
|
|
57
|
+
id: "airtable",
|
|
58
|
+
displayName: "Airtable",
|
|
59
|
+
readOnlyDefault: true,
|
|
60
|
+
allowedTools: ["ping", "list_bases", "list_workspaces", "list_tables_for_base", "get_table_schema", "search_records"],
|
|
61
|
+
deniedTools: ["create_*", "update_*", "delete_*", "publish_*", "admin_*"],
|
|
62
|
+
allowedResourceUriPrefixes: ["airtable:", "airtable://"]
|
|
63
|
+
};
|
|
64
|
+
var DEFAULT_MCP_PROVIDER_TEMPLATES = [NOTION_MCP_TEMPLATE, AIRTABLE_MCP_TEMPLATE];
|
|
65
|
+
function getMcpProviderTemplate(provider, templates = DEFAULT_MCP_PROVIDER_TEMPLATES) {
|
|
66
|
+
return templates.find((template) => template.id === provider);
|
|
67
|
+
}
|
|
68
|
+
var MCP_TOOL_NAME_PATTERN = /^[A-Za-z0-9_.:-]{1,128}$/;
|
|
69
|
+
function validateMcpToolName(toolName) {
|
|
70
|
+
if (!MCP_TOOL_NAME_PATTERN.test(toolName)) {
|
|
71
|
+
throw new McpError(MCP_ERROR_CODES.INPUT_INVALID, "Invalid MCP tool name");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function wildcardMatch(pattern, value) {
|
|
75
|
+
if (!pattern.includes("*")) return pattern === value;
|
|
76
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
77
|
+
return new RegExp(`^${escaped}$`, "i").test(value);
|
|
78
|
+
}
|
|
79
|
+
function classifyMcpTool(template, toolName) {
|
|
80
|
+
validateMcpToolName(toolName);
|
|
81
|
+
if (template.deniedTools.some((pattern) => wildcardMatch(pattern, toolName))) {
|
|
82
|
+
return { allowed: false, risk: "write", reason: "Tool matches a denied write/admin pattern" };
|
|
83
|
+
}
|
|
84
|
+
if (template.allowedTools.some((pattern) => wildcardMatch(pattern, toolName))) {
|
|
85
|
+
return { allowed: true, risk: "read", reason: "Tool is on the read-only allowlist" };
|
|
86
|
+
}
|
|
87
|
+
return { allowed: false, risk: "unknown", reason: "Tool is not on the read-only allowlist" };
|
|
88
|
+
}
|
|
89
|
+
function classifyMcpTools(template, tools) {
|
|
90
|
+
return tools.map((tool2) => ({ ...tool2, decision: classifyMcpTool(template, tool2.name) }));
|
|
91
|
+
}
|
|
92
|
+
function assertMcpToolAllowed(template, toolName) {
|
|
93
|
+
const decision = classifyMcpTool(template, toolName);
|
|
94
|
+
if (!decision.allowed) throw new McpError(MCP_ERROR_CODES.TOOL_NOT_ALLOWED, decision.reason);
|
|
95
|
+
}
|
|
96
|
+
var REDACTION = "[REDACTED_MCP_SECRET]";
|
|
97
|
+
var SECRET_KEY_PATTERN = /(api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|oauth[_-]?token|authorization|cookie|client[_-]?secret|session[_-]?headers?|mcp[_-]?session|x-composio-mcp-session)/i;
|
|
98
|
+
var SECRET_VALUE_PATTERN = /(Bearer\s+[A-Za-z0-9._~+\/-]{12,}|sk-[A-Za-z0-9_-]{12,}|(?:x-api-key|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|oauth[_-]?token|code|client[_-]?secret|session[_-]?headers?|mcp[_-]?session|x-composio-mcp-session)\s*[:=]\s*[^\s,&,}]+)/gi;
|
|
99
|
+
function redactMcpSecrets(value) {
|
|
100
|
+
if (typeof value === "string") return value.replace(SECRET_VALUE_PATTERN, REDACTION);
|
|
101
|
+
if (Array.isArray(value)) return value.map(redactMcpSecrets);
|
|
102
|
+
if (!value || typeof value !== "object") return value;
|
|
103
|
+
return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, SECRET_KEY_PATTERN.test(key) ? REDACTION : redactMcpSecrets(nested)]));
|
|
104
|
+
}
|
|
105
|
+
function containsMcpSecret(value) {
|
|
106
|
+
const redacted = redactMcpSecrets(value);
|
|
107
|
+
return JSON.stringify(redacted) !== JSON.stringify(value);
|
|
108
|
+
}
|
|
109
|
+
function hasMcpCanaryText(value, canaries) {
|
|
110
|
+
return canaries.some((canary) => canary.trim() && value.includes(canary));
|
|
111
|
+
}
|
|
112
|
+
function containsMcpCanary(value, canaries) {
|
|
113
|
+
if (typeof value === "string") return hasMcpCanaryText(value, canaries);
|
|
114
|
+
if (Array.isArray(value)) return value.some((item) => containsMcpCanary(item, canaries));
|
|
115
|
+
if (!value || typeof value !== "object") return false;
|
|
116
|
+
return Object.entries(value).some(([key, nested]) => hasMcpCanaryText(key, canaries) || containsMcpCanary(nested, canaries));
|
|
117
|
+
}
|
|
118
|
+
function containsMcpSecretOrCanary(value, canaries) {
|
|
119
|
+
return containsMcpSecret(value) || containsMcpCanary(value, canaries);
|
|
120
|
+
}
|
|
121
|
+
function doctorMcpSource(source, templates = DEFAULT_MCP_PROVIDER_TEMPLATES) {
|
|
122
|
+
const issues = [];
|
|
123
|
+
if (!getMcpProviderTemplate(source.provider, templates)) {
|
|
124
|
+
issues.push({ level: "error", code: MCP_ERROR_CODES.PROVIDER_CONFIG_INVALID, message: "Unknown MCP provider template" });
|
|
125
|
+
}
|
|
126
|
+
if (source.status !== "connected") {
|
|
127
|
+
issues.push({ level: "warning", code: MCP_ERROR_CODES.SOURCE_UNAVAILABLE, message: "MCP source is not connected" });
|
|
128
|
+
}
|
|
129
|
+
return { ok: issues.every((issue2) => issue2.level !== "error"), sourceId: source.id, issues };
|
|
130
|
+
}
|
|
131
|
+
var McpAccessFacade = class {
|
|
132
|
+
constructor(params) {
|
|
133
|
+
this.params = params;
|
|
134
|
+
}
|
|
135
|
+
params;
|
|
136
|
+
async listSources(actor) {
|
|
137
|
+
return (await this.params.store.listSources(actor)).filter((source) => this.canAccessSource(actor, source));
|
|
138
|
+
}
|
|
139
|
+
async probeSource(actor, sourceId) {
|
|
140
|
+
const source = await this.requireAccessibleSource(actor, sourceId);
|
|
141
|
+
this.requireConnectedSource(source);
|
|
142
|
+
const template = this.requireTemplate(source);
|
|
143
|
+
const [tools, resources] = await Promise.all([
|
|
144
|
+
this.params.transport.listTools(source),
|
|
145
|
+
this.params.transport.listResources(source)
|
|
146
|
+
]);
|
|
147
|
+
return {
|
|
148
|
+
sourceId: source.id,
|
|
149
|
+
provider: source.provider,
|
|
150
|
+
tools: classifyMcpTools(template, tools),
|
|
151
|
+
resources
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
async requireAccessibleSource(actor, sourceId) {
|
|
155
|
+
const source = await this.params.store.getSource(sourceId);
|
|
156
|
+
if (!source || source.workspaceId !== actor.workspaceId) {
|
|
157
|
+
throw new McpError(MCP_ERROR_CODES.SOURCE_NOT_FOUND, "MCP source not found");
|
|
158
|
+
}
|
|
159
|
+
if (!this.canAccessSource(actor, source)) throw new McpError(MCP_ERROR_CODES.SOURCE_NOT_FOUND, "MCP source not found");
|
|
160
|
+
return source;
|
|
161
|
+
}
|
|
162
|
+
canAccessSource(actor, source) {
|
|
163
|
+
if (source.workspaceId !== actor.workspaceId) return false;
|
|
164
|
+
return this.params.accessPolicy?.canAccessSource(actor, source) ?? (source.ownerKind === "user" && source.userId === actor.userId);
|
|
165
|
+
}
|
|
166
|
+
requireConnectedSource(source) {
|
|
167
|
+
if (source.status !== "connected") throw new McpError(MCP_ERROR_CODES.SOURCE_UNAVAILABLE, "MCP source is not connected");
|
|
168
|
+
}
|
|
169
|
+
requireTemplate(source) {
|
|
170
|
+
const template = getMcpProviderTemplate(source.provider, this.params.templates);
|
|
171
|
+
if (!template) throw new McpError(MCP_ERROR_CODES.PROVIDER_CONFIG_INVALID, "Unknown MCP provider");
|
|
172
|
+
return template;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// src/server/sourceAccess.ts
|
|
177
|
+
var SOURCE_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,160}$/;
|
|
178
|
+
function validateMcpSourceId(sourceId) {
|
|
179
|
+
const trimmed = sourceId.trim();
|
|
180
|
+
if (!SOURCE_ID_PATTERN.test(trimmed)) throw new McpError(MCP_ERROR_CODES.SOURCE_NOT_FOUND, "MCP source not found");
|
|
181
|
+
return trimmed;
|
|
182
|
+
}
|
|
183
|
+
function isActorOwnedMcpSource(actor, source) {
|
|
184
|
+
return Boolean(source && source.workspaceId === actor.workspaceId && source.userId === actor.userId);
|
|
185
|
+
}
|
|
186
|
+
async function requireActorOwnedMcpSource(registry, actor, sourceId) {
|
|
187
|
+
const source = await registry.getSource(validateMcpSourceId(sourceId));
|
|
188
|
+
if (!isActorOwnedMcpSource(actor, source)) throw new McpError(MCP_ERROR_CODES.SOURCE_NOT_FOUND, "MCP source not found");
|
|
189
|
+
return source;
|
|
190
|
+
}
|
|
191
|
+
function assertMcpPublicPayloadSecretFree(value) {
|
|
192
|
+
if (containsMcpSecret(value)) throw new McpError(MCP_ERROR_CODES.SECRET_LEAK_GUARD, "MCP public payload contained secret material");
|
|
193
|
+
}
|
|
194
|
+
function createMcpSourceStatusPayload(source) {
|
|
195
|
+
const payload = {
|
|
196
|
+
source: toMcpSourceDto(source),
|
|
197
|
+
connectable: source.credentialProvider === "provider-managed" || source.credentialProvider === "composio-managed" || source.credentialProvider === "app-managed",
|
|
198
|
+
canProbe: source.status === "connected",
|
|
199
|
+
canDisconnect: source.status !== "unconfigured" && source.status !== "revoked"
|
|
200
|
+
};
|
|
201
|
+
assertMcpPublicPayloadSecretFree(payload);
|
|
202
|
+
return payload;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// src/server/agentBridge.ts
|
|
206
|
+
var BORING_MCP_AGENT_BRIDGE_TOOL_NAMES = [
|
|
207
|
+
"mcp_servers_list",
|
|
208
|
+
"mcp_server_status",
|
|
209
|
+
"mcp_server_doctor",
|
|
210
|
+
"mcp_server_probe",
|
|
211
|
+
"mcp_tools_search",
|
|
212
|
+
"mcp_tool_describe",
|
|
213
|
+
"mcp_readonly_call"
|
|
214
|
+
];
|
|
215
|
+
var EMPTY_INPUT_SCHEMA = {
|
|
216
|
+
type: "object",
|
|
217
|
+
properties: {},
|
|
218
|
+
additionalProperties: false
|
|
219
|
+
};
|
|
220
|
+
var SOURCE_ID_INPUT_SCHEMA = {
|
|
221
|
+
type: "object",
|
|
222
|
+
properties: {
|
|
223
|
+
sourceId: { type: "string", description: "Stable boring-mcp source id." }
|
|
224
|
+
},
|
|
225
|
+
required: ["sourceId"],
|
|
226
|
+
additionalProperties: false
|
|
227
|
+
};
|
|
228
|
+
var TOOL_SEARCH_INPUT_SCHEMA = {
|
|
229
|
+
type: "object",
|
|
230
|
+
properties: {
|
|
231
|
+
sourceId: { type: "string", description: "Optional source id to search within." },
|
|
232
|
+
query: { type: "string", description: "Optional text query for tool name, summary, provider, or description." },
|
|
233
|
+
refresh: { type: "boolean", description: "Refresh provider tool metadata before searching." }
|
|
234
|
+
},
|
|
235
|
+
additionalProperties: false
|
|
236
|
+
};
|
|
237
|
+
var TOOL_DESCRIBE_INPUT_SCHEMA = {
|
|
238
|
+
type: "object",
|
|
239
|
+
properties: {
|
|
240
|
+
sourceId: { type: "string", description: "Stable boring-mcp source id." },
|
|
241
|
+
toolName: { type: "string", description: "Provider-native tool/action name." },
|
|
242
|
+
expectedSchemaHash: { type: "string", description: "Optional sha256 schema hash to detect drift." },
|
|
243
|
+
refresh: { type: "boolean", description: "Refresh provider tool metadata before describing." }
|
|
244
|
+
},
|
|
245
|
+
required: ["sourceId", "toolName"],
|
|
246
|
+
additionalProperties: false
|
|
247
|
+
};
|
|
248
|
+
var READONLY_CALL_INPUT_SCHEMA = {
|
|
249
|
+
type: "object",
|
|
250
|
+
properties: {
|
|
251
|
+
sourceId: { type: "string", description: "Stable boring-mcp source id." },
|
|
252
|
+
toolName: { type: "string", description: "Read-only provider-native tool/action name." },
|
|
253
|
+
input: { description: "JSON-safe provider input for the read-only tool." },
|
|
254
|
+
expectedSchemaHash: { type: "string", description: "Optional lowercase sha256 schema hash to detect drift before execution." }
|
|
255
|
+
},
|
|
256
|
+
required: ["sourceId", "toolName"],
|
|
257
|
+
additionalProperties: false
|
|
258
|
+
};
|
|
259
|
+
var BORING_MCP_AGENT_BRIDGE_TOOL_DEFINITIONS = [
|
|
260
|
+
{ name: "mcp_servers_list", description: "List MCP sources available to the current actor.", inputSchema: EMPTY_INPUT_SCHEMA, readOnly: true },
|
|
261
|
+
{ name: "mcp_server_status", description: "Get status and available actions for one MCP source.", inputSchema: SOURCE_ID_INPUT_SCHEMA, readOnly: true },
|
|
262
|
+
{ name: "mcp_server_doctor", description: "Diagnose configuration/status issues for one MCP source without provider execution.", inputSchema: SOURCE_ID_INPUT_SCHEMA, readOnly: true },
|
|
263
|
+
{ name: "mcp_server_probe", description: "Probe one connected MCP source for provider metadata and classified tools.", inputSchema: SOURCE_ID_INPUT_SCHEMA, readOnly: true },
|
|
264
|
+
{ name: "mcp_tools_search", description: "Search normalized MCP tool catalog entries across connected sources or one source.", inputSchema: TOOL_SEARCH_INPUT_SCHEMA, readOnly: true },
|
|
265
|
+
{ name: "mcp_tool_describe", description: "Describe one normalized MCP tool and report schema drift.", inputSchema: TOOL_DESCRIBE_INPUT_SCHEMA, readOnly: true },
|
|
266
|
+
{ name: "mcp_readonly_call", description: "Execute one governed read-only MCP tool call.", inputSchema: READONLY_CALL_INPUT_SCHEMA, readOnly: true }
|
|
267
|
+
];
|
|
268
|
+
function requireActor(context) {
|
|
269
|
+
const actor = context?.actor;
|
|
270
|
+
if (!actor || typeof actor.userId !== "string" || typeof actor.workspaceId !== "string") {
|
|
271
|
+
throw new McpError(MCP_ERROR_CODES.INPUT_INVALID, "MCP bridge tools require an explicit actor");
|
|
272
|
+
}
|
|
273
|
+
return actor;
|
|
274
|
+
}
|
|
275
|
+
function inputRecord(input) {
|
|
276
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
277
|
+
throw new McpError(MCP_ERROR_CODES.INPUT_INVALID, "MCP bridge input must be an object");
|
|
278
|
+
}
|
|
279
|
+
return input;
|
|
280
|
+
}
|
|
281
|
+
function optionalInputRecord(input) {
|
|
282
|
+
if (input === void 0) return {};
|
|
283
|
+
return inputRecord(input);
|
|
284
|
+
}
|
|
285
|
+
function stringField(record2, key) {
|
|
286
|
+
const value = record2[key];
|
|
287
|
+
if (typeof value !== "string") throw new McpError(MCP_ERROR_CODES.INPUT_INVALID, `MCP bridge ${key} must be a string`);
|
|
288
|
+
return value;
|
|
289
|
+
}
|
|
290
|
+
function optionalStringField(record2, key) {
|
|
291
|
+
const value = record2[key];
|
|
292
|
+
if (value === void 0) return void 0;
|
|
293
|
+
if (typeof value !== "string") throw new McpError(MCP_ERROR_CODES.INPUT_INVALID, `MCP bridge ${key} must be a string`);
|
|
294
|
+
return value;
|
|
295
|
+
}
|
|
296
|
+
function optionalBooleanField(record2, key) {
|
|
297
|
+
const value = record2[key];
|
|
298
|
+
if (value === void 0) return void 0;
|
|
299
|
+
if (typeof value !== "boolean") throw new McpError(MCP_ERROR_CODES.INPUT_INVALID, `MCP bridge ${key} must be a boolean`);
|
|
300
|
+
return value;
|
|
301
|
+
}
|
|
302
|
+
function parseEmptyInput(input) {
|
|
303
|
+
const record2 = optionalInputRecord(input);
|
|
304
|
+
if (Object.keys(record2).length > 0) throw new McpError(MCP_ERROR_CODES.INPUT_INVALID, "MCP bridge input must be empty");
|
|
305
|
+
}
|
|
306
|
+
function parseBridgeSourceId(sourceId) {
|
|
307
|
+
try {
|
|
308
|
+
return validateMcpSourceId(sourceId);
|
|
309
|
+
} catch {
|
|
310
|
+
throw new McpError(MCP_ERROR_CODES.INPUT_INVALID, "MCP bridge sourceId is invalid");
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function parseSourceInput(input) {
|
|
314
|
+
const record2 = inputRecord(input);
|
|
315
|
+
return { sourceId: parseBridgeSourceId(stringField(record2, "sourceId")) };
|
|
316
|
+
}
|
|
317
|
+
function parseSearchInput(input) {
|
|
318
|
+
const record2 = optionalInputRecord(input);
|
|
319
|
+
const sourceId = optionalStringField(record2, "sourceId");
|
|
320
|
+
return {
|
|
321
|
+
sourceId: sourceId === void 0 ? void 0 : parseBridgeSourceId(sourceId),
|
|
322
|
+
query: optionalStringField(record2, "query"),
|
|
323
|
+
refresh: optionalBooleanField(record2, "refresh")
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
function parseDescribeInput(input) {
|
|
327
|
+
const record2 = inputRecord(input);
|
|
328
|
+
const toolName = stringField(record2, "toolName");
|
|
329
|
+
validateMcpToolName(toolName);
|
|
330
|
+
return {
|
|
331
|
+
sourceId: parseBridgeSourceId(stringField(record2, "sourceId")),
|
|
332
|
+
toolName,
|
|
333
|
+
expectedSchemaHash: optionalStringField(record2, "expectedSchemaHash"),
|
|
334
|
+
refresh: optionalBooleanField(record2, "refresh")
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
function parseReadonlyInput(input) {
|
|
338
|
+
const record2 = inputRecord(input);
|
|
339
|
+
const toolName = stringField(record2, "toolName");
|
|
340
|
+
validateMcpToolName(toolName);
|
|
341
|
+
const expectedSchemaHash = optionalStringField(record2, "expectedSchemaHash");
|
|
342
|
+
return {
|
|
343
|
+
sourceId: parseBridgeSourceId(stringField(record2, "sourceId")),
|
|
344
|
+
toolName,
|
|
345
|
+
input: record2.input,
|
|
346
|
+
expectedSchemaHash
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
async function invokeGuarded(fn) {
|
|
350
|
+
try {
|
|
351
|
+
const result = await fn();
|
|
352
|
+
assertMcpPublicPayloadSecretFree(result);
|
|
353
|
+
return result;
|
|
354
|
+
} catch (error) {
|
|
355
|
+
if (error instanceof McpError) {
|
|
356
|
+
const redactedDetails = redactMcpSecrets(error.details);
|
|
357
|
+
const safeMessage = containsMcpSecret(error.message) ? "MCP bridge tool failed" : error.message;
|
|
358
|
+
throw new McpError(error.code, safeMessage, redactedDetails);
|
|
359
|
+
}
|
|
360
|
+
const redacted = redactMcpSecrets(error instanceof Error ? { name: error.name, message: error.message } : error);
|
|
361
|
+
throw new McpError(MCP_ERROR_CODES.PROVIDER_ERROR, "MCP bridge tool failed", redacted);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
function tool(definition, invoke) {
|
|
365
|
+
return {
|
|
366
|
+
...definition,
|
|
367
|
+
async invoke(context, input) {
|
|
368
|
+
const actor = requireActor(context);
|
|
369
|
+
return invokeGuarded(() => invoke(actor, input));
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
function createBoringMcpAgentBridgeRegistry(handlers) {
|
|
374
|
+
const byName = Object.fromEntries(BORING_MCP_AGENT_BRIDGE_TOOL_DEFINITIONS.map((definition) => [definition.name, definition]));
|
|
375
|
+
return {
|
|
376
|
+
mcp_servers_list: tool(byName.mcp_servers_list, (actor, input) => {
|
|
377
|
+
parseEmptyInput(input);
|
|
378
|
+
return handlers.listSources(actor);
|
|
379
|
+
}),
|
|
380
|
+
mcp_server_status: tool(byName.mcp_server_status, (actor, input) => handlers.getSourceStatus(actor, parseSourceInput(input).sourceId)),
|
|
381
|
+
mcp_server_doctor: tool(byName.mcp_server_doctor, (actor, input) => handlers.doctorSource(actor, parseSourceInput(input).sourceId)),
|
|
382
|
+
mcp_server_probe: tool(byName.mcp_server_probe, (actor, input) => handlers.probeSource(actor, parseSourceInput(input).sourceId)),
|
|
383
|
+
mcp_tools_search: tool(byName.mcp_tools_search, (actor, input) => handlers.mcp_tools_search(actor, parseSearchInput(input))),
|
|
384
|
+
mcp_tool_describe: tool(byName.mcp_tool_describe, (actor, input) => handlers.mcp_tool_describe(actor, parseDescribeInput(input))),
|
|
385
|
+
mcp_readonly_call: tool(byName.mcp_readonly_call, (actor, input) => handlers.mcp_readonly_call(actor, parseReadonlyInput(input)))
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
function listBoringMcpAgentBridgeTools(registry) {
|
|
389
|
+
return BORING_MCP_AGENT_BRIDGE_TOOL_NAMES.map((name) => registry[name]);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// src/server/hardening.ts
|
|
393
|
+
var InMemoryMcpRateBudgetGate = class {
|
|
394
|
+
constructor(options) {
|
|
395
|
+
this.options = options;
|
|
396
|
+
}
|
|
397
|
+
options;
|
|
398
|
+
buckets = /* @__PURE__ */ new Map();
|
|
399
|
+
check(context) {
|
|
400
|
+
const now = Date.now();
|
|
401
|
+
const key = `${context.actor?.workspaceId ?? "unknown"}:${context.actor?.userId ?? "unknown"}:${context.sourceId}`;
|
|
402
|
+
const current = this.buckets.get(key);
|
|
403
|
+
const bucket = current && now - current.startedAt < this.options.windowMs ? current : { startedAt: now, calls: 0, toolCalls: 0 };
|
|
404
|
+
bucket.calls += 1;
|
|
405
|
+
if (context.operation === "callTool") bucket.toolCalls += 1;
|
|
406
|
+
this.buckets.set(key, bucket);
|
|
407
|
+
if (bucket.calls > this.options.maxCalls) {
|
|
408
|
+
throw new McpError(MCP_ERROR_CODES.RESOURCE_LIMIT_EXCEEDED, "MCP provider rate budget exceeded");
|
|
409
|
+
}
|
|
410
|
+
if (this.options.maxToolCalls !== void 0 && bucket.toolCalls > this.options.maxToolCalls) {
|
|
411
|
+
throw new McpError(MCP_ERROR_CODES.RESOURCE_LIMIT_EXCEEDED, "MCP provider tool-call budget exceeded");
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
function isAbortLike(error) {
|
|
416
|
+
return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
|
|
417
|
+
}
|
|
418
|
+
async function withProviderTimeout(operation, timeoutMs) {
|
|
419
|
+
if (!timeoutMs || timeoutMs <= 0) return operation();
|
|
420
|
+
let timeout;
|
|
421
|
+
try {
|
|
422
|
+
return await Promise.race([
|
|
423
|
+
operation(),
|
|
424
|
+
new Promise((_, reject) => {
|
|
425
|
+
timeout = setTimeout(() => reject(new McpError(MCP_ERROR_CODES.PROVIDER_TIMEOUT, "MCP provider operation timed out")), timeoutMs);
|
|
426
|
+
})
|
|
427
|
+
]);
|
|
428
|
+
} catch (error) {
|
|
429
|
+
if (error instanceof McpError && error.code === MCP_ERROR_CODES.PROVIDER_TIMEOUT) throw error;
|
|
430
|
+
if (isAbortLike(error)) throw new McpError(MCP_ERROR_CODES.PROVIDER_TIMEOUT, "MCP provider operation timed out");
|
|
431
|
+
throw error;
|
|
432
|
+
} finally {
|
|
433
|
+
if (timeout) clearTimeout(timeout);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
async function withMetadataRetry(operation, retries) {
|
|
437
|
+
let lastError;
|
|
438
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
439
|
+
try {
|
|
440
|
+
return await operation();
|
|
441
|
+
} catch (error) {
|
|
442
|
+
if (error instanceof McpError) throw error;
|
|
443
|
+
lastError = error;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
throw lastError;
|
|
447
|
+
}
|
|
448
|
+
function normalizeProviderError(error) {
|
|
449
|
+
if (error instanceof McpError) {
|
|
450
|
+
const safeMessage = containsMcpSecret(error.message) ? "MCP provider operation failed" : error.message;
|
|
451
|
+
throw new McpError(error.code, safeMessage, redactMcpSecrets(error.details));
|
|
452
|
+
}
|
|
453
|
+
const details = redactMcpSecrets(error instanceof Error ? { name: error.name, message: error.message } : error);
|
|
454
|
+
throw new McpError(MCP_ERROR_CODES.PROVIDER_ERROR, "MCP provider operation failed", details);
|
|
455
|
+
}
|
|
456
|
+
function createHardenedMcpTransport(transport, options = {}, actor) {
|
|
457
|
+
async function guard(context, operation, retryMetadata = false) {
|
|
458
|
+
const run = async () => {
|
|
459
|
+
await options.gate?.check({ ...context, actor });
|
|
460
|
+
return withProviderTimeout(operation, options.timeoutMs);
|
|
461
|
+
};
|
|
462
|
+
try {
|
|
463
|
+
return retryMetadata ? await withMetadataRetry(run, options.metadataRetries ?? 0) : await run();
|
|
464
|
+
} catch (error) {
|
|
465
|
+
normalizeProviderError(error);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return {
|
|
469
|
+
listTools(source, input) {
|
|
470
|
+
return guard({ sourceId: source.id, operation: "listTools" }, () => transport.listTools(source, input), true);
|
|
471
|
+
},
|
|
472
|
+
listResources(source) {
|
|
473
|
+
return guard({ sourceId: source.id, operation: "listResources" }, () => transport.listResources(source), true);
|
|
474
|
+
},
|
|
475
|
+
readResource(source, uri) {
|
|
476
|
+
return guard({ sourceId: source.id, operation: "readResource" }, () => transport.readResource(source, uri), true);
|
|
477
|
+
},
|
|
478
|
+
callTool(source, toolName, input) {
|
|
479
|
+
return guard({ sourceId: source.id, operation: "callTool", toolName }, () => transport.callTool(source, toolName, input), false);
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
async function verifyMcpDisconnectResult(registry, actor, sourceId, disconnected) {
|
|
484
|
+
if (!isActorOwnedMcpSource(actor, disconnected)) {
|
|
485
|
+
throw new McpError(MCP_ERROR_CODES.SOURCE_NOT_FOUND, "MCP source not found");
|
|
486
|
+
}
|
|
487
|
+
const disconnectedSource = disconnected;
|
|
488
|
+
const latest = await registry.getSource(sourceId);
|
|
489
|
+
const source = isActorOwnedMcpSource(actor, latest) ? latest : disconnectedSource;
|
|
490
|
+
if (source.status === "connected") {
|
|
491
|
+
throw new McpError(MCP_ERROR_CODES.SOURCE_UNAVAILABLE, "MCP source disconnect verification failed");
|
|
492
|
+
}
|
|
493
|
+
const result = createMcpSourceStatusPayload(source);
|
|
494
|
+
assertMcpPublicPayloadSecretFree(result);
|
|
495
|
+
return result;
|
|
496
|
+
}
|
|
497
|
+
function issue(code, message) {
|
|
498
|
+
return { level: "error", code, message };
|
|
499
|
+
}
|
|
500
|
+
function evaluateBoringMcpLaunchGate(input) {
|
|
501
|
+
const issues = [];
|
|
502
|
+
if (input.pluginId !== BORING_MCP_PLUGIN_ID) issues.push(issue("MCP_LAUNCH_PLUGIN_MISSING", "boring-mcp plugin id is not enabled"));
|
|
503
|
+
if (!input.registry?.listSources || !input.registry.getSource || !input.registry.disconnectSource) issues.push(issue("MCP_LAUNCH_REGISTRY_INCOMPLETE", "source registry must support list/get/disconnect"));
|
|
504
|
+
if (!input.transport?.listTools || !input.transport.listResources || !input.transport.readResource || !input.transport.callTool) issues.push(issue("MCP_LAUNCH_TRANSPORT_MISSING", "MCP transport client is not configured"));
|
|
505
|
+
for (const name of BORING_MCP_AGENT_BRIDGE_TOOL_NAMES) {
|
|
506
|
+
if (!input.bridge?.[name]) issues.push(issue("MCP_LAUNCH_BRIDGE_TOOL_MISSING", `missing bridge tool ${name}`));
|
|
507
|
+
}
|
|
508
|
+
if (!input.templates?.length) issues.push(issue("MCP_LAUNCH_TEMPLATES_MISSING", "at least one provider template must be configured"));
|
|
509
|
+
if (!input.hardening?.gate) issues.push(issue("MCP_LAUNCH_RATE_BUDGET_MISSING", "rate/budget gate is not configured"));
|
|
510
|
+
if (!input.hardening?.timeoutMs || input.hardening.timeoutMs <= 0) issues.push(issue("MCP_LAUNCH_TIMEOUT_MISSING", "provider timeout is not configured"));
|
|
511
|
+
if (!input.maxReadonlyInputBytes || input.maxReadonlyInputBytes <= 0) issues.push(issue("MCP_LAUNCH_INPUT_LIMIT_MISSING", "read-only input byte limit is not configured"));
|
|
512
|
+
if (!input.docsReviewed) issues.push(issue("MCP_LAUNCH_OPERATOR_DOCS_MISSING", "operator smoke checklist has not been reviewed"));
|
|
513
|
+
if (containsMcpSecret(input)) issues.push(issue("MCP_LAUNCH_SECRET_LEAK", "launch gate input looked like it contained a secret"));
|
|
514
|
+
return { ok: issues.length === 0, issues };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// src/server/toolCatalog.ts
|
|
518
|
+
import { createHash } from "crypto";
|
|
519
|
+
var InMemoryMcpToolCatalogCache = class {
|
|
520
|
+
values = /* @__PURE__ */ new Map();
|
|
521
|
+
async get(actor, sourceId) {
|
|
522
|
+
return this.values.get(`${actor.workspaceId}:${actor.userId}:${sourceId}`);
|
|
523
|
+
}
|
|
524
|
+
async set(actor, sourceId, result) {
|
|
525
|
+
this.values.set(`${actor.workspaceId}:${actor.userId}:${sourceId}`, result);
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
function stableJson(value) {
|
|
529
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
530
|
+
if (!value || typeof value !== "object") return JSON.stringify(value);
|
|
531
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
|
|
532
|
+
}
|
|
533
|
+
function createMcpSchemaHash(schema) {
|
|
534
|
+
return `sha256:${createHash("sha256").update(stableJson(schema ?? {})).digest("hex")}`;
|
|
535
|
+
}
|
|
536
|
+
function displayName(toolName) {
|
|
537
|
+
return toolName.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
|
538
|
+
}
|
|
539
|
+
function sourceCatalogRevision(source) {
|
|
540
|
+
return createHash("sha256").update(JSON.stringify({
|
|
541
|
+
status: source.status,
|
|
542
|
+
updatedAt: source.updatedAt,
|
|
543
|
+
sessionId: source.connectorRef?.sessionId,
|
|
544
|
+
connectedAccountId: source.connectorRef?.connectedAccountId,
|
|
545
|
+
externalSourceId: source.connectorRef?.externalSourceId
|
|
546
|
+
})).digest("hex");
|
|
547
|
+
}
|
|
548
|
+
function normalizeMcpCatalogTool(sourceId, provider, tool2, templates) {
|
|
549
|
+
const template = getMcpProviderTemplate(provider, templates);
|
|
550
|
+
const decision = template ? classifyMcpTool(template, tool2.name) : { allowed: false, risk: "unknown", reason: "Tool provider has no read-only allowlist" };
|
|
551
|
+
const inputSchema = tool2.inputSchema ?? {};
|
|
552
|
+
return {
|
|
553
|
+
sourceId,
|
|
554
|
+
provider,
|
|
555
|
+
toolName: tool2.name,
|
|
556
|
+
displayName: displayName(tool2.name),
|
|
557
|
+
summary: tool2.description ?? displayName(tool2.name),
|
|
558
|
+
description: tool2.description,
|
|
559
|
+
inputSchema,
|
|
560
|
+
risk: decision.risk,
|
|
561
|
+
enabled: decision.allowed,
|
|
562
|
+
blockedReasons: decision.allowed ? [] : [decision.reason],
|
|
563
|
+
schemaHash: createMcpSchemaHash(inputSchema),
|
|
564
|
+
nativeRef: { provider, action: tool2.name }
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
function matchesQuery(entry, query) {
|
|
568
|
+
const normalized = query.trim().toLowerCase();
|
|
569
|
+
if (!normalized) return true;
|
|
570
|
+
return [entry.toolName, entry.displayName, entry.summary, entry.description, entry.provider].filter(Boolean).some((value) => String(value).toLowerCase().includes(normalized));
|
|
571
|
+
}
|
|
572
|
+
function assertConnectedSource(sourceId, status) {
|
|
573
|
+
if (status !== "connected") {
|
|
574
|
+
throw new McpError(MCP_ERROR_CODES.SOURCE_UNAVAILABLE, `MCP source ${sourceId} is not connected`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
function createBoringMcpToolCatalog(options) {
|
|
578
|
+
const cache = options.cache ?? new InMemoryMcpToolCatalogCache();
|
|
579
|
+
async function loadSourceCatalog(actor, sourceId, refresh, providerRefresh) {
|
|
580
|
+
const requestedSourceId = validateMcpSourceId(sourceId);
|
|
581
|
+
const source = await requireActorOwnedMcpSource(options.registry, actor, requestedSourceId);
|
|
582
|
+
const resolvedSourceId = validateMcpSourceId(source.id);
|
|
583
|
+
assertConnectedSource(resolvedSourceId, source.status);
|
|
584
|
+
const sourceRevision = sourceCatalogRevision(source);
|
|
585
|
+
if (!refresh && !providerRefresh) {
|
|
586
|
+
const cached = await cache.get(actor, resolvedSourceId);
|
|
587
|
+
if (cached && cached.provider === source.provider && cached.sourceRevision === sourceRevision) return cached;
|
|
588
|
+
}
|
|
589
|
+
const discoveredTools = await options.transport.listTools(source, { forceProviderRefresh: providerRefresh ?? refresh });
|
|
590
|
+
const tools = discoveredTools.map((tool2) => normalizeMcpCatalogTool(resolvedSourceId, source.provider, tool2, options.templates));
|
|
591
|
+
const snapshot = { sourceId: resolvedSourceId, provider: source.provider, sourceRevision, tools };
|
|
592
|
+
assertMcpPublicPayloadSecretFree(snapshot);
|
|
593
|
+
await cache.set(actor, resolvedSourceId, snapshot);
|
|
594
|
+
return snapshot;
|
|
595
|
+
}
|
|
596
|
+
return {
|
|
597
|
+
async searchTools(actor, input = {}) {
|
|
598
|
+
const sources = input.sourceId ? [{ id: validateMcpSourceId(input.sourceId) }] : (await options.registry.listSources(actor)).filter((source) => source.status === "connected");
|
|
599
|
+
const catalog = [];
|
|
600
|
+
for (const source of sources) {
|
|
601
|
+
const result = await loadSourceCatalog(actor, source.id, input.refresh, input.providerRefresh);
|
|
602
|
+
catalog.push(...result.tools);
|
|
603
|
+
}
|
|
604
|
+
const response = { tools: catalog.filter((entry) => matchesQuery(entry, input.query ?? "")) };
|
|
605
|
+
assertMcpPublicPayloadSecretFree(response);
|
|
606
|
+
return response;
|
|
607
|
+
},
|
|
608
|
+
async describeTool(actor, input) {
|
|
609
|
+
const result = await loadSourceCatalog(actor, input.sourceId, input.refresh, input.providerRefresh);
|
|
610
|
+
const tool2 = result.tools.find((candidate) => candidate.toolName === input.toolName);
|
|
611
|
+
if (!tool2) throw new McpError(MCP_ERROR_CODES.TOOL_NOT_FOUND, "MCP tool not found");
|
|
612
|
+
const schemaDrifted = Boolean(input.expectedSchemaHash && input.expectedSchemaHash !== tool2.schemaHash);
|
|
613
|
+
if (containsMcpSecret(tool2)) throw new McpError(MCP_ERROR_CODES.SECRET_LEAK_GUARD, "MCP tool metadata looked like it contained a secret");
|
|
614
|
+
const response = { tool: tool2, schemaDrifted };
|
|
615
|
+
assertMcpPublicPayloadSecretFree(response);
|
|
616
|
+
return response;
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// src/server/readonlyCall.ts
|
|
622
|
+
var DEFAULT_MAX_INPUT_BYTES = 64 * 1024;
|
|
623
|
+
var INVALID_AUDIT_VALUE = "[invalid]";
|
|
624
|
+
function isPlainRecord(value) {
|
|
625
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
626
|
+
const prototype = Object.getPrototypeOf(value);
|
|
627
|
+
return prototype === Object.prototype || prototype === null;
|
|
628
|
+
}
|
|
629
|
+
function normalizeJsonValue(value) {
|
|
630
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
|
|
631
|
+
if (typeof value === "number") {
|
|
632
|
+
if (!Number.isFinite(value)) throw new McpError(MCP_ERROR_CODES.INPUT_INVALID, "MCP tool input must be JSON-safe");
|
|
633
|
+
return value;
|
|
634
|
+
}
|
|
635
|
+
if (Array.isArray(value)) return value.map(normalizeJsonValue);
|
|
636
|
+
if (isPlainRecord(value)) {
|
|
637
|
+
return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, normalizeJsonValue(nested)]));
|
|
638
|
+
}
|
|
639
|
+
throw new McpError(MCP_ERROR_CODES.INPUT_INVALID, "MCP tool input must be JSON-safe");
|
|
640
|
+
}
|
|
641
|
+
function inputSizeBytes(input) {
|
|
642
|
+
return new TextEncoder().encode(JSON.stringify(input)).byteLength;
|
|
643
|
+
}
|
|
644
|
+
function parseReadonlyCall(input, maxInputBytes) {
|
|
645
|
+
if (!isPlainRecord(input)) throw new McpError(MCP_ERROR_CODES.INPUT_INVALID, "MCP readonly call input must be an object");
|
|
646
|
+
if (typeof input.sourceId !== "string") throw new McpError(MCP_ERROR_CODES.INPUT_INVALID, "MCP sourceId must be a string");
|
|
647
|
+
if (typeof input.toolName !== "string") throw new McpError(MCP_ERROR_CODES.INPUT_INVALID, "MCP toolName must be a string");
|
|
648
|
+
if (input.expectedSchemaHash !== void 0 && (typeof input.expectedSchemaHash !== "string" || !isValidSchemaHash(input.expectedSchemaHash))) {
|
|
649
|
+
throw new McpError(MCP_ERROR_CODES.INPUT_INVALID, "MCP expectedSchemaHash must be a sha256 schema hash");
|
|
650
|
+
}
|
|
651
|
+
if (containsMcpSecret(input.sourceId) || containsMcpSecret(input.toolName)) {
|
|
652
|
+
throw new McpError(MCP_ERROR_CODES.INPUT_INVALID, "MCP readonly call identifiers looked like they contained a secret");
|
|
653
|
+
}
|
|
654
|
+
const toolInput = normalizeJsonValue(input.input ?? {});
|
|
655
|
+
if (inputSizeBytes(toolInput) > maxInputBytes) {
|
|
656
|
+
throw new McpError(MCP_ERROR_CODES.RESOURCE_LIMIT_EXCEEDED, "MCP tool input is too large");
|
|
657
|
+
}
|
|
658
|
+
if (containsMcpSecret(toolInput)) {
|
|
659
|
+
throw new McpError(MCP_ERROR_CODES.SECRET_LEAK_GUARD, "MCP tool input looked like it contained a secret");
|
|
660
|
+
}
|
|
661
|
+
const sourceId = validateMcpSourceId(input.sourceId);
|
|
662
|
+
validateMcpToolName(input.toolName);
|
|
663
|
+
return {
|
|
664
|
+
sourceId,
|
|
665
|
+
toolName: input.toolName,
|
|
666
|
+
expectedSchemaHash: input.expectedSchemaHash,
|
|
667
|
+
toolInput
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
function assertReadOnlyTool(tool2) {
|
|
671
|
+
if (!tool2.enabled || tool2.risk !== "read") {
|
|
672
|
+
throw new McpError(MCP_ERROR_CODES.TOOL_NOT_ALLOWED, tool2.blockedReasons[0] ?? "MCP tool is not allowed for read-only execution");
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
function assertSchemaCurrent(tool2, expectedSchemaHash) {
|
|
676
|
+
if (expectedSchemaHash && expectedSchemaHash !== tool2.schemaHash) {
|
|
677
|
+
throw new McpError(MCP_ERROR_CODES.PROVIDER_TOOL_DRIFT, "MCP tool schema changed before execution");
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
function assertTemplateAllowsReadonly(sourceProvider, toolName, templates) {
|
|
681
|
+
const template = getMcpProviderTemplate(sourceProvider, templates);
|
|
682
|
+
const decision = template ? classifyMcpTool(template, toolName) : { allowed: false, risk: "unknown", reason: "Tool provider has no read-only allowlist" };
|
|
683
|
+
if (!decision.allowed || decision.risk !== "read") throw new McpError(MCP_ERROR_CODES.TOOL_NOT_ALLOWED, decision.reason);
|
|
684
|
+
}
|
|
685
|
+
function auditEvent(actor, input, outcome, code) {
|
|
686
|
+
return {
|
|
687
|
+
operation: "mcp_readonly_call",
|
|
688
|
+
outcome,
|
|
689
|
+
workspaceId: actor.workspaceId,
|
|
690
|
+
userId: actor.userId,
|
|
691
|
+
sourceId: input.sourceId,
|
|
692
|
+
toolName: input.toolName,
|
|
693
|
+
expectedSchemaHash: input.expectedSchemaHash,
|
|
694
|
+
code
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
function toErrorCode(error) {
|
|
698
|
+
return error instanceof McpError ? error.code : MCP_ERROR_CODES.PROVIDER_ERROR;
|
|
699
|
+
}
|
|
700
|
+
function isValidSchemaHash(value) {
|
|
701
|
+
return /^sha256:[a-f0-9]{64}$/.test(value);
|
|
702
|
+
}
|
|
703
|
+
function auditSourceId(value) {
|
|
704
|
+
if (typeof value !== "string" || containsMcpSecret(value)) return INVALID_AUDIT_VALUE;
|
|
705
|
+
try {
|
|
706
|
+
return validateMcpSourceId(value);
|
|
707
|
+
} catch {
|
|
708
|
+
return INVALID_AUDIT_VALUE;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
function auditToolName(value) {
|
|
712
|
+
if (typeof value !== "string" || containsMcpSecret(value)) return INVALID_AUDIT_VALUE;
|
|
713
|
+
try {
|
|
714
|
+
validateMcpToolName(value);
|
|
715
|
+
return value;
|
|
716
|
+
} catch {
|
|
717
|
+
return INVALID_AUDIT_VALUE;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
function auditSchemaHash(value) {
|
|
721
|
+
return typeof value === "string" && !containsMcpSecret(value) && isValidSchemaHash(value) ? value : void 0;
|
|
722
|
+
}
|
|
723
|
+
function createBoringMcpReadonlyCaller(options) {
|
|
724
|
+
const catalog = createBoringMcpToolCatalog({ ...options, cache: options.catalogCache });
|
|
725
|
+
const maxInputBytes = options.maxInputBytes ?? DEFAULT_MAX_INPUT_BYTES;
|
|
726
|
+
async function record2(event) {
|
|
727
|
+
try {
|
|
728
|
+
await options.audit?.record(event);
|
|
729
|
+
} catch {
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return {
|
|
733
|
+
async callReadonly(actor, input) {
|
|
734
|
+
const raw = isPlainRecord(input) ? input : {};
|
|
735
|
+
let request = {
|
|
736
|
+
sourceId: auditSourceId(raw.sourceId),
|
|
737
|
+
toolName: auditToolName(raw.toolName),
|
|
738
|
+
expectedSchemaHash: auditSchemaHash(raw.expectedSchemaHash)
|
|
739
|
+
};
|
|
740
|
+
let audited = false;
|
|
741
|
+
try {
|
|
742
|
+
const parsed = parseReadonlyCall(input, maxInputBytes);
|
|
743
|
+
request = parsed;
|
|
744
|
+
const source = await requireActorOwnedMcpSource(options.registry, actor, parsed.sourceId);
|
|
745
|
+
if (source.status !== "connected") throw new McpError(MCP_ERROR_CODES.SOURCE_UNAVAILABLE, "MCP source is not connected");
|
|
746
|
+
assertTemplateAllowsReadonly(source.provider, parsed.toolName, options.templates);
|
|
747
|
+
const described = await catalog.describeTool(actor, {
|
|
748
|
+
sourceId: parsed.sourceId,
|
|
749
|
+
toolName: parsed.toolName,
|
|
750
|
+
expectedSchemaHash: parsed.expectedSchemaHash,
|
|
751
|
+
refresh: true,
|
|
752
|
+
providerRefresh: Boolean(parsed.expectedSchemaHash)
|
|
753
|
+
});
|
|
754
|
+
assertReadOnlyTool(described.tool);
|
|
755
|
+
assertSchemaCurrent(described.tool, parsed.expectedSchemaHash);
|
|
756
|
+
let providerResult;
|
|
757
|
+
try {
|
|
758
|
+
providerResult = await options.transport.callTool(source, parsed.toolName, parsed.toolInput);
|
|
759
|
+
} catch (error) {
|
|
760
|
+
if (error instanceof McpError) {
|
|
761
|
+
const safeMessage = containsMcpSecret(error.message) ? "MCP provider tool call failed" : error.message;
|
|
762
|
+
await record2(auditEvent(actor, request, "failure", error.code));
|
|
763
|
+
audited = true;
|
|
764
|
+
throw new McpError(error.code, safeMessage, redactMcpSecrets(error.details));
|
|
765
|
+
}
|
|
766
|
+
const redacted2 = redactMcpSecrets(error instanceof Error ? { name: error.name, message: error.message } : error);
|
|
767
|
+
await record2(auditEvent(actor, request, "failure", MCP_ERROR_CODES.PROVIDER_ERROR));
|
|
768
|
+
audited = true;
|
|
769
|
+
throw new McpError(MCP_ERROR_CODES.PROVIDER_ERROR, "MCP provider tool call failed", redacted2);
|
|
770
|
+
}
|
|
771
|
+
const redacted = redactMcpSecrets(providerResult);
|
|
772
|
+
if (containsMcpSecret(providerResult)) {
|
|
773
|
+
await record2(auditEvent(actor, request, "failure", MCP_ERROR_CODES.SECRET_LEAK_GUARD));
|
|
774
|
+
audited = true;
|
|
775
|
+
throw new McpError(MCP_ERROR_CODES.SECRET_LEAK_GUARD, "MCP provider response looked like it contained a secret");
|
|
776
|
+
}
|
|
777
|
+
const response = { content: redacted };
|
|
778
|
+
assertMcpPublicPayloadSecretFree(response);
|
|
779
|
+
await record2(auditEvent(actor, request, "success"));
|
|
780
|
+
audited = true;
|
|
781
|
+
return response;
|
|
782
|
+
} catch (error) {
|
|
783
|
+
const code = toErrorCode(error);
|
|
784
|
+
if (!audited) {
|
|
785
|
+
await record2(auditEvent(actor, request, "blocked", code));
|
|
786
|
+
}
|
|
787
|
+
throw error;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// src/server/sourceHandlers.ts
|
|
794
|
+
function createBoringMcpSourceHandlers(options) {
|
|
795
|
+
const catalogCache = new InMemoryMcpToolCatalogCache();
|
|
796
|
+
function transportFor(actor) {
|
|
797
|
+
return createHardenedMcpTransport(options.transport, options.hardening, actor);
|
|
798
|
+
}
|
|
799
|
+
function facadeFor(actor) {
|
|
800
|
+
return new McpAccessFacade({ store: options.registry, transport: transportFor(actor), templates: options.templates });
|
|
801
|
+
}
|
|
802
|
+
function catalogFor(actor) {
|
|
803
|
+
return createBoringMcpToolCatalog({ ...options, transport: transportFor(actor), cache: catalogCache });
|
|
804
|
+
}
|
|
805
|
+
function readonlyCallerFor(actor) {
|
|
806
|
+
return createBoringMcpReadonlyCaller({
|
|
807
|
+
registry: options.registry,
|
|
808
|
+
transport: transportFor(actor),
|
|
809
|
+
templates: options.templates,
|
|
810
|
+
maxInputBytes: options.maxReadonlyInputBytes,
|
|
811
|
+
audit: options.audit,
|
|
812
|
+
catalogCache
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
return {
|
|
816
|
+
async listSources(actor) {
|
|
817
|
+
const result = { sources: (await facadeFor(actor).listSources(actor)).map(toMcpSourceDto) };
|
|
818
|
+
assertMcpPublicPayloadSecretFree(result);
|
|
819
|
+
return result;
|
|
820
|
+
},
|
|
821
|
+
async getSourceStatus(actor, sourceId) {
|
|
822
|
+
const source = await requireActorOwnedMcpSource(options.registry, actor, sourceId);
|
|
823
|
+
return createMcpSourceStatusPayload(source);
|
|
824
|
+
},
|
|
825
|
+
async doctorSource(actor, sourceId) {
|
|
826
|
+
const source = await requireActorOwnedMcpSource(options.registry, actor, sourceId);
|
|
827
|
+
const result = doctorMcpSource(source, options.templates);
|
|
828
|
+
assertMcpPublicPayloadSecretFree(result);
|
|
829
|
+
return result;
|
|
830
|
+
},
|
|
831
|
+
async probeSource(actor, sourceId) {
|
|
832
|
+
const normalizedSourceId = validateMcpSourceId(sourceId);
|
|
833
|
+
const result = await facadeFor(actor).probeSource(actor, normalizedSourceId);
|
|
834
|
+
assertMcpPublicPayloadSecretFree(result);
|
|
835
|
+
return result;
|
|
836
|
+
},
|
|
837
|
+
async searchTools(actor, input) {
|
|
838
|
+
return catalogFor(actor).searchTools(actor, input);
|
|
839
|
+
},
|
|
840
|
+
async describeTool(actor, input) {
|
|
841
|
+
return catalogFor(actor).describeTool(actor, input);
|
|
842
|
+
},
|
|
843
|
+
async mcp_tools_search(actor, input) {
|
|
844
|
+
return catalogFor(actor).searchTools(actor, input);
|
|
845
|
+
},
|
|
846
|
+
async mcp_tool_describe(actor, input) {
|
|
847
|
+
return catalogFor(actor).describeTool(actor, input);
|
|
848
|
+
},
|
|
849
|
+
async callReadonly(actor, input) {
|
|
850
|
+
return readonlyCallerFor(actor).callReadonly(actor, input);
|
|
851
|
+
},
|
|
852
|
+
async mcp_readonly_call(actor, input) {
|
|
853
|
+
return readonlyCallerFor(actor).callReadonly(actor, input);
|
|
854
|
+
},
|
|
855
|
+
async disconnectSource(actor, sourceId) {
|
|
856
|
+
const normalizedSourceId = validateMcpSourceId(sourceId);
|
|
857
|
+
await requireActorOwnedMcpSource(options.registry, actor, normalizedSourceId);
|
|
858
|
+
if (!options.registry.disconnectSource) {
|
|
859
|
+
throw new McpError(MCP_ERROR_CODES.SOURCE_UNAVAILABLE, "MCP source disconnect is not configured");
|
|
860
|
+
}
|
|
861
|
+
const source = await options.registry.disconnectSource(actor, normalizedSourceId);
|
|
862
|
+
return verifyMcpDisconnectResult(options.registry, actor, normalizedSourceId, source);
|
|
863
|
+
}
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// src/server/agentTools.ts
|
|
868
|
+
function toolResult(value) {
|
|
869
|
+
return { content: [{ type: "text", text: JSON.stringify(value) }] };
|
|
870
|
+
}
|
|
871
|
+
function createBoringMcpAgentTools(options) {
|
|
872
|
+
const handlers = createBoringMcpSourceHandlers(options);
|
|
873
|
+
const bridge = createBoringMcpAgentBridgeRegistry(handlers);
|
|
874
|
+
return listBoringMcpAgentBridgeTools(bridge).map((tool2) => ({
|
|
875
|
+
name: tool2.name,
|
|
876
|
+
description: tool2.description,
|
|
877
|
+
parameters: tool2.inputSchema,
|
|
878
|
+
promptSnippet: `${tool2.name}: ${tool2.description}`,
|
|
879
|
+
async execute(params, ctx) {
|
|
880
|
+
const actor = await options.resolveActor(params, ctx);
|
|
881
|
+
return toolResult(await tool2.invoke({ actor }, params));
|
|
882
|
+
}
|
|
883
|
+
}));
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// src/server/mcpSdkTransport.ts
|
|
887
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
888
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
889
|
+
async function resolveEndpoint(options, source) {
|
|
890
|
+
return typeof options.endpoint === "function" ? options.endpoint({ source }) : options.endpoint;
|
|
891
|
+
}
|
|
892
|
+
function normalizeUrl(value) {
|
|
893
|
+
try {
|
|
894
|
+
return value instanceof URL ? value : new URL(value);
|
|
895
|
+
} catch {
|
|
896
|
+
throw new McpError(MCP_ERROR_CODES.PROVIDER_CONFIG_INVALID, "Invalid MCP endpoint URL");
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
function normalizeHeaders(headers) {
|
|
900
|
+
if (!headers) return void 0;
|
|
901
|
+
return Object.fromEntries(Object.entries(headers).filter(([key, value]) => key.trim() && typeof value === "string"));
|
|
902
|
+
}
|
|
903
|
+
function normalizeError(error) {
|
|
904
|
+
if (error instanceof McpError) return error;
|
|
905
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
906
|
+
return new McpError(MCP_ERROR_CODES.PROVIDER_ERROR, "MCP provider request failed", { message: redactMcpSecrets(message) });
|
|
907
|
+
}
|
|
908
|
+
async function withClient(options, source, run) {
|
|
909
|
+
const client = new Client({ name: options.clientName ?? "boring-mcp", version: options.clientVersion ?? "0.0.0" });
|
|
910
|
+
try {
|
|
911
|
+
const endpoint = await resolveEndpoint(options, source);
|
|
912
|
+
const transport = new StreamableHTTPClientTransport(normalizeUrl(endpoint.url), {
|
|
913
|
+
requestInit: { headers: normalizeHeaders(endpoint.headers) }
|
|
914
|
+
});
|
|
915
|
+
await client.connect(transport);
|
|
916
|
+
return await run(client);
|
|
917
|
+
} catch (error) {
|
|
918
|
+
throw normalizeError(error);
|
|
919
|
+
} finally {
|
|
920
|
+
await client.close().catch(() => void 0);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
function normalizeTool(tool2) {
|
|
924
|
+
return { name: tool2.name, description: tool2.description, inputSchema: tool2.inputSchema };
|
|
925
|
+
}
|
|
926
|
+
function normalizeResource(resource) {
|
|
927
|
+
return { uri: resource.uri, name: resource.name, description: resource.description, mimeType: resource.mimeType };
|
|
928
|
+
}
|
|
929
|
+
function createMcpSdkStreamableHttpTransport(options) {
|
|
930
|
+
return {
|
|
931
|
+
async listTools(source) {
|
|
932
|
+
return withClient(options, source, async (client) => {
|
|
933
|
+
const result = await client.listTools();
|
|
934
|
+
return result.tools.map(normalizeTool);
|
|
935
|
+
});
|
|
936
|
+
},
|
|
937
|
+
async listResources(source) {
|
|
938
|
+
return withClient(options, source, async (client) => {
|
|
939
|
+
const result = await client.listResources();
|
|
940
|
+
return result.resources.map(normalizeResource);
|
|
941
|
+
});
|
|
942
|
+
},
|
|
943
|
+
async readResource(source, uri) {
|
|
944
|
+
return withClient(options, source, async (client) => client.readResource({ uri }));
|
|
945
|
+
},
|
|
946
|
+
async callTool(source, toolName, input) {
|
|
947
|
+
return withClient(options, source, async (client) => {
|
|
948
|
+
const result = await client.callTool({ name: toolName, arguments: input && typeof input === "object" ? input : {} });
|
|
949
|
+
return { content: result.content };
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// src/server/composioManagedConnector.ts
|
|
956
|
+
function trimTrailingSlash(value) {
|
|
957
|
+
return value.endsWith("/") ? value.slice(0, -1) : value;
|
|
958
|
+
}
|
|
959
|
+
function composioUserId(actor) {
|
|
960
|
+
return `${actor.workspaceId}:${actor.userId}`;
|
|
961
|
+
}
|
|
962
|
+
function actorForSource(source) {
|
|
963
|
+
return { userId: source.userId, workspaceId: source.workspaceId };
|
|
964
|
+
}
|
|
965
|
+
function providerError(message, details) {
|
|
966
|
+
return new McpError(MCP_ERROR_CODES.PROVIDER_ERROR, message, redactMcpSecrets(details));
|
|
967
|
+
}
|
|
968
|
+
function providerErrorStatus(error) {
|
|
969
|
+
if (!(error instanceof McpError) || error.code !== MCP_ERROR_CODES.PROVIDER_ERROR) return void 0;
|
|
970
|
+
const details = record(error.details);
|
|
971
|
+
return typeof details.status === "number" ? details.status : void 0;
|
|
972
|
+
}
|
|
973
|
+
function requireServerSecret(secret) {
|
|
974
|
+
if (secret.storage !== "server-env" && secret.storage !== "server-vault" || !secret.value) {
|
|
975
|
+
throw new McpError(MCP_ERROR_CODES.PROVIDER_CONFIG_INVALID, "Composio connector secret is not configured server-side");
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
async function readJsonResponse(response) {
|
|
979
|
+
const text = await response.text();
|
|
980
|
+
if (!text) return void 0;
|
|
981
|
+
try {
|
|
982
|
+
return JSON.parse(text);
|
|
983
|
+
} catch {
|
|
984
|
+
throw providerError("Composio returned non-JSON response", { status: response.status });
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
function record(value) {
|
|
988
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
989
|
+
}
|
|
990
|
+
function array(value) {
|
|
991
|
+
return Array.isArray(value) ? value : [];
|
|
992
|
+
}
|
|
993
|
+
function optionalString(value) {
|
|
994
|
+
return typeof value === "string" && value.trim() ? value : void 0;
|
|
995
|
+
}
|
|
996
|
+
function optionalHeaders(value) {
|
|
997
|
+
const entries = Object.entries(record(value)).filter((entry) => typeof entry[1] === "string");
|
|
998
|
+
return entries.length ? Object.fromEntries(entries) : void 0;
|
|
999
|
+
}
|
|
1000
|
+
function normalizeComposioMcpUrl(rawUrl, options) {
|
|
1001
|
+
let parsed;
|
|
1002
|
+
try {
|
|
1003
|
+
parsed = new URL(rawUrl);
|
|
1004
|
+
} catch {
|
|
1005
|
+
throw providerError("Composio session response included an invalid MCP URL");
|
|
1006
|
+
}
|
|
1007
|
+
const loopback = parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" || parsed.hostname === "::1";
|
|
1008
|
+
const allowedInsecureLoopback = options.allowInsecureMcpUrlsForTests && parsed.protocol === "http:" && loopback;
|
|
1009
|
+
if (parsed.protocol !== "https:" && !allowedInsecureLoopback || parsed.username || parsed.password) {
|
|
1010
|
+
throw new McpError(MCP_ERROR_CODES.PROVIDER_CONFIG_INVALID, "Composio MCP URL must be https and must not include credentials");
|
|
1011
|
+
}
|
|
1012
|
+
return parsed.toString();
|
|
1013
|
+
}
|
|
1014
|
+
function extractSession(payload, options) {
|
|
1015
|
+
const root = record(payload);
|
|
1016
|
+
const session = record(root.session ?? root.data ?? root);
|
|
1017
|
+
const mcp = record(session.mcp);
|
|
1018
|
+
const id = optionalString(session.id) ?? optionalString(session.session_id) ?? optionalString(root.id) ?? optionalString(root.session_id);
|
|
1019
|
+
const url = optionalString(mcp.url);
|
|
1020
|
+
if (!id || !url) throw providerError("Composio session response did not include an MCP session", payload);
|
|
1021
|
+
return { id, mcp: { url: normalizeComposioMcpUrl(url, options), headers: optionalHeaders(mcp.headers) } };
|
|
1022
|
+
}
|
|
1023
|
+
function extractConnectUrl(payload) {
|
|
1024
|
+
const root = record(payload);
|
|
1025
|
+
const nested = record(root.data ?? root.connection_request ?? root.link ?? root);
|
|
1026
|
+
return optionalString(root.redirect_url) ?? optionalString(root.redirectUrl) ?? optionalString(root.connect_url) ?? optionalString(root.connectUrl) ?? optionalString(root.url) ?? optionalString(nested.redirect_url) ?? optionalString(nested.redirectUrl) ?? optionalString(nested.connect_url) ?? optionalString(nested.connectUrl) ?? optionalString(nested.url);
|
|
1027
|
+
}
|
|
1028
|
+
function extractProviderAccountLabel(payload) {
|
|
1029
|
+
const root = record(payload);
|
|
1030
|
+
const nested = record(root.data ?? root.connected_account ?? root.account ?? root);
|
|
1031
|
+
return optionalString(root.providerAccountLabel) ?? optionalString(root.provider_account_label) ?? optionalString(nested.label) ?? optionalString(nested.email) ?? optionalString(nested.name) ?? optionalString(nested.alias);
|
|
1032
|
+
}
|
|
1033
|
+
async function composioRequest(options, secret, method, path, body) {
|
|
1034
|
+
requireServerSecret(secret);
|
|
1035
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
1036
|
+
if (!fetchImpl) throw new McpError(MCP_ERROR_CODES.PROVIDER_CONFIG_INVALID, "fetch is not available for Composio connector");
|
|
1037
|
+
const response = await fetchImpl(`${trimTrailingSlash(options.apiBaseUrl ?? "https://backend.composio.dev")}${path}`, {
|
|
1038
|
+
method,
|
|
1039
|
+
headers: {
|
|
1040
|
+
"content-type": "application/json",
|
|
1041
|
+
"x-api-key": secret.value
|
|
1042
|
+
},
|
|
1043
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
1044
|
+
});
|
|
1045
|
+
const payload = await readJsonResponse(response);
|
|
1046
|
+
if (!response.ok) throw providerError("Composio request failed", { status: response.status, payload });
|
|
1047
|
+
return payload;
|
|
1048
|
+
}
|
|
1049
|
+
function composioPost(options, secret, path, body) {
|
|
1050
|
+
return composioRequest(options, secret, "POST", path, body);
|
|
1051
|
+
}
|
|
1052
|
+
function composioGet(options, secret, path) {
|
|
1053
|
+
return composioRequest(options, secret, "GET", path);
|
|
1054
|
+
}
|
|
1055
|
+
function composioDelete(options, secret, path) {
|
|
1056
|
+
return composioRequest(options, secret, "DELETE", path);
|
|
1057
|
+
}
|
|
1058
|
+
async function resolveComposioMcpSession(options, input) {
|
|
1059
|
+
const payload = await composioPost(options, input.secret, "/api/v3.1/tool_router/session", {
|
|
1060
|
+
user_id: composioUserId(input.actor),
|
|
1061
|
+
mcp: true,
|
|
1062
|
+
toolkits: { enable: [input.config.toolkitId] },
|
|
1063
|
+
manage_connections: {
|
|
1064
|
+
enable: true,
|
|
1065
|
+
enable_wait_for_connections: false,
|
|
1066
|
+
callback_url: options.callbackUrl
|
|
1067
|
+
}
|
|
1068
|
+
});
|
|
1069
|
+
return extractSession(payload, options);
|
|
1070
|
+
}
|
|
1071
|
+
async function createLink(options, secret, sessionId, config) {
|
|
1072
|
+
return composioPost(options, secret, `/api/v3/tool_router/session/${encodeURIComponent(sessionId)}/link`, {
|
|
1073
|
+
toolkit: config.toolkitId,
|
|
1074
|
+
callback_url: options.callbackUrl
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
function accountIsForConfig(account, actor, config) {
|
|
1078
|
+
const toolkit = record(account.toolkit);
|
|
1079
|
+
return optionalString(toolkit.slug) === config.toolkitId && optionalString(account.user_id) === composioUserId(actor);
|
|
1080
|
+
}
|
|
1081
|
+
function summarizeAccount(account) {
|
|
1082
|
+
const status = optionalString(account.status)?.toUpperCase();
|
|
1083
|
+
const disabled = account.is_disabled === true || record(account.auth_config).is_disabled === true;
|
|
1084
|
+
return {
|
|
1085
|
+
id: optionalString(account.id) ?? optionalString(account.nanoid) ?? optionalString(account.word_id),
|
|
1086
|
+
label: extractProviderAccountLabel(account),
|
|
1087
|
+
active: !disabled && (status === "ACTIVE" || status === "CONNECTED" || status === "ENABLED")
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
async function findConnectedAccount(options, input) {
|
|
1091
|
+
const params = new URLSearchParams({ user_id: composioUserId(input.actor), toolkit_slug: input.config.toolkitId });
|
|
1092
|
+
const payload = await composioGet(options, input.secret, `/api/v3.1/connected_accounts?${params}`);
|
|
1093
|
+
const items = array(record(payload).items).map(record).filter((account) => accountIsForConfig(account, input.actor, input.config)).map(summarizeAccount);
|
|
1094
|
+
return items.find((account) => account.active) ?? items[0];
|
|
1095
|
+
}
|
|
1096
|
+
function composioMcpHeaders(session, secret) {
|
|
1097
|
+
requireServerSecret(secret);
|
|
1098
|
+
return { ...session.mcp.headers ?? {}, "x-api-key": secret.value };
|
|
1099
|
+
}
|
|
1100
|
+
function transportForSession(options, session, secret) {
|
|
1101
|
+
return createMcpSdkStreamableHttpTransport({
|
|
1102
|
+
endpoint: { url: session.mcp.url, headers: composioMcpHeaders(session, secret) },
|
|
1103
|
+
clientName: options.clientName ?? "boring-mcp-composio",
|
|
1104
|
+
clientVersion: options.clientVersion ?? "0.0.0"
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
var COMPOSIO_SEARCH_TOOLS = "COMPOSIO_SEARCH_TOOLS";
|
|
1108
|
+
var COMPOSIO_GET_TOOL_SCHEMAS = "COMPOSIO_GET_TOOL_SCHEMAS";
|
|
1109
|
+
var COMPOSIO_MULTI_EXECUTE_TOOL = "COMPOSIO_MULTI_EXECUTE_TOOL";
|
|
1110
|
+
function isComposioMetaTool(toolName) {
|
|
1111
|
+
return toolName.startsWith("COMPOSIO_");
|
|
1112
|
+
}
|
|
1113
|
+
function safeTools(tools) {
|
|
1114
|
+
return tools.filter((tool2) => !isComposioMetaTool(tool2.name));
|
|
1115
|
+
}
|
|
1116
|
+
function jsonFromTextContent(value) {
|
|
1117
|
+
const root = record(value);
|
|
1118
|
+
const content = array(root.content);
|
|
1119
|
+
for (const item of content) {
|
|
1120
|
+
const text = optionalString(record(item).text);
|
|
1121
|
+
if (!text) continue;
|
|
1122
|
+
try {
|
|
1123
|
+
return JSON.parse(text);
|
|
1124
|
+
} catch {
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
return void 0;
|
|
1128
|
+
}
|
|
1129
|
+
function concreteAllowedToolSlugs(provider) {
|
|
1130
|
+
return getMcpProviderTemplate(provider)?.allowedTools.filter((toolName) => !toolName.includes("*")) ?? [];
|
|
1131
|
+
}
|
|
1132
|
+
function collectComposioToolSlugs(payload, toolkitId) {
|
|
1133
|
+
const root = record(jsonFromTextContent(payload) ?? payload);
|
|
1134
|
+
const data = record(root.data ?? root);
|
|
1135
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1136
|
+
for (const item of array(data.results)) {
|
|
1137
|
+
const result = record(item);
|
|
1138
|
+
const toolkits = array(result.toolkits).filter((toolkit) => typeof toolkit === "string");
|
|
1139
|
+
if (toolkits.length > 0 && !toolkits.some((toolkit) => toolkit.toLowerCase() === toolkitId.toLowerCase())) continue;
|
|
1140
|
+
for (const slug of [...array(result.primary_tool_slugs), ...array(result.related_tool_slugs)]) {
|
|
1141
|
+
if (typeof slug === "string" && slug.trim() && !isComposioMetaTool(slug)) seen.add(slug);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
return [...seen].slice(0, 50);
|
|
1145
|
+
}
|
|
1146
|
+
function toolsFromComposioSchemas(payload, slugs) {
|
|
1147
|
+
const root = record(jsonFromTextContent(payload) ?? payload);
|
|
1148
|
+
const data = record(root.data ?? root);
|
|
1149
|
+
const schemas = record(data.tool_schemas ?? root.tool_schemas);
|
|
1150
|
+
const tools = [];
|
|
1151
|
+
for (const slug of slugs) {
|
|
1152
|
+
const rawSchema = schemas[slug];
|
|
1153
|
+
if (!rawSchema || typeof rawSchema !== "object" || Array.isArray(rawSchema)) continue;
|
|
1154
|
+
const schema = rawSchema;
|
|
1155
|
+
tools.push({
|
|
1156
|
+
name: optionalString(schema.tool_slug) ?? slug,
|
|
1157
|
+
description: optionalString(schema.description),
|
|
1158
|
+
inputSchema: schema.input_schema ?? {}
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
return tools;
|
|
1162
|
+
}
|
|
1163
|
+
async function discoverComposioToolkitTools(input) {
|
|
1164
|
+
const searchResult = await input.transport.callTool(input.source, COMPOSIO_SEARCH_TOOLS, {
|
|
1165
|
+
queries: [input.config.toolkitId],
|
|
1166
|
+
session: input.session.id
|
|
1167
|
+
});
|
|
1168
|
+
const slugs = [...new Set([
|
|
1169
|
+
...collectComposioToolSlugs(searchResult, input.config.toolkitId),
|
|
1170
|
+
...concreteAllowedToolSlugs(input.source.provider)
|
|
1171
|
+
].filter((toolName) => !isComposioMetaTool(toolName)))];
|
|
1172
|
+
if (slugs.length === 0) return [];
|
|
1173
|
+
const schemaResult = await input.transport.callTool(input.source, COMPOSIO_GET_TOOL_SCHEMAS, {
|
|
1174
|
+
tool_slugs: slugs,
|
|
1175
|
+
session_id: input.session.id
|
|
1176
|
+
});
|
|
1177
|
+
return toolsFromComposioSchemas(schemaResult, slugs);
|
|
1178
|
+
}
|
|
1179
|
+
function createComposioManagedConnectorProvider(options = {}) {
|
|
1180
|
+
return {
|
|
1181
|
+
async startConnect({ actor, config, secret }) {
|
|
1182
|
+
const session = await resolveComposioMcpSession(options, { actor, config, secret });
|
|
1183
|
+
const link = await createLink(options, secret, session.id, config);
|
|
1184
|
+
return {
|
|
1185
|
+
connectorRef: { provider: config.provider, toolkitId: config.toolkitId, sessionId: session.id },
|
|
1186
|
+
status: "unconfigured",
|
|
1187
|
+
connectUrl: extractConnectUrl(link),
|
|
1188
|
+
providerAccountLabel: extractProviderAccountLabel(link)
|
|
1189
|
+
};
|
|
1190
|
+
},
|
|
1191
|
+
async refreshStatus({ actor, source, config, secret }) {
|
|
1192
|
+
if (source.status === "revoked") {
|
|
1193
|
+
return { status: "revoked", providerAccountLabel: source.providerAccountLabel, lastVerifiedAt: source.lastVerifiedAt, connectorRef: source.connectorRef };
|
|
1194
|
+
}
|
|
1195
|
+
const account = await findConnectedAccount(options, { actor, config, secret });
|
|
1196
|
+
if (!account?.active) {
|
|
1197
|
+
return {
|
|
1198
|
+
status: source.status === "connected" ? "expired" : source.status,
|
|
1199
|
+
providerAccountLabel: account?.label ?? source.providerAccountLabel,
|
|
1200
|
+
lastVerifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1201
|
+
connectorRef: { ...source.connectorRef, provider: config.provider, toolkitId: config.toolkitId, connectedAccountId: account?.id }
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
const session = await resolveComposioMcpSession(options, { actor, config, secret });
|
|
1205
|
+
return {
|
|
1206
|
+
status: "connected",
|
|
1207
|
+
providerAccountLabel: account.label ?? source.providerAccountLabel,
|
|
1208
|
+
lastVerifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1209
|
+
connectorRef: { ...source.connectorRef, provider: config.provider, toolkitId: config.toolkitId, sessionId: session.id, connectedAccountId: account.id }
|
|
1210
|
+
};
|
|
1211
|
+
},
|
|
1212
|
+
async probe({ actor, config, secret }) {
|
|
1213
|
+
const session = await resolveComposioMcpSession(options, { actor, config, secret });
|
|
1214
|
+
const transport = transportForSession(options, session, secret);
|
|
1215
|
+
const source = {
|
|
1216
|
+
id: `composio-probe:${actor.workspaceId}:${actor.userId}:${config.provider}`,
|
|
1217
|
+
workspaceId: actor.workspaceId,
|
|
1218
|
+
userId: actor.userId,
|
|
1219
|
+
provider: config.provider,
|
|
1220
|
+
displayName: config.displayName,
|
|
1221
|
+
status: "connected",
|
|
1222
|
+
ownerKind: "user",
|
|
1223
|
+
credentialProvider: "composio-managed",
|
|
1224
|
+
connectorRef: { provider: config.provider, toolkitId: config.toolkitId, sessionId: session.id }
|
|
1225
|
+
};
|
|
1226
|
+
const [tools, resources] = await Promise.all([
|
|
1227
|
+
transport.listTools(source).then(safeTools),
|
|
1228
|
+
transport.listResources(source).catch(() => [])
|
|
1229
|
+
]);
|
|
1230
|
+
return { tools, resources };
|
|
1231
|
+
},
|
|
1232
|
+
async revoke({ actor, source, config, secret }) {
|
|
1233
|
+
const connectedAccountId = source.connectorRef?.connectedAccountId ?? (await findConnectedAccount(options, { actor, config, secret }))?.id;
|
|
1234
|
+
if (!connectedAccountId) return;
|
|
1235
|
+
try {
|
|
1236
|
+
await composioDelete(options, secret, `/api/v3.1/connected_accounts/${encodeURIComponent(connectedAccountId)}`);
|
|
1237
|
+
} catch (error) {
|
|
1238
|
+
if (providerErrorStatus(error) === 404) return;
|
|
1239
|
+
throw error;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
var COMPOSIO_TRANSPORT_SESSION_TTL_MS = 5 * 6e4;
|
|
1245
|
+
function providerErrorText(error) {
|
|
1246
|
+
if (error instanceof McpError) {
|
|
1247
|
+
const details = error.details;
|
|
1248
|
+
if (details && typeof details === "object" && typeof details.message === "string") return details.message;
|
|
1249
|
+
}
|
|
1250
|
+
return error instanceof Error ? error.message : String(error);
|
|
1251
|
+
}
|
|
1252
|
+
function isUnsupportedResourcesError(error) {
|
|
1253
|
+
const message = providerErrorText(error).toLowerCase();
|
|
1254
|
+
return message.includes("does not support resources") || message.includes("method not found") || message.includes("resources/list") && (message.includes("not found") || message.includes("unsupported"));
|
|
1255
|
+
}
|
|
1256
|
+
function createComposioMcpTransport(options) {
|
|
1257
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1258
|
+
const pendingSessions = /* @__PURE__ */ new Map();
|
|
1259
|
+
function keyForSource(source) {
|
|
1260
|
+
const connector = source.connectorRef;
|
|
1261
|
+
return [
|
|
1262
|
+
source.workspaceId,
|
|
1263
|
+
source.userId,
|
|
1264
|
+
source.provider,
|
|
1265
|
+
source.id,
|
|
1266
|
+
source.updatedAt,
|
|
1267
|
+
connector?.sessionId ?? "",
|
|
1268
|
+
connector?.connectedAccountId ?? "",
|
|
1269
|
+
connector?.externalSourceId ?? ""
|
|
1270
|
+
].join(":");
|
|
1271
|
+
}
|
|
1272
|
+
async function createCacheEntry(source) {
|
|
1273
|
+
const config = options.configs.find((entry2) => entry2.provider === source.provider);
|
|
1274
|
+
if (!config) throw new McpError(MCP_ERROR_CODES.PROVIDER_CONFIG_INVALID, "Unknown Composio MCP provider", { reason: "unsupported_provider" });
|
|
1275
|
+
const secret = await options.secretResolver.resolveSecret(source.provider);
|
|
1276
|
+
const session = await resolveComposioMcpSession(options, { actor: actorForSource(source), config, secret });
|
|
1277
|
+
const entry = { config, secret, session, expiresAt: Date.now() + COMPOSIO_TRANSPORT_SESSION_TTL_MS };
|
|
1278
|
+
cache.set(keyForSource(source), entry);
|
|
1279
|
+
return entry;
|
|
1280
|
+
}
|
|
1281
|
+
function pruneExpiredEntries(now = Date.now()) {
|
|
1282
|
+
for (const [key, entry] of cache) {
|
|
1283
|
+
if (entry.expiresAt <= now) cache.delete(key);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
async function cachedContext(source) {
|
|
1287
|
+
const key = keyForSource(source);
|
|
1288
|
+
const now = Date.now();
|
|
1289
|
+
pruneExpiredEntries(now);
|
|
1290
|
+
const cached = cache.get(key);
|
|
1291
|
+
if (cached && cached.expiresAt > now) {
|
|
1292
|
+
return { key, entry: cached, transport: transportForSession(options, cached.session, cached.secret) };
|
|
1293
|
+
}
|
|
1294
|
+
const pending = pendingSessions.get(key) ?? createCacheEntry(source).finally(() => pendingSessions.delete(key));
|
|
1295
|
+
pendingSessions.set(key, pending);
|
|
1296
|
+
const entry = await pending;
|
|
1297
|
+
return { key, entry, transport: transportForSession(options, entry.session, entry.secret) };
|
|
1298
|
+
}
|
|
1299
|
+
async function rawToolsFor(source, entry, transport) {
|
|
1300
|
+
if (entry.rawTools) return entry.rawTools;
|
|
1301
|
+
const tools = await transport.listTools(source);
|
|
1302
|
+
entry.rawTools = tools;
|
|
1303
|
+
return tools;
|
|
1304
|
+
}
|
|
1305
|
+
return {
|
|
1306
|
+
async listTools(source, input) {
|
|
1307
|
+
const { key, entry, transport } = await cachedContext(source);
|
|
1308
|
+
if (input?.forceProviderRefresh) {
|
|
1309
|
+
entry.rawTools = void 0;
|
|
1310
|
+
entry.toolkitTools = void 0;
|
|
1311
|
+
}
|
|
1312
|
+
try {
|
|
1313
|
+
const rawTools = await rawToolsFor(source, entry, transport);
|
|
1314
|
+
const directTools = safeTools(rawTools);
|
|
1315
|
+
if (directTools.length > 0 || !rawTools.some((tool2) => tool2.name === COMPOSIO_SEARCH_TOOLS) || !rawTools.some((tool2) => tool2.name === COMPOSIO_GET_TOOL_SCHEMAS)) {
|
|
1316
|
+
return directTools;
|
|
1317
|
+
}
|
|
1318
|
+
entry.toolkitTools ??= await discoverComposioToolkitTools({ transport, source, config: entry.config, session: entry.session });
|
|
1319
|
+
return entry.toolkitTools;
|
|
1320
|
+
} catch (error) {
|
|
1321
|
+
cache.delete(key);
|
|
1322
|
+
throw error;
|
|
1323
|
+
}
|
|
1324
|
+
},
|
|
1325
|
+
async listResources(source) {
|
|
1326
|
+
const { key, transport } = await cachedContext(source);
|
|
1327
|
+
try {
|
|
1328
|
+
return await transport.listResources(source);
|
|
1329
|
+
} catch (error) {
|
|
1330
|
+
if (isUnsupportedResourcesError(error)) return [];
|
|
1331
|
+
cache.delete(key);
|
|
1332
|
+
throw error;
|
|
1333
|
+
}
|
|
1334
|
+
},
|
|
1335
|
+
async readResource(source, uri) {
|
|
1336
|
+
const { key, transport } = await cachedContext(source);
|
|
1337
|
+
try {
|
|
1338
|
+
return await transport.readResource(source, uri);
|
|
1339
|
+
} catch (error) {
|
|
1340
|
+
cache.delete(key);
|
|
1341
|
+
throw error;
|
|
1342
|
+
}
|
|
1343
|
+
},
|
|
1344
|
+
async callTool(source, toolName, input) {
|
|
1345
|
+
if (isComposioMetaTool(toolName)) throw new McpError(MCP_ERROR_CODES.TOOL_NOT_ALLOWED, "Composio MCP management tools are not exposed");
|
|
1346
|
+
const { key, entry, transport } = await cachedContext(source);
|
|
1347
|
+
try {
|
|
1348
|
+
const rawTools = await rawToolsFor(source, entry, transport);
|
|
1349
|
+
if (rawTools.some((tool2) => tool2.name === toolName)) return await transport.callTool(source, toolName, input);
|
|
1350
|
+
if (!rawTools.some((tool2) => tool2.name === COMPOSIO_MULTI_EXECUTE_TOOL)) return await transport.callTool(source, toolName, input);
|
|
1351
|
+
return await transport.callTool(source, COMPOSIO_MULTI_EXECUTE_TOOL, {
|
|
1352
|
+
tools: [{ tool_slug: toolName, arguments: input && typeof input === "object" ? input : {} }],
|
|
1353
|
+
sync_response_to_workbench: false,
|
|
1354
|
+
thought: `Execute ${toolName} through governed boring-mcp`,
|
|
1355
|
+
current_step: "MCP_READONLY_CALL",
|
|
1356
|
+
current_step_metric: "1/1 tools",
|
|
1357
|
+
session_id: entry.session.id
|
|
1358
|
+
});
|
|
1359
|
+
} catch (error) {
|
|
1360
|
+
cache.delete(key);
|
|
1361
|
+
throw error;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// src/server/managedConnectorAdapter.ts
|
|
1368
|
+
import { createHash as createHash2 } from "crypto";
|
|
1369
|
+
function findConfig(configs, provider) {
|
|
1370
|
+
return configs.find((config) => config.provider === provider);
|
|
1371
|
+
}
|
|
1372
|
+
function createManagedConnectorSourceId(actor, provider) {
|
|
1373
|
+
const digest = createHash2("sha256").update(`${actor.workspaceId}\0${actor.userId}\0${provider}`).digest("hex").slice(0, 32);
|
|
1374
|
+
return validateMcpSourceId(`managed:${provider}:${digest}`);
|
|
1375
|
+
}
|
|
1376
|
+
function createLegacyManagedConnectorSourceId(actor, provider) {
|
|
1377
|
+
return validateMcpSourceId(`managed:${actor.workspaceId}:${actor.userId}:${provider}`);
|
|
1378
|
+
}
|
|
1379
|
+
function defaultSourceId(actor, config) {
|
|
1380
|
+
return createManagedConnectorSourceId(actor, config.provider);
|
|
1381
|
+
}
|
|
1382
|
+
function assertSecretFree(value, canaries, code = MCP_ERROR_CODES.SECRET_LEAK_GUARD) {
|
|
1383
|
+
if (containsMcpSecretOrCanary(value, canaries)) {
|
|
1384
|
+
throw new McpError(code, "Managed connector response contained secret material");
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
function safeConnectUrl(rawUrl, config) {
|
|
1388
|
+
if (!rawUrl) return void 0;
|
|
1389
|
+
let parsed;
|
|
1390
|
+
try {
|
|
1391
|
+
parsed = new URL(rawUrl);
|
|
1392
|
+
} catch {
|
|
1393
|
+
throw new McpError(MCP_ERROR_CODES.PROVIDER_CONFIG_INVALID, "Managed connector returned an invalid connect URL");
|
|
1394
|
+
}
|
|
1395
|
+
if (parsed.protocol !== "https:" || parsed.username || parsed.password) {
|
|
1396
|
+
throw new McpError(MCP_ERROR_CODES.PROVIDER_CONFIG_INVALID, "Managed connector returned an unsafe connect URL");
|
|
1397
|
+
}
|
|
1398
|
+
if (config.connectUrlOrigins?.length && !config.connectUrlOrigins.includes(parsed.origin)) {
|
|
1399
|
+
throw new McpError(MCP_ERROR_CODES.PROVIDER_CONFIG_INVALID, "Managed connector returned an unapproved connect URL origin");
|
|
1400
|
+
}
|
|
1401
|
+
return parsed.toString();
|
|
1402
|
+
}
|
|
1403
|
+
function createManagedConnectorAdapter(options) {
|
|
1404
|
+
const canaries = [...options.preflightEvidence?.redactionCanaries ?? [], ...options.redactionCanaries ?? []];
|
|
1405
|
+
const templates = options.templates;
|
|
1406
|
+
async function getSecret(provider) {
|
|
1407
|
+
const secret = await options.secretResolver.resolveSecret(provider);
|
|
1408
|
+
if (secret.storage !== "server-env" && secret.storage !== "server-vault" || !secret.value) {
|
|
1409
|
+
throw new McpError(MCP_ERROR_CODES.PROVIDER_CONFIG_INVALID, "Managed connector secret is not configured");
|
|
1410
|
+
}
|
|
1411
|
+
return secret;
|
|
1412
|
+
}
|
|
1413
|
+
function requireConfig(provider) {
|
|
1414
|
+
const config = findConfig(options.configs, provider);
|
|
1415
|
+
const template = getMcpProviderTemplate(provider, templates);
|
|
1416
|
+
if (!config || !template) throw new McpError(MCP_ERROR_CODES.PROVIDER_CONFIG_INVALID, "Unknown managed connector provider", { reason: "unsupported_provider" });
|
|
1417
|
+
return { config, template };
|
|
1418
|
+
}
|
|
1419
|
+
return {
|
|
1420
|
+
async startConnect(actor, input) {
|
|
1421
|
+
const { config } = requireConfig(input.provider);
|
|
1422
|
+
const sourceId = validateMcpSourceId((options.sourceIdFactory ?? defaultSourceId)(actor, config));
|
|
1423
|
+
const secret = await getSecret(config.provider);
|
|
1424
|
+
const secretCanaries = [...canaries, secret.value];
|
|
1425
|
+
const response = await options.provider.startConnect({ actor, config, secret, sourceId });
|
|
1426
|
+
const connectUrl = safeConnectUrl(response.connectUrl, config);
|
|
1427
|
+
assertSecretFree({ ...response, connectUrl }, secretCanaries);
|
|
1428
|
+
const source = {
|
|
1429
|
+
id: sourceId,
|
|
1430
|
+
workspaceId: actor.workspaceId,
|
|
1431
|
+
userId: actor.userId,
|
|
1432
|
+
provider: config.provider,
|
|
1433
|
+
displayName: input.displayName ?? config.displayName,
|
|
1434
|
+
status: response.status ?? "unconfigured",
|
|
1435
|
+
ownerKind: "user",
|
|
1436
|
+
credentialProvider: "composio-managed",
|
|
1437
|
+
scopes: config.scopes ? [...config.scopes] : void 0,
|
|
1438
|
+
providerAccountLabel: response.providerAccountLabel,
|
|
1439
|
+
connectorRef: response.connectorRef
|
|
1440
|
+
};
|
|
1441
|
+
assertSecretFree(source, secretCanaries);
|
|
1442
|
+
const saved = await options.registry.upsertSource(actor, source);
|
|
1443
|
+
const result = { ...createMcpSourceStatusPayload(saved), connectUrl };
|
|
1444
|
+
assertSecretFree(result, secretCanaries);
|
|
1445
|
+
return result;
|
|
1446
|
+
},
|
|
1447
|
+
async refreshStatus(actor, sourceId) {
|
|
1448
|
+
const source = await requireActorOwnedMcpSource(options.registry, actor, sourceId);
|
|
1449
|
+
const { config } = requireConfig(source.provider);
|
|
1450
|
+
const secret = await getSecret(config.provider);
|
|
1451
|
+
const secretCanaries = [...canaries, secret.value];
|
|
1452
|
+
const response = await options.provider.refreshStatus({ actor, source, config, secret });
|
|
1453
|
+
assertSecretFree(response, secretCanaries);
|
|
1454
|
+
const nextSource = {
|
|
1455
|
+
...source,
|
|
1456
|
+
status: response.status,
|
|
1457
|
+
providerAccountLabel: response.providerAccountLabel ?? source.providerAccountLabel,
|
|
1458
|
+
connectorRef: response.connectorRef ?? source.connectorRef,
|
|
1459
|
+
lastVerifiedAt: response.lastVerifiedAt
|
|
1460
|
+
};
|
|
1461
|
+
assertSecretFree(nextSource, secretCanaries);
|
|
1462
|
+
const saved = await options.registry.upsertSource(actor, nextSource);
|
|
1463
|
+
const result = createMcpSourceStatusPayload(saved);
|
|
1464
|
+
assertSecretFree(result, secretCanaries);
|
|
1465
|
+
return result;
|
|
1466
|
+
},
|
|
1467
|
+
async probeSource(actor, sourceId) {
|
|
1468
|
+
const source = await requireActorOwnedMcpSource(options.registry, actor, sourceId);
|
|
1469
|
+
if (source.status !== "connected") throw new McpError(MCP_ERROR_CODES.SOURCE_UNAVAILABLE, "MCP source is not connected");
|
|
1470
|
+
const { config, template } = requireConfig(source.provider);
|
|
1471
|
+
const secret = await getSecret(config.provider);
|
|
1472
|
+
const response = await options.provider.probe({ actor, source, config, secret });
|
|
1473
|
+
assertSecretFree(response, [...canaries, secret.value]);
|
|
1474
|
+
const result = {
|
|
1475
|
+
sourceId: source.id,
|
|
1476
|
+
provider: source.provider,
|
|
1477
|
+
tools: classifyMcpTools(template, response.tools),
|
|
1478
|
+
resources: response.resources
|
|
1479
|
+
};
|
|
1480
|
+
assertSecretFree(result, [...canaries, secret.value]);
|
|
1481
|
+
return result;
|
|
1482
|
+
},
|
|
1483
|
+
async disconnectSource(actor, sourceId) {
|
|
1484
|
+
if (!options.registry.disconnectSource) throw new McpError(MCP_ERROR_CODES.SOURCE_UNAVAILABLE, "MCP source disconnect is not configured");
|
|
1485
|
+
const source = await requireActorOwnedMcpSource(options.registry, actor, sourceId);
|
|
1486
|
+
let secretCanaries = canaries;
|
|
1487
|
+
if (options.provider.revoke) {
|
|
1488
|
+
try {
|
|
1489
|
+
const { config } = requireConfig(source.provider);
|
|
1490
|
+
const secret = await getSecret(config.provider);
|
|
1491
|
+
await options.provider.revoke({ actor, source, config, secret });
|
|
1492
|
+
secretCanaries = [...canaries, secret.value];
|
|
1493
|
+
} catch (error) {
|
|
1494
|
+
if (!(error instanceof McpError && error.code === MCP_ERROR_CODES.PROVIDER_CONFIG_INVALID)) throw error;
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
const disconnected = await options.registry.disconnectSource(actor, source.id);
|
|
1498
|
+
const result = await verifyMcpDisconnectResult(options.registry, actor, source.id, disconnected);
|
|
1499
|
+
assertSecretFree(result, secretCanaries);
|
|
1500
|
+
return result;
|
|
1501
|
+
}
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
// src/server/managedConnectorPreflight.ts
|
|
1506
|
+
var VENDOR_RISK_FIELDS = [
|
|
1507
|
+
"dpaStatus",
|
|
1508
|
+
"subprocessorStatus",
|
|
1509
|
+
"dataResidencyStatus",
|
|
1510
|
+
"incidentHistoryStatus"
|
|
1511
|
+
];
|
|
1512
|
+
function check(id, ok, message, code = MCP_ERROR_CODES.PROVIDER_CONFIG_INVALID) {
|
|
1513
|
+
return ok ? { id, ok, message } : { id, ok, code, message };
|
|
1514
|
+
}
|
|
1515
|
+
function hasText(value) {
|
|
1516
|
+
return value.trim().length > 0;
|
|
1517
|
+
}
|
|
1518
|
+
function hasAcceptedGap(evidence, field) {
|
|
1519
|
+
return evidence[field] === "approved" || Boolean(evidence.acceptedGaps?.some((gap) => gap.id === field && hasText(gap.owner) && hasText(gap.followUp) && hasText(gap.reason)));
|
|
1520
|
+
}
|
|
1521
|
+
function validateManagedConnectorPreflight(evidence) {
|
|
1522
|
+
const hasBrowserSamples = evidence.browserDtoSamples.length > 0;
|
|
1523
|
+
const hasLogSamples = evidence.redactedLogSamples.length > 0;
|
|
1524
|
+
const hasProviderResultSamples = evidence.redactedProviderResultSamples.length > 0;
|
|
1525
|
+
const hasCanaries = evidence.redactionCanaries.some(hasText);
|
|
1526
|
+
const browserHasSecret = containsMcpSecretOrCanary(evidence.browserDtoSamples, evidence.redactionCanaries);
|
|
1527
|
+
const logsHaveSecret = containsMcpSecretOrCanary(evidence.redactedLogSamples, evidence.redactionCanaries);
|
|
1528
|
+
const providerResultsHaveSecret = containsMcpSecretOrCanary(evidence.redactedProviderResultSamples, evidence.redactionCanaries);
|
|
1529
|
+
const vendorRiskOk = VENDOR_RISK_FIELDS.every((field) => hasAcceptedGap(evidence.vendorRisk, field));
|
|
1530
|
+
const checks = [
|
|
1531
|
+
check("isolated-test-project", evidence.isolatedTestProject, "Connector test project is isolated from production data"),
|
|
1532
|
+
check("server-only-api-key", evidence.apiKeyStorage === "server-env" || evidence.apiKeyStorage === "server-vault", "Connector API key resolves only from server env/Vault"),
|
|
1533
|
+
check("redaction-canaries-present", hasCanaries, "Explicit redaction canaries are provided for browser/log/provider-result evidence"),
|
|
1534
|
+
check("browser-dto-secret-free", hasBrowserSamples && !browserHasSecret, "Browser DTO samples contain no API keys, OAuth tokens, cookies, MCP session headers, or seeded canaries", MCP_ERROR_CODES.SECRET_LEAK_GUARD),
|
|
1535
|
+
check("log-redaction-canary", hasLogSamples && !logsHaveSecret, "Redacted log samples contain no connector/API/session/OAuth canaries", MCP_ERROR_CODES.SECRET_LEAK_GUARD),
|
|
1536
|
+
check("provider-result-redaction-canary", hasProviderResultSamples && !providerResultsHaveSecret, "Redacted provider result samples contain no connector/API/session/OAuth canaries", MCP_ERROR_CODES.SECRET_LEAK_GUARD),
|
|
1537
|
+
check("revoke-disconnect", evidence.revokeDisconnectVerified, "Revoke/disconnect behavior has been verified for the connector"),
|
|
1538
|
+
check("connected-account-status", evidence.connectedAccountStatusVerified, "Connected-account status refresh behavior has been verified"),
|
|
1539
|
+
check("vendor-risk", vendorRiskOk, "DPA, subprocessors, data residency, and incident-history risk are approved or owner-accepted")
|
|
1540
|
+
];
|
|
1541
|
+
return { ok: checks.every((item) => item.ok), connectorName: evidence.connectorName, checks };
|
|
1542
|
+
}
|
|
1543
|
+
function assertManagedConnectorPreflight(evidence) {
|
|
1544
|
+
const result = validateManagedConnectorPreflight(evidence);
|
|
1545
|
+
if (!result.ok) {
|
|
1546
|
+
const failed = result.checks.filter((item) => !item.ok).map((item) => item.id).join(", ");
|
|
1547
|
+
throw new McpError(MCP_ERROR_CODES.PROVIDER_CONFIG_INVALID, `Managed connector preflight failed for ${result.connectorName}: ${failed}`, result);
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// src/server/index.ts
|
|
1552
|
+
function createBoringMcpServerPlugin(options = {}) {
|
|
1553
|
+
const hasAgentToolWiring = Boolean(options.registry && options.transport && options.resolveActor);
|
|
1554
|
+
return defineServerPlugin({
|
|
1555
|
+
id: BORING_MCP_PLUGIN_ID,
|
|
1556
|
+
label: "Sources",
|
|
1557
|
+
systemPrompt: options.systemPrompt ?? "Use boring-mcp bridge tools only when an app has enabled them. Treat MCP sources as read-only unless a tool is explicitly allowed.",
|
|
1558
|
+
agentTools: hasAgentToolWiring ? createBoringMcpAgentTools({
|
|
1559
|
+
registry: options.registry,
|
|
1560
|
+
transport: options.transport,
|
|
1561
|
+
resolveActor: options.resolveActor,
|
|
1562
|
+
templates: options.templates,
|
|
1563
|
+
maxReadonlyInputBytes: options.maxReadonlyInputBytes,
|
|
1564
|
+
audit: options.audit,
|
|
1565
|
+
hardening: options.hardening
|
|
1566
|
+
}) : void 0
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
var server_default = createBoringMcpServerPlugin();
|
|
1570
|
+
export {
|
|
1571
|
+
AIRTABLE_MCP_TEMPLATE,
|
|
1572
|
+
BORING_MCP_AGENT_BRIDGE_TOOL_DEFINITIONS,
|
|
1573
|
+
BORING_MCP_AGENT_BRIDGE_TOOL_NAMES,
|
|
1574
|
+
BORING_MCP_PLUGIN_ID,
|
|
1575
|
+
BORING_MCP_SOURCES_PANEL_ID,
|
|
1576
|
+
BORING_MCP_SOURCES_TAB_PANEL_ID,
|
|
1577
|
+
DEFAULT_MCP_PROVIDER_TEMPLATES,
|
|
1578
|
+
InMemoryMcpRateBudgetGate,
|
|
1579
|
+
InMemoryMcpToolCatalogCache,
|
|
1580
|
+
MCP_ERROR_CODES,
|
|
1581
|
+
MCP_TOOL_NAME_PATTERN,
|
|
1582
|
+
McpAccessFacade,
|
|
1583
|
+
McpError,
|
|
1584
|
+
NOTION_MCP_TEMPLATE,
|
|
1585
|
+
assertManagedConnectorPreflight,
|
|
1586
|
+
assertMcpPublicPayloadSecretFree,
|
|
1587
|
+
assertMcpToolAllowed,
|
|
1588
|
+
classifyMcpTool,
|
|
1589
|
+
classifyMcpTools,
|
|
1590
|
+
containsMcpCanary,
|
|
1591
|
+
containsMcpSecret,
|
|
1592
|
+
containsMcpSecretOrCanary,
|
|
1593
|
+
createBoringMcpAgentBridgeRegistry,
|
|
1594
|
+
createBoringMcpAgentTools,
|
|
1595
|
+
createBoringMcpReadonlyCaller,
|
|
1596
|
+
createBoringMcpServerPlugin,
|
|
1597
|
+
createBoringMcpSourceHandlers,
|
|
1598
|
+
createBoringMcpToolCatalog,
|
|
1599
|
+
createComposioManagedConnectorProvider,
|
|
1600
|
+
createComposioMcpTransport,
|
|
1601
|
+
createHardenedMcpTransport,
|
|
1602
|
+
createLegacyManagedConnectorSourceId,
|
|
1603
|
+
createManagedConnectorAdapter,
|
|
1604
|
+
createManagedConnectorSourceId,
|
|
1605
|
+
createMcpSchemaHash,
|
|
1606
|
+
createMcpSdkStreamableHttpTransport,
|
|
1607
|
+
createMcpSourceStatusPayload,
|
|
1608
|
+
server_default as default,
|
|
1609
|
+
doctorMcpSource,
|
|
1610
|
+
evaluateBoringMcpLaunchGate,
|
|
1611
|
+
getMcpProviderTemplate,
|
|
1612
|
+
isActorOwnedMcpSource,
|
|
1613
|
+
listBoringMcpAgentBridgeTools,
|
|
1614
|
+
normalizeMcpCatalogTool,
|
|
1615
|
+
redactMcpSecrets,
|
|
1616
|
+
requireActorOwnedMcpSource,
|
|
1617
|
+
resolveComposioMcpSession,
|
|
1618
|
+
toMcpSourceDto,
|
|
1619
|
+
validateManagedConnectorPreflight,
|
|
1620
|
+
validateMcpSourceId,
|
|
1621
|
+
validateMcpToolName,
|
|
1622
|
+
verifyMcpDisconnectResult
|
|
1623
|
+
};
|