@databrainhq/mcp-server 0.3.0-rc.13 → 0.3.0-rc.15
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/.mcp.json +6 -1
- package/dist/apps/server.d.ts +102 -389
- package/dist/apps/server.js +5 -5
- package/dist/apps/stdio.d.ts +1 -2
- package/dist/apps/stdio.js +6 -6
- package/dist/{bundle-TDQXWIPM.js → bundle-QJNCUMNY.js} +2 -2
- package/dist/{chunk-Q5EYDKSA.js → chunk-2F7V6RZR.js} +511 -107
- package/dist/chunk-2F7V6RZR.js.map +1 -0
- package/dist/chunk-3AH56XX7.js +28 -0
- package/dist/chunk-3AH56XX7.js.map +1 -0
- package/dist/{chunk-AEQTEE5C.js → chunk-4E57ZRZO.js} +302 -63
- package/dist/chunk-4E57ZRZO.js.map +1 -0
- package/dist/{chunk-PSJVYD4W.js → chunk-6MD7KOAU.js} +11723 -10560
- package/dist/chunk-6MD7KOAU.js.map +1 -0
- package/dist/{chunk-HCYZN7I3.js → chunk-FJLRZJ4A.js} +10 -7
- package/dist/chunk-FJLRZJ4A.js.map +1 -0
- package/dist/{chunk-X44C5GW2.js → chunk-MUZ3KEJX.js} +63 -40
- package/dist/chunk-MUZ3KEJX.js.map +1 -0
- package/dist/{chunk-DTFYHLYN.js → chunk-NXE2A4WV.js} +26 -11
- package/dist/chunk-NXE2A4WV.js.map +1 -0
- package/dist/{chunk-233VGFHY.js → chunk-QQ6KNULX.js} +317 -147
- package/dist/chunk-QQ6KNULX.js.map +1 -0
- package/dist/chunk-V5JINLOA.js +506 -0
- package/dist/chunk-V5JINLOA.js.map +1 -0
- package/dist/cli.d.ts +16 -15
- package/dist/cli.js +134 -36
- package/dist/cli.js.map +1 -1
- package/dist/custom-chart/custom-runtime.js +6 -6
- package/dist/custom-chart/template/chart.ts +116 -28
- package/dist/custom-chart/template/icon.svg +1 -1
- package/dist/custom-chart/template/manifest.json +66 -12
- package/dist/custom-chart/template/sample-data.json +21 -3
- package/dist/index.d.ts +1 -2
- package/dist/index.js +67 -13
- package/dist/index.js.map +1 -1
- package/dist/{main-VEKNKWL5.js → main-2FKQQLWX.js} +489 -174
- package/dist/main-2FKQQLWX.js.map +1 -0
- package/dist/{test-only-LJGOY2QA.js → test-only-VH3FF5ML.js} +15 -9
- package/dist/test-only-VH3FF5ML.js.map +1 -0
- package/package.json +20 -9
- package/dist/chunk-233VGFHY.js.map +0 -1
- package/dist/chunk-AEQTEE5C.js.map +0 -1
- package/dist/chunk-DTFYHLYN.js.map +0 -1
- package/dist/chunk-HCYZN7I3.js.map +0 -1
- package/dist/chunk-PSJVYD4W.js.map +0 -1
- package/dist/chunk-Q5EYDKSA.js.map +0 -1
- package/dist/chunk-X44C5GW2.js.map +0 -1
- package/dist/main-VEKNKWL5.js.map +0 -1
- package/dist/test-only-LJGOY2QA.js.map +0 -1
- /package/dist/{bundle-TDQXWIPM.js.map → bundle-QJNCUMNY.js.map} +0 -0
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
catalogueTurnEntry,
|
|
4
|
+
isExecutableBrynTool,
|
|
5
|
+
parseBrynCatalogue,
|
|
6
|
+
registerBrynTools,
|
|
7
|
+
registerBrynTurns,
|
|
8
|
+
registerCatalogueTurns
|
|
9
|
+
} from "./chunk-V5JINLOA.js";
|
|
2
10
|
import {
|
|
3
11
|
registerChartApps
|
|
4
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-QQ6KNULX.js";
|
|
5
13
|
import {
|
|
6
14
|
PREVIEW_PATH,
|
|
7
15
|
WRITE_CREATE,
|
|
@@ -9,26 +17,33 @@ import {
|
|
|
9
17
|
registerAllPrompts,
|
|
10
18
|
registerAllResources,
|
|
11
19
|
registerAllTools
|
|
12
|
-
} from "./chunk-
|
|
20
|
+
} from "./chunk-6MD7KOAU.js";
|
|
13
21
|
import {
|
|
22
|
+
BrynCatalogueCache,
|
|
14
23
|
DatabrainApiError,
|
|
15
24
|
DatabrainClient,
|
|
16
25
|
MCP_SERVER_VERSION,
|
|
26
|
+
RateLimitedError,
|
|
27
|
+
parseBrynStream,
|
|
17
28
|
parseErrorBody,
|
|
29
|
+
parseRetryAfter,
|
|
18
30
|
parseStudioStream,
|
|
31
|
+
readBrynStream,
|
|
19
32
|
stringify,
|
|
20
33
|
toolContext,
|
|
21
34
|
toolError,
|
|
22
35
|
toolSuccess
|
|
23
|
-
} from "./chunk-
|
|
24
|
-
import "./chunk-
|
|
36
|
+
} from "./chunk-4E57ZRZO.js";
|
|
37
|
+
import "./chunk-2F7V6RZR.js";
|
|
25
38
|
import {
|
|
26
39
|
FilePatStore,
|
|
27
|
-
HOSTED_SCOPES,
|
|
28
|
-
HostedAuthError,
|
|
29
40
|
createBearerVerifier,
|
|
30
41
|
principalSchema
|
|
31
|
-
} from "./chunk-
|
|
42
|
+
} from "./chunk-MUZ3KEJX.js";
|
|
43
|
+
import {
|
|
44
|
+
HOSTED_SCOPES,
|
|
45
|
+
HostedAuthError
|
|
46
|
+
} from "./chunk-3AH56XX7.js";
|
|
32
47
|
|
|
33
48
|
// src/hosted/main.ts
|
|
34
49
|
import { isAbsolute } from "path";
|
|
@@ -37,16 +52,21 @@ import { pathToFileURL } from "url";
|
|
|
37
52
|
// src/hosted/config.ts
|
|
38
53
|
function loadHostedConfig(env = process.env) {
|
|
39
54
|
const testOnly = env.DATABRAIN_HOSTED_TEST_ONLY === "1";
|
|
40
|
-
if (testOnly && env.NODE_ENV === "production")
|
|
41
|
-
|
|
55
|
+
if (testOnly && env.NODE_ENV === "production")
|
|
56
|
+
throw new Error("Hosted test mode is forbidden in production.");
|
|
57
|
+
if (env.DATABRAIN_HOSTED_TEST_ONLY && !testOnly)
|
|
58
|
+
throw new Error("Invalid DATABRAIN_HOSTED_TEST_ONLY.");
|
|
42
59
|
const validUrl = (value, field) => {
|
|
43
60
|
try {
|
|
44
61
|
if (!value) throw new Error();
|
|
45
62
|
const url = new URL(value);
|
|
46
|
-
if (url.username || url.password || url.search || url.hash || url.protocol !== "https:")
|
|
63
|
+
if (url.username || url.password || url.search || url.hash || url.protocol !== "https:")
|
|
64
|
+
throw new Error();
|
|
47
65
|
return url;
|
|
48
66
|
} catch {
|
|
49
|
-
throw new Error(
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Configure ${field} as an HTTPS URL without credentials, query or fragment.`
|
|
69
|
+
);
|
|
50
70
|
}
|
|
51
71
|
};
|
|
52
72
|
let publicValue = env.DATABRAIN_HOSTED_PUBLIC_URL;
|
|
@@ -58,10 +78,12 @@ function loadHostedConfig(env = process.env) {
|
|
|
58
78
|
}
|
|
59
79
|
const publicUrl = validUrl(publicValue, "DATABRAIN_HOSTED_PUBLIC_URL");
|
|
60
80
|
validUrl(issuerValue, "DATABRAIN_HOSTED_ISSUER");
|
|
61
|
-
if (publicUrl.pathname !== "/mcp")
|
|
81
|
+
if (publicUrl.pathname !== "/mcp")
|
|
82
|
+
throw new Error("DATABRAIN_HOSTED_PUBLIC_URL must end in /mcp.");
|
|
62
83
|
const port = Number(env.DATABRAIN_HOSTED_PORT || "3144");
|
|
63
84
|
const bind = env.DATABRAIN_HOSTED_BIND || "127.0.0.1";
|
|
64
|
-
if (!Number.isInteger(port) || port < 1024 || port > 65535)
|
|
85
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535)
|
|
86
|
+
throw new Error("Invalid DATABRAIN_HOSTED_PORT.");
|
|
65
87
|
let apiUrl;
|
|
66
88
|
if (env.DATABRAIN_HOSTED_API_URL) {
|
|
67
89
|
let url;
|
|
@@ -71,7 +93,9 @@ function loadHostedConfig(env = process.env) {
|
|
|
71
93
|
url = void 0;
|
|
72
94
|
}
|
|
73
95
|
if (!url || !["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== "/") {
|
|
74
|
-
throw new Error(
|
|
96
|
+
throw new Error(
|
|
97
|
+
"Configure DATABRAIN_HOSTED_API_URL as an origin without credentials, path, query or fragment."
|
|
98
|
+
);
|
|
75
99
|
}
|
|
76
100
|
apiUrl = url.origin;
|
|
77
101
|
}
|
|
@@ -86,65 +110,17 @@ function loadHostedConfig(env = process.env) {
|
|
|
86
110
|
}
|
|
87
111
|
|
|
88
112
|
// src/hosted/http.ts
|
|
89
|
-
import {
|
|
113
|
+
import {
|
|
114
|
+
createServer
|
|
115
|
+
} from "http";
|
|
90
116
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
91
117
|
|
|
92
|
-
// src/hosted/
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
import { instrument } from "@posthog/mcp";
|
|
97
|
-
import { PostHog } from "posthog-node";
|
|
98
|
-
|
|
99
|
-
// src/tools/infrastructure/create-workspace.ts
|
|
100
|
-
import { z } from "zod";
|
|
101
|
-
function registerCreateWorkspace(server, client) {
|
|
102
|
-
server.tool(
|
|
103
|
-
"create_workspace",
|
|
104
|
-
'Create a new workspace to connect a datasource or datamart for dashboard building. Works only when this sign-in granted "All workspaces I can access" and your role may create workspaces; the new workspace is usable on the next call.',
|
|
105
|
-
{
|
|
106
|
-
name: z.string().describe("Unique workspace name"),
|
|
107
|
-
connectionType: z.enum(["DATASOURCE", "DATAMART", "MULTI_DATASOURCE"]).optional().describe("Connection type \u2014 DATASOURCE, DATAMART, or MULTI_DATASOURCE"),
|
|
108
|
-
datasourceName: z.string().optional().describe("Datasource name (required when connectionType is DATASOURCE)"),
|
|
109
|
-
datamartName: z.string().optional().describe("Datamart name (required when connectionType is DATAMART)"),
|
|
110
|
-
body: z.record(z.unknown()).optional().describe("Raw API body passthrough for POST /api/v2/workspace")
|
|
111
|
-
},
|
|
112
|
-
WRITE_CREATE,
|
|
113
|
-
async (params) => {
|
|
114
|
-
try {
|
|
115
|
-
const body = params.body ? { ...params.body } : {
|
|
116
|
-
name: params.name,
|
|
117
|
-
connectionType: params.connectionType,
|
|
118
|
-
datasourceName: params.datasourceName,
|
|
119
|
-
datamartName: params.datamartName
|
|
120
|
-
};
|
|
121
|
-
const result = await client.request({
|
|
122
|
-
method: "POST",
|
|
123
|
-
path: "/api/v2/workspace",
|
|
124
|
-
body,
|
|
125
|
-
auth: "service",
|
|
126
|
-
invalidateCache: ["list_workspaces"]
|
|
127
|
-
});
|
|
128
|
-
if (result.error) {
|
|
129
|
-
return toolError(`Workspace creation failed: ${stringify(result.error)}`);
|
|
130
|
-
}
|
|
131
|
-
return toolSuccess(
|
|
132
|
-
`Workspace "${result.name || params.name}" created successfully.
|
|
133
|
-
|
|
134
|
-
Next steps:
|
|
135
|
-
1. Use \`create_data_app\` to create a data app linked to this workspace
|
|
136
|
-
2. Build dashboards in the Databrain UI using this workspace
|
|
137
|
-
3. Use \`create_embed\` to embed dashboards`
|
|
138
|
-
);
|
|
139
|
-
} catch (err) {
|
|
140
|
-
if (err instanceof DatabrainApiError) return err.toToolResponse();
|
|
141
|
-
return toolError(`Unexpected error: ${err.message}`);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
118
|
+
// src/hosted/authorization.ts
|
|
119
|
+
function brynEntry(name, catalogue) {
|
|
120
|
+
return catalogue?.tools.find(
|
|
121
|
+
(entry) => entry.name === name && isExecutableBrynTool(entry)
|
|
144
122
|
);
|
|
145
123
|
}
|
|
146
|
-
|
|
147
|
-
// src/hosted/authorization.ts
|
|
148
124
|
var TOOL_SCOPES = {
|
|
149
125
|
whoami: [],
|
|
150
126
|
list_datasources: ["mcp:datamarts:read"],
|
|
@@ -172,7 +148,11 @@ var TOOL_SCOPES = {
|
|
|
172
148
|
create_dashboard: ["mcp:dashboards:write"],
|
|
173
149
|
get_dashboard: ["mcp:dashboards:read"],
|
|
174
150
|
dashboard_snapshot: ["mcp:dashboards:read"],
|
|
175
|
-
generate_dashboard_tiles: [
|
|
151
|
+
generate_dashboard_tiles: [
|
|
152
|
+
"mcp:dashboards:write",
|
|
153
|
+
"mcp:query",
|
|
154
|
+
"mcp:embeds:write"
|
|
155
|
+
],
|
|
176
156
|
ask_question: ["mcp:query"],
|
|
177
157
|
get_semantic_layer: ["mcp:datamarts:read"],
|
|
178
158
|
update_semantic_layer: ["mcp:datamarts:write"],
|
|
@@ -184,8 +164,16 @@ var TOOL_SCOPES = {
|
|
|
184
164
|
apply_semantic_change: ["mcp:datamarts:write"],
|
|
185
165
|
eval_semantic_model: ["mcp:datamarts:read", "mcp:query"],
|
|
186
166
|
list_widgets: ["mcp:dashboards:read"],
|
|
187
|
-
validate_metric_spec: [
|
|
188
|
-
|
|
167
|
+
validate_metric_spec: [
|
|
168
|
+
"mcp:dashboards:read",
|
|
169
|
+
"mcp:query",
|
|
170
|
+
"mcp:embeds:write"
|
|
171
|
+
],
|
|
172
|
+
create_or_update_metric_from_query: [
|
|
173
|
+
"mcp:dashboards:write",
|
|
174
|
+
"mcp:query",
|
|
175
|
+
"mcp:embeds:write"
|
|
176
|
+
],
|
|
189
177
|
create_workspace_metric: ["mcp:dashboards:write", "mcp:query"],
|
|
190
178
|
add_widget: ["mcp:dashboards:write", "mcp:embeds:write"],
|
|
191
179
|
update_widget: ["mcp:dashboards:write", "mcp:embeds:write"],
|
|
@@ -212,25 +200,51 @@ var TOOL_SCOPES = {
|
|
|
212
200
|
drill_down: ["mcp:dashboards:read", "mcp:query"],
|
|
213
201
|
change_date_range: ["mcp:dashboards:read", "mcp:query"]
|
|
214
202
|
};
|
|
215
|
-
|
|
216
|
-
|
|
203
|
+
var BRYN_TURN_SCOPES = {
|
|
204
|
+
ask_bryn_metric: ["mcp:query"],
|
|
205
|
+
ask_bryn_semantic_model: ["mcp:datamarts:read", "mcp:datamarts:write"],
|
|
206
|
+
ask_bryn_theme: ["mcp:embeds:write"],
|
|
207
|
+
ask_bryn_custom_chart: ["mcp:custom-charts:write"]
|
|
208
|
+
};
|
|
209
|
+
function requiredScopes(name, catalogue) {
|
|
210
|
+
if ((catalogue?.tools.length || catalogue?.turns.length) && Object.hasOwn(BRYN_TURN_SCOPES, name))
|
|
211
|
+
return BRYN_TURN_SCOPES[name];
|
|
212
|
+
const turn = Object.hasOwn(TOOL_SCOPES, name) ? void 0 : catalogueTurnEntry(name, catalogue);
|
|
213
|
+
if (turn) return turn.requiredScopes;
|
|
214
|
+
const bryn = brynEntry(name, catalogue);
|
|
215
|
+
if (bryn) {
|
|
216
|
+
if (bryn.requiredScopes) return bryn.requiredScopes;
|
|
217
|
+
if (bryn.surface === "custom-chart-studio")
|
|
218
|
+
return ["mcp:custom-charts:write"];
|
|
219
|
+
return bryn.effect === "read" ? ["mcp:dashboards:read"] : ["mcp:embeds:write"];
|
|
220
|
+
}
|
|
221
|
+
if (!Object.hasOwn(TOOL_SCOPES, name))
|
|
222
|
+
throw new HostedAuthError(403, "tool_not_authorized");
|
|
217
223
|
return TOOL_SCOPES[name];
|
|
218
224
|
}
|
|
219
|
-
function authorizeTool(name, principal) {
|
|
220
|
-
if (principal.expiresAt !== void 0 && principal.expiresAt <= Date.now() / 1e3)
|
|
221
|
-
|
|
225
|
+
function authorizeTool(name, principal, catalogue) {
|
|
226
|
+
if (principal.expiresAt !== void 0 && principal.expiresAt <= Date.now() / 1e3)
|
|
227
|
+
throw new HostedAuthError(401, "invalid_token");
|
|
228
|
+
if (requiredScopes(name, catalogue).some(
|
|
229
|
+
(scope) => !principal.scopes.includes(scope)
|
|
230
|
+
))
|
|
231
|
+
throw new HostedAuthError(403, "insufficient_scope");
|
|
222
232
|
}
|
|
223
233
|
var TOOL_ARGUMENT_VALUES = {
|
|
224
234
|
list_dashboards: { scope: ["auto", "data_app", "global"] }
|
|
225
235
|
};
|
|
226
236
|
var TOOL_OPAQUE_ARGUMENTS = {
|
|
237
|
+
ask_bryn_theme: ["theme", "dark", "schema", "contrast"],
|
|
238
|
+
ask_bryn_custom_chart: ["draft"],
|
|
227
239
|
preview_chart: ["rows"],
|
|
228
240
|
custom_chart_preview: ["rows", "manifest"],
|
|
229
241
|
custom_chart_edit: ["draft"]
|
|
230
242
|
};
|
|
231
|
-
function assertToolArguments(name, value, principal, delegated = false) {
|
|
243
|
+
function assertToolArguments(name, value, principal, delegated = false, catalogue) {
|
|
232
244
|
const allowed = Object.hasOwn(TOOL_ARGUMENT_VALUES, name) ? TOOL_ARGUMENT_VALUES[name] : {};
|
|
233
|
-
const
|
|
245
|
+
const bryn = brynEntry(name, catalogue);
|
|
246
|
+
const turn = Object.hasOwn(TOOL_SCOPES, name) ? void 0 : catalogueTurnEntry(name, catalogue);
|
|
247
|
+
const opaque = turn ? turn.opaqueContextFields : bryn ? bryn.opaqueContextFields ? ["input", ...bryn.opaqueContextFields] : ["input", "draft", "theme", "dark", "schema", "contrast", "followUp"] : Object.hasOwn(TOOL_OPAQUE_ARGUMENTS, name) ? TOOL_OPAQUE_ARGUMENTS[name] : void 0;
|
|
234
248
|
const target = selectWorkspace(value, principal);
|
|
235
249
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
236
250
|
assertBoundArguments(value, principal, { target, delegated });
|
|
@@ -242,7 +256,8 @@ function assertToolArguments(name, value, principal, delegated = false) {
|
|
|
242
256
|
rest[key] = child;
|
|
243
257
|
continue;
|
|
244
258
|
}
|
|
245
|
-
if (typeof child !== "string" || !allowed[key].includes(child))
|
|
259
|
+
if (typeof child !== "string" || !allowed[key].includes(child))
|
|
260
|
+
throw new HostedAuthError(403, "identity_argument_forbidden");
|
|
246
261
|
}
|
|
247
262
|
assertBoundArguments(rest, principal, { target, delegated, opaque });
|
|
248
263
|
return target;
|
|
@@ -261,7 +276,9 @@ var WORKSPACE_FREE_TOOLS = /* @__PURE__ */ new Set([
|
|
|
261
276
|
"list_custom_charts",
|
|
262
277
|
"publish_custom_chart"
|
|
263
278
|
]);
|
|
264
|
-
var WORKSPACE_SPANNING_TOOLS = /* @__PURE__ */ new Set([
|
|
279
|
+
var WORKSPACE_SPANNING_TOOLS = /* @__PURE__ */ new Set([
|
|
280
|
+
"list_dashboards"
|
|
281
|
+
]);
|
|
265
282
|
var WORKSPACE_INPUT = "workspace";
|
|
266
283
|
var WorkspaceSelectionError = class extends HostedAuthError {
|
|
267
284
|
constructor(status, code, reason) {
|
|
@@ -274,7 +291,9 @@ function grantedWorkspaces(principal) {
|
|
|
274
291
|
return principal.workspaceId ? [{ id: principal.workspaceId, name: principal.workspaceName }] : [];
|
|
275
292
|
}
|
|
276
293
|
function grantedNames(principal) {
|
|
277
|
-
const names = grantedWorkspaces(principal).map(
|
|
294
|
+
const names = grantedWorkspaces(principal).map(
|
|
295
|
+
(item) => item.name ?? item.id
|
|
296
|
+
);
|
|
278
297
|
return names.length > 20 ? `${names.slice(0, 20).join(", ")} and ${names.length - 20} more` : names.join(", ");
|
|
279
298
|
}
|
|
280
299
|
function findGranted(principal, key, value) {
|
|
@@ -311,7 +330,9 @@ function selectWorkspace(args, principal, keys = [WORKSPACE_INPUT, "workspaceNam
|
|
|
311
330
|
}
|
|
312
331
|
function namesWorkspace(value) {
|
|
313
332
|
if (!value || typeof value !== "object") return false;
|
|
314
|
-
return Object.entries(value).some(
|
|
333
|
+
return Object.entries(value).some(
|
|
334
|
+
([key, child]) => [WORKSPACE_INPUT, "workspaceName", "workspaceId"].includes(key) && child !== void 0 || namesWorkspace(child)
|
|
335
|
+
);
|
|
315
336
|
}
|
|
316
337
|
function tenantAllowed(child, principal, delegated) {
|
|
317
338
|
if (typeof child !== "string") return false;
|
|
@@ -357,18 +378,85 @@ function assertBoundArguments(value, principal, binding = {}) {
|
|
|
357
378
|
}
|
|
358
379
|
|
|
359
380
|
// src/hosted/server.ts
|
|
360
|
-
|
|
381
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
382
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
383
|
+
import { instrument } from "@posthog/mcp";
|
|
384
|
+
import { PostHog } from "posthog-node";
|
|
385
|
+
import { z as z2 } from "zod";
|
|
386
|
+
|
|
387
|
+
// src/tools/infrastructure/create-workspace.ts
|
|
388
|
+
import { z } from "zod";
|
|
389
|
+
function registerCreateWorkspace(server, client) {
|
|
390
|
+
server.tool(
|
|
391
|
+
"create_workspace",
|
|
392
|
+
'Create a new workspace to connect a datasource or datamart for dashboard building. Works only when this sign-in granted "All workspaces I can access" and your role may create workspaces; the new workspace is usable on the next call.',
|
|
393
|
+
{
|
|
394
|
+
name: z.string().describe("Unique workspace name"),
|
|
395
|
+
connectionType: z.enum(["DATASOURCE", "DATAMART", "MULTI_DATASOURCE"]).optional().describe(
|
|
396
|
+
"Connection type \u2014 DATASOURCE, DATAMART, or MULTI_DATASOURCE"
|
|
397
|
+
),
|
|
398
|
+
datasourceName: z.string().optional().describe(
|
|
399
|
+
"Datasource name (required when connectionType is DATASOURCE)"
|
|
400
|
+
),
|
|
401
|
+
datamartName: z.string().optional().describe("Datamart name (required when connectionType is DATAMART)"),
|
|
402
|
+
body: z.record(z.string(), z.unknown()).optional().describe("Raw API body passthrough for POST /api/v2/workspace")
|
|
403
|
+
},
|
|
404
|
+
WRITE_CREATE,
|
|
405
|
+
async (params) => {
|
|
406
|
+
try {
|
|
407
|
+
const body = params.body ? { ...params.body } : {
|
|
408
|
+
name: params.name,
|
|
409
|
+
connectionType: params.connectionType,
|
|
410
|
+
datasourceName: params.datasourceName,
|
|
411
|
+
datamartName: params.datamartName
|
|
412
|
+
};
|
|
413
|
+
const result = await client.request({
|
|
414
|
+
method: "POST",
|
|
415
|
+
path: "/api/v2/workspace",
|
|
416
|
+
body,
|
|
417
|
+
auth: "service",
|
|
418
|
+
invalidateCache: ["list_workspaces"]
|
|
419
|
+
});
|
|
420
|
+
if (result.error) {
|
|
421
|
+
return toolError(
|
|
422
|
+
`Workspace creation failed: ${stringify(result.error)}`
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
return toolSuccess(
|
|
426
|
+
`Workspace "${result.name || params.name}" created successfully.
|
|
427
|
+
|
|
428
|
+
Next steps:
|
|
429
|
+
1. Use \`create_data_app\` to create a data app linked to this workspace
|
|
430
|
+
2. Build dashboards in the Databrain UI using this workspace
|
|
431
|
+
3. Use \`create_embed\` to embed dashboards`
|
|
432
|
+
);
|
|
433
|
+
} catch (err) {
|
|
434
|
+
if (err instanceof DatabrainApiError) return err.toToolResponse();
|
|
435
|
+
return toolError(`Unexpected error: ${err.message}`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// src/hosted/server.ts
|
|
442
|
+
var schemaInstrumentation = new PostHog("hosted-contract-only", {
|
|
443
|
+
disabled: true
|
|
444
|
+
});
|
|
361
445
|
var callWorkspace = new AsyncLocalStorage();
|
|
362
446
|
var WORKSPACE_HINT = "Call whoami to see the workspaces this sign-in granted, then name one.";
|
|
363
447
|
function workspaceError(error) {
|
|
364
|
-
return toolError(
|
|
448
|
+
return toolError(
|
|
449
|
+
`Error: ${error.reason} (${error.code})
|
|
365
450
|
|
|
366
|
-
Hint: ${WORKSPACE_HINT}`
|
|
451
|
+
Hint: ${WORKSPACE_HINT}`
|
|
452
|
+
);
|
|
367
453
|
}
|
|
368
454
|
function scopeError(missing) {
|
|
369
|
-
return toolError(
|
|
455
|
+
return toolError(
|
|
456
|
+
`Error: This connection cannot do this. It needs ${missing.join(", ")}, which your role did not allow when you connected. (INSUFFICIENT_SCOPE)
|
|
370
457
|
|
|
371
|
-
Hint: Ask a Databrain admin for a role that allows it, then connect again. If your role allows it in a workspace you did not choose, connect again and choose that workspace.`
|
|
458
|
+
Hint: Ask a Databrain admin for a role that allows it, then connect again. If your role allows it in a workspace you did not choose, connect again and choose that workspace.`
|
|
459
|
+
);
|
|
372
460
|
}
|
|
373
461
|
function onlyWorkspace(raw, target) {
|
|
374
462
|
const keep = (items) => Array.isArray(items) ? items.filter((item) => {
|
|
@@ -376,15 +464,20 @@ function onlyWorkspace(raw, target) {
|
|
|
376
464
|
return record.id === target.id || record._id === target.id || record.id === void 0 && record._id === void 0 && target.name !== void 0 && record.name === target.name;
|
|
377
465
|
}) : items;
|
|
378
466
|
if (Array.isArray(raw)) return keep(raw);
|
|
379
|
-
if (raw && typeof raw === "object" && "data" in raw)
|
|
467
|
+
if (raw && typeof raw === "object" && "data" in raw)
|
|
468
|
+
return { ...raw, data: keep(raw.data) };
|
|
380
469
|
return raw;
|
|
381
470
|
}
|
|
382
471
|
var HostedClient = class extends DatabrainClient {
|
|
383
|
-
constructor(adapter, principal, delegation) {
|
|
384
|
-
super({
|
|
472
|
+
constructor(adapter, principal, delegation, brynCatalogue) {
|
|
473
|
+
super({
|
|
474
|
+
apiUrl: "https://api.usedatabrain.com",
|
|
475
|
+
demoDomain: "https://demo.usedatabrain.com"
|
|
476
|
+
});
|
|
385
477
|
this.adapter = adapter;
|
|
386
478
|
this.principal = principal;
|
|
387
479
|
this.delegation = delegation;
|
|
480
|
+
this.brynCatalogue = brynCatalogue;
|
|
388
481
|
}
|
|
389
482
|
// A generated API credential must never replace the authorizing hosted principal.
|
|
390
483
|
setRuntimeApiToken(_token) {
|
|
@@ -396,25 +489,58 @@ var HostedClient = class extends DatabrainClient {
|
|
|
396
489
|
try {
|
|
397
490
|
const name = toolContext.getStore()?.tool;
|
|
398
491
|
if (!name) throw new HostedAuthError(403, "tool_not_authorized");
|
|
399
|
-
authorizeTool(name, this.principal);
|
|
492
|
+
authorizeTool(name, this.principal, this.brynCatalogue);
|
|
400
493
|
const named = callWorkspace.getStore()?.target;
|
|
401
|
-
const target = named ??
|
|
494
|
+
const target = named ?? selectWorkspace(options.body, this.principal, [
|
|
495
|
+
"workspaceName",
|
|
496
|
+
"workspaceId"
|
|
497
|
+
]) ?? selectWorkspace(options.query, this.principal, [
|
|
498
|
+
"workspaceName",
|
|
499
|
+
"workspaceId"
|
|
500
|
+
]);
|
|
402
501
|
const delegated = this.delegation !== void 0;
|
|
403
|
-
const opaque = options.method === "POST" && options.path === PREVIEW_PATH ? ["rows"] : options.method === "POST" && /^\/api\/v2\/custom-charts\/[a-z][a-z0-9_-]*\/bryn\/turn$/.test(
|
|
404
|
-
|
|
405
|
-
|
|
502
|
+
const opaque = options.method === "POST" && options.path === PREVIEW_PATH ? ["rows"] : options.method === "POST" && /^\/api\/v2\/custom-charts\/[a-z][a-z0-9_-]*\/bryn\/turn$/.test(
|
|
503
|
+
options.path
|
|
504
|
+
) ? ["draft"] : options.method === "POST" && options.path === "/api/v2/theme-bryn/turn" ? ["theme", "dark", "schema", "contrast"] : options.method === "POST" && /^\/api\/mcp\/bryn\/tools\/[a-z][a-z0-9_]*$/.test(
|
|
505
|
+
options.path
|
|
506
|
+
) ? [
|
|
507
|
+
"input",
|
|
508
|
+
"draft",
|
|
509
|
+
"theme",
|
|
510
|
+
"dark",
|
|
511
|
+
"schema",
|
|
512
|
+
"contrast",
|
|
513
|
+
"followUp"
|
|
514
|
+
] : void 0;
|
|
515
|
+
assertBoundArguments(options.body, this.principal, {
|
|
516
|
+
target,
|
|
517
|
+
delegated,
|
|
518
|
+
opaque
|
|
519
|
+
});
|
|
520
|
+
assertBoundArguments(options.query, this.principal, {
|
|
521
|
+
target,
|
|
522
|
+
delegated
|
|
523
|
+
});
|
|
406
524
|
let raw = await this.adapter.executeRequest(
|
|
407
525
|
structuredClone(options),
|
|
408
526
|
structuredClone(this.principal),
|
|
409
527
|
this.delegation,
|
|
410
528
|
target && structuredClone(target)
|
|
411
529
|
);
|
|
412
|
-
if (named && options.method === "GET" && options.path === "/api/v2/workspace")
|
|
413
|
-
|
|
530
|
+
if (named && options.method === "GET" && options.path === "/api/v2/workspace")
|
|
531
|
+
raw = onlyWorkspace(raw, named);
|
|
532
|
+
if (!options.skipDataUnwrap && raw && typeof raw === "object" && !Array.isArray(raw) && "data" in raw && raw.data !== null && raw.data !== void 0)
|
|
533
|
+
return raw.data;
|
|
414
534
|
return raw;
|
|
415
535
|
} catch (error) {
|
|
416
536
|
if (error instanceof WorkspaceSelectionError) {
|
|
417
|
-
throw new DatabrainApiError(
|
|
537
|
+
throw new DatabrainApiError(
|
|
538
|
+
error.status,
|
|
539
|
+
error.code,
|
|
540
|
+
`${error.reason} (${error.code})`,
|
|
541
|
+
void 0,
|
|
542
|
+
WORKSPACE_HINT
|
|
543
|
+
);
|
|
418
544
|
}
|
|
419
545
|
const status = error instanceof HostedAuthError ? error.status : 503;
|
|
420
546
|
const detail = error instanceof HostedAuthError ? error.detail : void 0;
|
|
@@ -456,47 +582,75 @@ function workspaceLine(name, input) {
|
|
|
456
582
|
}
|
|
457
583
|
function whoami(principal) {
|
|
458
584
|
const granted = grantedWorkspaces(principal);
|
|
459
|
-
return toolSuccess(
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
585
|
+
return toolSuccess(
|
|
586
|
+
JSON.stringify(
|
|
587
|
+
{
|
|
588
|
+
status: "authenticated",
|
|
589
|
+
activeBearer: principal.credentialType,
|
|
590
|
+
actorKind: principal.actorKind,
|
|
591
|
+
userId: principal.userId,
|
|
592
|
+
companyId: principal.companyId,
|
|
593
|
+
workspaceGrant: principal.workspaces?.mode === "all" ? "All workspaces I can access (new ones are included)" : "Only the workspaces chosen at sign-in",
|
|
594
|
+
grantedWorkspaces: granted.map((item) => ({
|
|
595
|
+
id: item.id,
|
|
596
|
+
name: item.name
|
|
597
|
+
})),
|
|
598
|
+
...granted.length === 1 ? { workspaceId: granted[0].id, workspaceName: granted[0].name } : {},
|
|
599
|
+
note: granted.length === 1 ? "Every call works in this workspace." : `Name the workspace on each call that works in one (the \`${WORKSPACE_INPUT}\` or \`workspaceName\` input): ${grantedNames(principal)}.`,
|
|
600
|
+
oauthClientId: principal.clientId,
|
|
601
|
+
scopes: principal.scopes
|
|
602
|
+
},
|
|
603
|
+
null,
|
|
604
|
+
2
|
|
605
|
+
)
|
|
606
|
+
);
|
|
472
607
|
}
|
|
473
|
-
function createHostedToolServer(principal, adapter, delegation) {
|
|
608
|
+
function createHostedToolServer(principal, adapter, delegation, brynCatalogue) {
|
|
474
609
|
const bound = structuredClone(principal);
|
|
475
610
|
const delegated = delegation !== void 0;
|
|
476
|
-
const server = new McpServer(
|
|
477
|
-
|
|
478
|
-
|
|
611
|
+
const server = new McpServer(
|
|
612
|
+
{ name: "databrain", version: MCP_SERVER_VERSION },
|
|
613
|
+
{
|
|
614
|
+
instructions: "Hosted Databrain: each request is authenticated as the signed-in user, with that user's own role in each workspace. Use whoami to see the verified identity, scopes and the workspaces this sign-in granted. When more than one workspace is granted, name the workspace on each call (the workspace or workspaceName input). Never ask for a service token or put credentials in tool arguments. Workspace and tenant access come from the bearer principal. Existing dry-run and production-confirmation rules still apply. For existing saved charts, call list_widgets with dashboardId or dashboardName and no clientId; no embed, data app or datamart is needed. Use render_metric_tile to draw a metricRef directly. Use ask_question for new data questions. Chart controls and save_chart_as_dashboard use the same workspace and role; saving requires the existing production confirmation. Hosts without MCP Apps can read the text answer."
|
|
615
|
+
}
|
|
616
|
+
);
|
|
479
617
|
const register = server.tool.bind(server);
|
|
480
618
|
server.tool = ((...args) => {
|
|
481
619
|
const name = args[0];
|
|
482
620
|
const index = args.length - 1;
|
|
483
621
|
const handler = args[index];
|
|
484
622
|
const shape = typeof args[1] === "string" && args[2] && typeof args[2] === "object" && !("readOnlyHint" in args[2]) ? args[2] : void 0;
|
|
485
|
-
const ownSelector = shape && ["workspaceName", "workspaceId"].find(
|
|
623
|
+
const ownSelector = shape && ["workspaceName", "workspaceId"].find(
|
|
624
|
+
(key) => Object.hasOwn(shape, key)
|
|
625
|
+
);
|
|
486
626
|
const addsInput = shape !== void 0 && !ownSelector && !WORKSPACE_FREE_TOOLS.has(name) && !Object.hasOwn(shape, WORKSPACE_INPUT);
|
|
487
627
|
if (shape && !WORKSPACE_FREE_TOOLS.has(name)) {
|
|
488
628
|
if (addsInput) {
|
|
489
|
-
args[2] = {
|
|
629
|
+
args[2] = {
|
|
630
|
+
...shape,
|
|
631
|
+
[WORKSPACE_INPUT]: z2.string().min(1).max(512).optional().describe(
|
|
632
|
+
WORKSPACE_SPANNING_TOOLS.has(name) ? "Workspace id or name. Leave it out to cover every granted workspace." : "Workspace id or name. Needed when you granted more than one workspace."
|
|
633
|
+
)
|
|
634
|
+
};
|
|
490
635
|
}
|
|
491
636
|
args[1] = `${args[1]}${workspaceLine(name, addsInput ? WORKSPACE_INPUT : ownSelector)}`;
|
|
492
637
|
}
|
|
493
638
|
args[index] = async (...values) => {
|
|
494
639
|
try {
|
|
495
|
-
authorizeTool(name, bound);
|
|
496
|
-
const target = assertToolArguments(
|
|
640
|
+
authorizeTool(name, bound, brynCatalogue);
|
|
641
|
+
const target = assertToolArguments(
|
|
642
|
+
name,
|
|
643
|
+
values[0],
|
|
644
|
+
bound,
|
|
645
|
+
delegated,
|
|
646
|
+
brynCatalogue
|
|
647
|
+
);
|
|
497
648
|
if (name === "whoami") return whoami(bound);
|
|
498
649
|
const input = values[0] && typeof values[0] === "object" ? values[0] : void 0;
|
|
499
|
-
if (input?.projectDir !== void 0)
|
|
650
|
+
if (input?.projectDir !== void 0)
|
|
651
|
+
return toolError(
|
|
652
|
+
"projectDir is not available on the hosted server. Pass chartTs, manifest, bundleCss and iconSvg to compile in memory."
|
|
653
|
+
);
|
|
500
654
|
if (!target && !WORKSPACE_FREE_TOOLS.has(name) && !WORKSPACE_SPANNING_TOOLS.has(name) && !namesWorkspace(input)) {
|
|
501
655
|
throw new WorkspaceSelectionError(
|
|
502
656
|
400,
|
|
@@ -507,25 +661,41 @@ function createHostedToolServer(principal, adapter, delegation) {
|
|
|
507
661
|
if (input) {
|
|
508
662
|
if (addsInput) delete input[WORKSPACE_INPUT];
|
|
509
663
|
if (shape && target) {
|
|
510
|
-
if (Object.hasOwn(shape, "workspaceId"))
|
|
511
|
-
|
|
664
|
+
if (Object.hasOwn(shape, "workspaceId"))
|
|
665
|
+
input.workspaceId = target.id;
|
|
666
|
+
if (Object.hasOwn(shape, "workspaceName") && target.name !== void 0)
|
|
667
|
+
input.workspaceName = target.name;
|
|
512
668
|
}
|
|
513
669
|
}
|
|
514
|
-
const result = await callWorkspace.run(
|
|
670
|
+
const result = await callWorkspace.run(
|
|
671
|
+
{ target },
|
|
672
|
+
() => handler(...values)
|
|
673
|
+
);
|
|
515
674
|
if (target && result?._meta?.["ui/resourceUri"]) {
|
|
516
|
-
return {
|
|
675
|
+
return {
|
|
676
|
+
...result,
|
|
677
|
+
_meta: { ...result._meta, "databrain/workspace": target }
|
|
678
|
+
};
|
|
517
679
|
}
|
|
518
680
|
return result;
|
|
519
681
|
} catch (error) {
|
|
520
|
-
if (error instanceof WorkspaceSelectionError)
|
|
521
|
-
|
|
682
|
+
if (error instanceof WorkspaceSelectionError)
|
|
683
|
+
return workspaceError(error);
|
|
684
|
+
return toolError(
|
|
685
|
+
"Hosted authorization denied this tool call. Check the granted scopes, workspace and tenant access."
|
|
686
|
+
);
|
|
522
687
|
}
|
|
523
688
|
};
|
|
524
689
|
return register(...args);
|
|
525
690
|
});
|
|
526
|
-
const client = new HostedClient(adapter, bound, delegation);
|
|
691
|
+
const client = new HostedClient(adapter, bound, delegation, brynCatalogue);
|
|
527
692
|
enableChartApps(server);
|
|
528
693
|
registerAllTools(server, client);
|
|
694
|
+
if (brynCatalogue?.tools.length || brynCatalogue?.turns.length) {
|
|
695
|
+
registerBrynTools(server, client, brynCatalogue);
|
|
696
|
+
registerBrynTurns(server, client);
|
|
697
|
+
registerCatalogueTurns(server, client, brynCatalogue);
|
|
698
|
+
}
|
|
529
699
|
registerCreateWorkspace(server, client);
|
|
530
700
|
registerAllResources(server);
|
|
531
701
|
registerAllPrompts(server);
|
|
@@ -536,12 +706,16 @@ function createHostedToolServer(principal, adapter, delegation) {
|
|
|
536
706
|
|
|
537
707
|
// src/hosted/http.ts
|
|
538
708
|
var HEALTH_BODY = JSON.stringify({ status: "ok" });
|
|
709
|
+
var MAX_MCP_BODY_BYTES = 4 * 1024 * 1024 + 64 * 1024;
|
|
539
710
|
function healthPath(url) {
|
|
540
711
|
const path = (url || "/").split("?")[0];
|
|
541
712
|
return path === "/health";
|
|
542
713
|
}
|
|
543
714
|
function reply(res, status, body) {
|
|
544
|
-
res.writeHead(status, {
|
|
715
|
+
res.writeHead(status, {
|
|
716
|
+
"Content-Type": "application/json",
|
|
717
|
+
"Cache-Control": "no-store"
|
|
718
|
+
});
|
|
545
719
|
res.end(JSON.stringify(body));
|
|
546
720
|
}
|
|
547
721
|
async function readBody(req) {
|
|
@@ -549,7 +723,7 @@ async function readBody(req) {
|
|
|
549
723
|
const chunks = [];
|
|
550
724
|
for await (const chunk of req) {
|
|
551
725
|
size += chunk.length;
|
|
552
|
-
if (size >
|
|
726
|
+
if (size > MAX_MCP_BODY_BYTES) throw new Error("body_too_large");
|
|
553
727
|
chunks.push(Buffer.from(chunk));
|
|
554
728
|
}
|
|
555
729
|
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
@@ -560,6 +734,7 @@ function createHostedHttpServer(config, adapter, pats) {
|
|
|
560
734
|
const metadataUrl = `${publicUrl.origin}${metadataPath}`;
|
|
561
735
|
const verify = createBearerVerifier(adapter, config.publicUrl, pats);
|
|
562
736
|
let active = 0;
|
|
737
|
+
const catalogueCache = new BrynCatalogueCache();
|
|
563
738
|
const server = createServer(async (req, res) => {
|
|
564
739
|
if (healthPath(req.url)) {
|
|
565
740
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
@@ -579,7 +754,11 @@ function createHostedHttpServer(config, adapter, pats) {
|
|
|
579
754
|
if (config.testOnly) res.setHeader("X-Databrain-Test-Only", "true");
|
|
580
755
|
try {
|
|
581
756
|
const localPort = server.address()?.port;
|
|
582
|
-
if (![
|
|
757
|
+
if (![
|
|
758
|
+
publicUrl.host,
|
|
759
|
+
`127.0.0.1:${localPort}`,
|
|
760
|
+
`localhost:${localPort}`
|
|
761
|
+
].includes(req.headers.host || "")) {
|
|
583
762
|
reply(res, 403, { error: "invalid_host" });
|
|
584
763
|
return;
|
|
585
764
|
}
|
|
@@ -590,10 +769,15 @@ function createHostedHttpServer(config, adapter, pats) {
|
|
|
590
769
|
if (req.headers.origin) {
|
|
591
770
|
res.setHeader("Access-Control-Allow-Origin", publicUrl.origin);
|
|
592
771
|
res.setHeader("Vary", "Origin");
|
|
593
|
-
res.setHeader(
|
|
772
|
+
res.setHeader(
|
|
773
|
+
"Access-Control-Expose-Headers",
|
|
774
|
+
"WWW-Authenticate, MCP-Protocol-Version"
|
|
775
|
+
);
|
|
594
776
|
}
|
|
595
777
|
const url = new URL(req.url || "/", publicUrl.origin);
|
|
596
|
-
if (req.method === "GET" && [metadataPath, "/.well-known/oauth-protected-resource"].includes(
|
|
778
|
+
if (req.method === "GET" && [metadataPath, "/.well-known/oauth-protected-resource"].includes(
|
|
779
|
+
url.pathname
|
|
780
|
+
)) {
|
|
597
781
|
reply(res, 200, {
|
|
598
782
|
resource: config.publicUrl,
|
|
599
783
|
authorization_servers: [config.issuer],
|
|
@@ -612,15 +796,24 @@ function createHostedHttpServer(config, adapter, pats) {
|
|
|
612
796
|
return;
|
|
613
797
|
}
|
|
614
798
|
if (req.method === "OPTIONS") {
|
|
615
|
-
res.setHeader(
|
|
616
|
-
|
|
799
|
+
res.setHeader(
|
|
800
|
+
"Access-Control-Allow-Methods",
|
|
801
|
+
"POST, GET, DELETE, OPTIONS"
|
|
802
|
+
);
|
|
803
|
+
res.setHeader(
|
|
804
|
+
"Access-Control-Allow-Headers",
|
|
805
|
+
"Authorization, Content-Type, MCP-Protocol-Version, MCP-Session-Id"
|
|
806
|
+
);
|
|
617
807
|
res.writeHead(204);
|
|
618
808
|
res.end();
|
|
619
809
|
return;
|
|
620
810
|
}
|
|
621
|
-
const authFields = req.rawHeaders.filter(
|
|
811
|
+
const authFields = req.rawHeaders.filter(
|
|
812
|
+
(_, i) => i % 2 === 0 && req.rawHeaders[i].toLowerCase() === "authorization"
|
|
813
|
+
);
|
|
622
814
|
const match = /^Bearer ([^\s]+)$/i.exec(req.headers.authorization || "");
|
|
623
|
-
if (authFields.length !== 1 || !match)
|
|
815
|
+
if (authFields.length !== 1 || !match)
|
|
816
|
+
throw new HostedAuthError(401, "invalid_token");
|
|
624
817
|
const principal = await verify(match[1]);
|
|
625
818
|
if (req.method !== "POST") {
|
|
626
819
|
res.setHeader("Allow", "POST, OPTIONS");
|
|
@@ -650,25 +843,87 @@ function createHostedHttpServer(config, adapter, pats) {
|
|
|
650
843
|
return;
|
|
651
844
|
}
|
|
652
845
|
const delegation = match[1].startsWith("dbp_") ? void 0 : adapter.delegate?.(match[1], principal);
|
|
846
|
+
let brynCatalogue = { tools: [], turns: [] };
|
|
847
|
+
let catalogueRetryAfter;
|
|
848
|
+
if (adapter.getBrynCatalogue) {
|
|
849
|
+
const read = await catalogueCache.get(
|
|
850
|
+
`${principal.companyId}:${principal.userId}:${principal.credentialType}`,
|
|
851
|
+
async () => {
|
|
852
|
+
let raw;
|
|
853
|
+
try {
|
|
854
|
+
raw = await adapter.getBrynCatalogue(principal, delegation);
|
|
855
|
+
} catch (error) {
|
|
856
|
+
if (error instanceof HostedAuthError) {
|
|
857
|
+
if (error.detail?.status === 429)
|
|
858
|
+
throw new RateLimitedError(error.retryAfterMs);
|
|
859
|
+
if (error.status === 404) return { tools: [], turns: [] };
|
|
860
|
+
}
|
|
861
|
+
throw error;
|
|
862
|
+
}
|
|
863
|
+
try {
|
|
864
|
+
return parseBrynCatalogue(raw);
|
|
865
|
+
} catch {
|
|
866
|
+
return { tools: [], turns: [] };
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
);
|
|
870
|
+
brynCatalogue = read.catalogue;
|
|
871
|
+
if (read.degraded) catalogueRetryAfter = read.retryAfterSeconds;
|
|
872
|
+
}
|
|
653
873
|
if (body?.method === "tools/call" && typeof body.params?.name === "string") {
|
|
654
874
|
try {
|
|
655
|
-
authorizeTool(body.params.name, principal);
|
|
656
|
-
assertToolArguments(
|
|
875
|
+
authorizeTool(body.params.name, principal, brynCatalogue);
|
|
876
|
+
assertToolArguments(
|
|
877
|
+
body.params.name,
|
|
878
|
+
body.params.arguments,
|
|
879
|
+
principal,
|
|
880
|
+
delegation !== void 0,
|
|
881
|
+
brynCatalogue
|
|
882
|
+
);
|
|
657
883
|
} catch (error) {
|
|
658
884
|
if (error instanceof WorkspaceSelectionError) {
|
|
659
|
-
reply(res, 200, {
|
|
885
|
+
reply(res, 200, {
|
|
886
|
+
jsonrpc: "2.0",
|
|
887
|
+
id: body.id ?? null,
|
|
888
|
+
result: workspaceError(error)
|
|
889
|
+
});
|
|
660
890
|
return;
|
|
661
891
|
}
|
|
662
892
|
if (error instanceof HostedAuthError && error.code === "insufficient_scope") {
|
|
663
|
-
const missing = requiredScopes(
|
|
664
|
-
|
|
893
|
+
const missing = requiredScopes(
|
|
894
|
+
body.params.name,
|
|
895
|
+
brynCatalogue
|
|
896
|
+
).filter((scope) => !principal.scopes.includes(scope));
|
|
897
|
+
reply(res, 200, {
|
|
898
|
+
jsonrpc: "2.0",
|
|
899
|
+
id: body.id ?? null,
|
|
900
|
+
result: scopeError(missing)
|
|
901
|
+
});
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
if (catalogueRetryAfter !== void 0 && error instanceof HostedAuthError && error.code === "tool_not_authorized") {
|
|
905
|
+
reply(res, 200, {
|
|
906
|
+
jsonrpc: "2.0",
|
|
907
|
+
id: body.id ?? null,
|
|
908
|
+
result: toolError(
|
|
909
|
+
`Error: Bryn tools are temporarily unavailable because the tool list could not be read (rate limited or busy). Retry in ${catalogueRetryAfter} seconds. (TEMPORARILY_UNAVAILABLE)`
|
|
910
|
+
)
|
|
911
|
+
});
|
|
665
912
|
return;
|
|
666
913
|
}
|
|
667
914
|
throw error;
|
|
668
915
|
}
|
|
669
916
|
}
|
|
670
|
-
const mcp = createHostedToolServer(
|
|
671
|
-
|
|
917
|
+
const mcp = createHostedToolServer(
|
|
918
|
+
principal,
|
|
919
|
+
adapter,
|
|
920
|
+
delegation,
|
|
921
|
+
brynCatalogue
|
|
922
|
+
);
|
|
923
|
+
const transport = new StreamableHTTPServerTransport({
|
|
924
|
+
sessionIdGenerator: void 0,
|
|
925
|
+
enableJsonResponse: true
|
|
926
|
+
});
|
|
672
927
|
let closed = false;
|
|
673
928
|
const close = () => {
|
|
674
929
|
if (!closed) {
|
|
@@ -692,8 +947,14 @@ function createHostedHttpServer(config, adapter, pats) {
|
|
|
692
947
|
return;
|
|
693
948
|
}
|
|
694
949
|
const status = error instanceof HostedAuthError ? error.status : 503;
|
|
695
|
-
if (status === 401)
|
|
696
|
-
|
|
950
|
+
if (status === 401)
|
|
951
|
+
res.setHeader(
|
|
952
|
+
"WWW-Authenticate",
|
|
953
|
+
`Bearer error="invalid_token", resource_metadata="${metadataUrl}"`
|
|
954
|
+
);
|
|
955
|
+
reply(res, status, {
|
|
956
|
+
error: error instanceof HostedAuthError ? error.code : "temporarily_unavailable"
|
|
957
|
+
});
|
|
697
958
|
}
|
|
698
959
|
});
|
|
699
960
|
server.requestTimeout = 3e4;
|
|
@@ -708,7 +969,12 @@ var USER_AGENT = `databrain-mcp-server/${MCP_SERVER_VERSION} (hosted)`;
|
|
|
708
969
|
var REQUEST_TIMEOUT_MS = 3e4;
|
|
709
970
|
var FORWARDED_HEADERS = /* @__PURE__ */ new Set(["idempotency-key"]);
|
|
710
971
|
var WORKSPACE_HEADER = "X-Databrain-Workspace";
|
|
711
|
-
var GRANT_DENIALS = /* @__PURE__ */ new Set([
|
|
972
|
+
var GRANT_DENIALS = /* @__PURE__ */ new Set([
|
|
973
|
+
"ROLE_NOT_ALLOWED",
|
|
974
|
+
"WORKSPACE_CREATE_NOT_ALLOWED",
|
|
975
|
+
"forbidden_permission",
|
|
976
|
+
"insufficient_scope"
|
|
977
|
+
]);
|
|
712
978
|
function errorDetail(status, text, bearer) {
|
|
713
979
|
let parsed;
|
|
714
980
|
try {
|
|
@@ -754,20 +1020,27 @@ function createProviderAdapter(config) {
|
|
|
754
1020
|
const publicOrigin = new URL(config.publicUrl).origin;
|
|
755
1021
|
const bearers = /* @__PURE__ */ new WeakMap();
|
|
756
1022
|
const responseSchema = principalSchema.extend({ issuer: z3.literal(config.issuer) }).strip();
|
|
757
|
-
|
|
1023
|
+
const adapter = {
|
|
758
1024
|
async verifyOAuth(token, resource) {
|
|
759
1025
|
try {
|
|
760
1026
|
const response = await fetch(endpoint, {
|
|
761
|
-
headers: {
|
|
1027
|
+
headers: {
|
|
1028
|
+
Authorization: `Bearer ${token}`,
|
|
1029
|
+
Accept: "application/json"
|
|
1030
|
+
},
|
|
762
1031
|
redirect: "error",
|
|
763
1032
|
signal: AbortSignal.timeout(1e4)
|
|
764
1033
|
});
|
|
765
1034
|
if (!response.ok) {
|
|
766
|
-
if (response.status === 429 || response.status >= 500)
|
|
1035
|
+
if (response.status === 429 || response.status >= 500)
|
|
1036
|
+
throw new HostedAuthError(503, "temporarily_unavailable");
|
|
767
1037
|
throw new HostedAuthError(401, "invalid_token");
|
|
768
1038
|
}
|
|
769
|
-
const { issuer: _issuer, ...principal } = responseSchema.parse(
|
|
770
|
-
|
|
1039
|
+
const { issuer: _issuer, ...principal } = responseSchema.parse(
|
|
1040
|
+
await response.json()
|
|
1041
|
+
);
|
|
1042
|
+
if (principal.resource !== resource || principal.credentialType !== "oauth")
|
|
1043
|
+
throw new HostedAuthError(401, "invalid_token");
|
|
771
1044
|
return principal;
|
|
772
1045
|
} catch (error) {
|
|
773
1046
|
if (error instanceof HostedAuthError) throw error;
|
|
@@ -778,18 +1051,25 @@ function createProviderAdapter(config) {
|
|
|
778
1051
|
throw new HostedAuthError(401, "pat_membership_adapter_required");
|
|
779
1052
|
},
|
|
780
1053
|
delegate(token) {
|
|
781
|
-
const handle = Object.freeze({
|
|
1054
|
+
const handle = Object.freeze({
|
|
1055
|
+
kind: "hosted-delegation"
|
|
1056
|
+
});
|
|
782
1057
|
bearers.set(handle, token);
|
|
783
1058
|
return handle;
|
|
784
1059
|
},
|
|
785
1060
|
async executeRequest(options, principal, delegation, workspace) {
|
|
786
1061
|
const bearer = delegation ? bearers.get(delegation) : void 0;
|
|
787
|
-
if (options.auth !== "none" && !bearer)
|
|
788
|
-
|
|
789
|
-
if (
|
|
1062
|
+
if (options.auth !== "none" && !bearer)
|
|
1063
|
+
throw new HostedAuthError(503, "delegated_backend_adapter_required");
|
|
1064
|
+
if (options.auth === "none" && !options.token)
|
|
1065
|
+
throw new HostedAuthError(403, "tool_not_authorized");
|
|
1066
|
+
if (!options.path.startsWith("/") || options.path.startsWith("//"))
|
|
1067
|
+
throw new HostedAuthError(403, "tool_not_authorized");
|
|
790
1068
|
const url = new URL(`${apiBase}${options.path}`);
|
|
791
|
-
if (url.origin !== apiOrigin)
|
|
792
|
-
|
|
1069
|
+
if (url.origin !== apiOrigin)
|
|
1070
|
+
throw new HostedAuthError(403, "tool_not_authorized");
|
|
1071
|
+
for (const [key, value] of Object.entries(options.query ?? {}))
|
|
1072
|
+
url.searchParams.set(key, value);
|
|
793
1073
|
const headers = {
|
|
794
1074
|
Accept: "application/json",
|
|
795
1075
|
"Content-Type": "application/json",
|
|
@@ -812,7 +1092,10 @@ function createProviderAdapter(config) {
|
|
|
812
1092
|
} else {
|
|
813
1093
|
headers.Authorization = `Bearer ${bearer}`;
|
|
814
1094
|
if (workspace) {
|
|
815
|
-
if (!principal.grantedWorkspaces?.some(
|
|
1095
|
+
if (!principal.grantedWorkspaces?.some(
|
|
1096
|
+
(item) => item.id === workspace.id
|
|
1097
|
+
) && principal.workspaceId !== workspace.id)
|
|
1098
|
+
throw new HostedAuthError(403, "principal_mismatch");
|
|
816
1099
|
headers[WORKSPACE_HEADER] = workspace.id;
|
|
817
1100
|
}
|
|
818
1101
|
}
|
|
@@ -823,27 +1106,41 @@ function createProviderAdapter(config) {
|
|
|
823
1106
|
body: options.body === void 0 ? void 0 : JSON.stringify(options.body),
|
|
824
1107
|
signal: AbortSignal.timeout(options.timeoutMs ?? REQUEST_TIMEOUT_MS)
|
|
825
1108
|
});
|
|
826
|
-
const text = await response.text();
|
|
1109
|
+
const text = options.responseFormat === "bryn-sse" && response.ok ? await readBrynStream(response) : await response.text();
|
|
827
1110
|
if (!response.ok) {
|
|
828
1111
|
const forbidden = response.status === 403 ? errorDetail(403, text, bearer) : void 0;
|
|
829
1112
|
if (forbidden?.code && GRANT_DENIALS.has(forbidden.code)) {
|
|
830
1113
|
throw new HostedAuthError(
|
|
831
1114
|
403,
|
|
832
1115
|
"delegated_request_denied",
|
|
833
|
-
forbidden.code === "insufficient_scope" && options.path === "/api/v2/guest-token/create" ? {
|
|
1116
|
+
forbidden.code === "insufficient_scope" && options.path === "/api/v2/guest-token/create" ? {
|
|
1117
|
+
...forbidden,
|
|
1118
|
+
message: "Guest/tenant operations require mcp:embeds:write. Ask an admin for a role that allows it, then connect again."
|
|
1119
|
+
} : forbidden
|
|
834
1120
|
);
|
|
835
1121
|
}
|
|
836
|
-
const status = response.status === 403 && options.responseFormat === "studio-sse" ? 403 : response.status === 401 ? 401 : response.status >= 500 ? 503 : response.status === 403 || response.status === 404 ? 404 : 400;
|
|
1122
|
+
const status = response.status === 403 && (options.responseFormat === "studio-sse" || options.responseFormat === "bryn-sse") ? 403 : response.status === 401 ? 401 : response.status >= 500 ? 503 : response.status === 403 || response.status === 404 ? 404 : 400;
|
|
837
1123
|
const detail = status === 401 || status === 503 || response.status === 403 ? void 0 : errorDetail(response.status, text, bearer);
|
|
838
|
-
throw new HostedAuthError(
|
|
1124
|
+
throw new HostedAuthError(
|
|
1125
|
+
status,
|
|
1126
|
+
"delegated_request_denied",
|
|
1127
|
+
detail,
|
|
1128
|
+
response.status === 429 ? parseRetryAfter(response.headers.get("retry-after")) : void 0
|
|
1129
|
+
);
|
|
839
1130
|
}
|
|
840
1131
|
try {
|
|
841
|
-
return options.responseFormat === "studio-sse" ? parseStudioStream(text) : text ? JSON.parse(text) : null;
|
|
1132
|
+
return options.responseFormat === "studio-sse" ? parseStudioStream(text) : options.responseFormat === "bryn-sse" ? parseBrynStream(text) : text ? JSON.parse(text) : null;
|
|
842
1133
|
} catch {
|
|
843
1134
|
throw new HostedAuthError(503, "delegated_response_invalid");
|
|
844
1135
|
}
|
|
845
1136
|
}
|
|
846
1137
|
};
|
|
1138
|
+
adapter.getBrynCatalogue = (principal, delegation) => adapter.executeRequest(
|
|
1139
|
+
{ method: "GET", path: "/api/mcp/bryn/catalog", auth: "service" },
|
|
1140
|
+
principal,
|
|
1141
|
+
delegation
|
|
1142
|
+
);
|
|
1143
|
+
return adapter;
|
|
847
1144
|
}
|
|
848
1145
|
|
|
849
1146
|
// src/hosted/main.ts
|
|
@@ -852,33 +1149,51 @@ async function startHosted() {
|
|
|
852
1149
|
let adapter;
|
|
853
1150
|
if (config.testOnly) {
|
|
854
1151
|
const file = process.env.DATABRAIN_HOSTED_TEST_FIXTURES;
|
|
855
|
-
if (!file || !isAbsolute(file))
|
|
856
|
-
|
|
1152
|
+
if (!file || !isAbsolute(file))
|
|
1153
|
+
throw new Error(
|
|
1154
|
+
"Hosted test mode requires an absolute fixture store path."
|
|
1155
|
+
);
|
|
1156
|
+
const { createTestOnlyAdapter } = await import("./test-only-VH3FF5ML.js");
|
|
857
1157
|
adapter = createTestOnlyAdapter(file);
|
|
858
1158
|
} else {
|
|
859
1159
|
const file = process.env.DATABRAIN_HOSTED_ADAPTER_MODULE;
|
|
860
1160
|
if (!file) {
|
|
861
1161
|
adapter = createProviderAdapter(config);
|
|
862
|
-
process.stderr.write(
|
|
1162
|
+
process.stderr.write(
|
|
1163
|
+
"[databrain] OAuth configured: data tools run as the signed-in user through the issuer; PAT membership requires a trusted adapter.\n"
|
|
1164
|
+
);
|
|
863
1165
|
} else {
|
|
864
|
-
if (!isAbsolute(file))
|
|
1166
|
+
if (!isAbsolute(file))
|
|
1167
|
+
throw new Error(
|
|
1168
|
+
"DATABRAIN_HOSTED_ADAPTER_MODULE must be an absolute path."
|
|
1169
|
+
);
|
|
865
1170
|
try {
|
|
866
1171
|
adapter = (await import(pathToFileURL(file).href)).adapter;
|
|
867
1172
|
} catch {
|
|
868
1173
|
throw new Error("Could not load the trusted hosted adapter.");
|
|
869
1174
|
}
|
|
870
1175
|
}
|
|
871
|
-
if (!adapter || ["verifyOAuth", "resolvePatGrant", "executeRequest"].some(
|
|
1176
|
+
if (!adapter || ["verifyOAuth", "resolvePatGrant", "executeRequest"].some(
|
|
1177
|
+
(key) => typeof adapter[key] !== "function"
|
|
1178
|
+
))
|
|
1179
|
+
throw new Error("Invalid trusted hosted adapter.");
|
|
872
1180
|
}
|
|
873
1181
|
const patPath = process.env.DATABRAIN_HOSTED_PAT_STORE;
|
|
874
|
-
if (patPath && !isAbsolute(patPath))
|
|
875
|
-
|
|
1182
|
+
if (patPath && !isAbsolute(patPath))
|
|
1183
|
+
throw new Error("Hosted PAT store must be an absolute path.");
|
|
1184
|
+
const server = createHostedHttpServer(
|
|
1185
|
+
config,
|
|
1186
|
+
adapter,
|
|
1187
|
+
patPath ? new FilePatStore(patPath) : void 0
|
|
1188
|
+
);
|
|
876
1189
|
await new Promise((resolve, reject) => {
|
|
877
1190
|
server.once("error", reject);
|
|
878
1191
|
server.listen(config.port, config.bind, resolve);
|
|
879
1192
|
});
|
|
880
|
-
process.stderr.write(
|
|
881
|
-
`
|
|
1193
|
+
process.stderr.write(
|
|
1194
|
+
`[databrain] hosted HTTP ready on ${config.bind}:${config.port}/mcp${config.testOnly ? " \u2014 TEST ONLY: synthetic identity and data; no OAuth consent" : ""}
|
|
1195
|
+
`
|
|
1196
|
+
);
|
|
882
1197
|
const shutdown = () => {
|
|
883
1198
|
server.close(() => process.exit(0));
|
|
884
1199
|
setTimeout(() => {
|
|
@@ -892,4 +1207,4 @@ async function startHosted() {
|
|
|
892
1207
|
export {
|
|
893
1208
|
startHosted
|
|
894
1209
|
};
|
|
895
|
-
//# sourceMappingURL=main-
|
|
1210
|
+
//# sourceMappingURL=main-2FKQQLWX.js.map
|