mcp-tools.ts1022 lines · main
1import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2import { z } from 'zod';
3
4import { runInProjectDatabase } from '../db/data-plane.js';
5import type { McpKeyScope } from '../db/schema.js';
6import { env } from '../env.js';
7import { audit } from './audit.js';
8import {
9 AUTH_BRIDGE_TOOLS,
10 AUTH_BRIDGE_WRITE_TOOLS,
11 registerAuthBridgeTools,
12} from './mcp-auth-bridge.js';
13import { BRIVEN_ASK_TOOLS, registerBrivenAskTool } from './mcp-briven-ask.js';
14import {
15 DB_LIFECYCLE_ADMIN_TOOLS,
16 DB_LIFECYCLE_READ_TOOLS,
17 DB_LIFECYCLE_WRITE_TOOLS,
18 registerDbLifecycleAdminTools,
19 registerDbLifecycleReadTools,
20 registerDbLifecycleWriteTools,
21} from './mcp-db-lifecycle.js';
22import { signedTransformUrl, isImageTransformConfigured } from './image-transform.js';
23import {
24 deleteFile,
25 listDeletedFiles,
26 listFiles,
27 presignDownload,
28 presignUpload,
29 restoreFile,
30 setFilePublic,
31} from './storage.js';
32import { createStorageKey, listStorageKeys, revokeStorageKey } from './storage-keys.js';
33import {
34 createGrant,
35 isGranted,
36 listGrants,
37 revokeGrant,
38} from './storage-grants.js';
39import {
40 createShareLink,
41 listShareLinks,
42 revokeShareLink,
43} from './storage-share-links.js';
44import {
45 STUDIO_COLUMN_TYPES,
46 createTable,
47 getTableColumns,
48 insertRow,
49 listProjectTables,
50} from './studio.js';
51import { TIERS, getProjectTier } from './tiers.js';
52
53/**
54 * B Phase 5 / mcp.briven.tech — the MCP tool set.
55 *
56 * THE ISOLATION CONTRACT (must never bend):
57 * - NO tool accepts a `projectId` argument. Every tool derives the project
58 * id ONLY from the verified key binding (`McpToolContext.projectId`) and
59 * runs through `runInProjectDatabase(boundProjectId, …)`, which opens a
60 * connection bound to that one project's DoltGres database. An agent can
61 * therefore never reach another project's data, whatever it sends.
62 * - A read-only key never even SEES the write tools: create_table/insert/
63 * update/delete are only REGISTERED when scope ∈ {read-write, admin}, so
64 * they are absent from `tools/list` for a `read` key.
65 * - Every `tools/call` is audited under the `mcp.*` prefix (actor = keyId,
66 * project = bound projectId). Row values are never written to the audit log.
67 *
68 * The read tools mirror the Studio service's already-proven DoltGres-safe read
69 * path (table listing, column introspection) rather than inventing new SQL
70 * handling; the write tools reuse / mirror the same validated-identifier +
71 * parameterised-value discipline.
72 */
73
74/** The verified, immutable binding a built server closes over. */
75export interface McpToolContext {
76 readonly keyId: string;
77 readonly projectId: string;
78 readonly scope: McpKeyScope;
79 readonly ipHash: string | null;
80 readonly userAgent: string | null;
81}
82
83/** Same identifier rule Studio enforces — blocks SQL-injection via column names. */
84const COLUMN_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
85
86/** Hard cap on rows a single `query` call returns, so a huge table can't OOM. */
87const QUERY_ROW_CAP = 1000;
88
89/**
90 * Read-only guard for the `query` tool. Only a single SELECT/WITH statement is
91 * allowed; any DML/DDL keyword or a second statement is refused. This is the
92 * real defence — the tool deliberately does NOT issue `SET dolt_transaction_commit`,
93 * but the keyword gate is what stops a data-modifying CTE from ever running.
94 */
95const READONLY_LEAD_RE = /^\s*(select|with)\b/i;
96const FORBIDDEN_KEYWORD_RE =
97 /\b(insert|update|delete|drop|alter|create|truncate|grant|revoke|merge|replace)\b/i;
98
99export function assertReadOnlyQuery(sql: string): string {
100 if (typeof sql !== 'string' || sql.trim() === '') {
101 throw new Error('sql is required');
102 }
103 if (sql.length > 16 * 1024) {
104 throw new Error('sql payload too large');
105 }
106 const trimmed = sql.trim().replace(/;\s*$/, '');
107 if (trimmed.includes(';')) {
108 throw new Error('only a single statement is allowed');
109 }
110 if (!READONLY_LEAD_RE.test(trimmed)) {
111 throw new Error('only read-only SELECT / WITH queries are allowed');
112 }
113 if (FORBIDDEN_KEYWORD_RE.test(trimmed)) {
114 throw new Error('query contains a forbidden (write) keyword');
115 }
116 return trimmed;
117}
118
119/** Turn any tool payload into the MCP text-content result shape. */
120function jsonResult(payload: unknown): {
121 content: { type: 'text'; text: string }[];
122} {
123 return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
124}
125
126/** Validate a column→value map against the table's real columns; return the keys. */
127async function validatedColumns(
128 projectId: string,
129 table: string,
130 values: Record<string, unknown>,
131 label: string,
132): Promise<string[]> {
133 // getTableColumns also asserts the table exists in THIS project and refuses
134 // the platform-owned `_briven_*` tables — so it doubles as the existence gate.
135 const cols = await getTableColumns(projectId, table);
136 const colNames = new Set(cols.map((c) => c.name));
137 const keys = Object.keys(values);
138 if (keys.length === 0) {
139 throw new Error(`${label} requires at least one column`);
140 }
141 for (const k of keys) {
142 if (!COLUMN_NAME_RE.test(k)) throw new Error(`invalid column name: ${k}`);
143 if (!colNames.has(k)) throw new Error(`column not found on table: ${k}`);
144 }
145 return keys;
146}
147
148/** Parameterised UPDATE bound to the project's own database. */
149async function runUpdate(
150 projectId: string,
151 table: string,
152 match: Record<string, unknown>,
153 set: Record<string, unknown>,
154): Promise<number> {
155 const setKeys = await validatedColumns(projectId, table, set, 'update set');
156 const matchKeys = await validatedColumns(projectId, table, match, 'update match');
157 // Refuse an unbounded UPDATE — a missing match would rewrite every row.
158 const params: unknown[] = [];
159 const setSql = setKeys
160 .map((k) => {
161 params.push(set[k]);
162 return `"${k}" = $${params.length}`;
163 })
164 .join(', ');
165 const whereSql = matchKeys
166 .map((k) => {
167 params.push(match[k]);
168 return `"${k}" = $${params.length}`;
169 })
170 .join(' AND ');
171 const rows = await runInProjectDatabase(projectId, async (tx) => {
172 await tx.unsafe('SET dolt_transaction_commit = 1');
173 return tx.unsafe(
174 // RETURNING * (real columns), NOT `RETURNING 1` — DoltGres rejects a bare
175 // integer literal in RETURNING and throws, which silently failed writes.
176 `UPDATE "${table}" SET ${setSql} WHERE ${whereSql} RETURNING *`,
177 params,
178 );
179 });
180 return rows.length;
181}
182
183/** Parameterised DELETE bound to the project's own database. */
184async function runDelete(
185 projectId: string,
186 table: string,
187 match: Record<string, unknown>,
188): Promise<number> {
189 const matchKeys = await validatedColumns(projectId, table, match, 'delete match');
190 // Refuse an unbounded DELETE — `validatedColumns` already requires ≥1 key.
191 const params: unknown[] = [];
192 const whereSql = matchKeys
193 .map((k) => {
194 params.push(match[k]);
195 return `"${k}" = $${params.length}`;
196 })
197 .join(' AND ');
198 const rows = await runInProjectDatabase(projectId, async (tx) => {
199 await tx.unsafe('SET dolt_transaction_commit = 1');
200 return tx.unsafe(`DELETE FROM "${table}" WHERE ${whereSql} RETURNING *`, params);
201 });
202 return rows.length;
203}
204
205/**
206 * Build a fresh `McpServer` hard-bound to one verified key's project + scope.
207 * One server is created per request in the stateless transport model, so the
208 * binding can never leak across keys / sessions.
209 */
210export function buildMcpServer(ctx: McpToolContext): McpServer {
211 const server = new McpServer(
212 { name: 'briven-mcp', version: '1.0.0' },
213 {
214 instructions:
215 'Briven data-plane access. THIS IS YOUR ENVIRONMENT — build your ' +
216 'solution WITHIN it, using only these tools. The structure and ' +
217 'functionality are here; work with them. ' +
218 'Every tool operates ONLY on the single project this key is bound to; ' +
219 'you cannot select another project, and no project can reach another ' +
220 "project's data. " +
221 'Do NOT stand up a separate/side database or server for your project, ' +
222 'do NOT expect a durable raw or superuser SQL login, and do NOT ask ' +
223 'briven for special credentials or bespoke features — solve it with the ' +
224 'primitives you have here (create_table, describe_table, list_tables, ' +
225 'query, insert, update, delete, storage_*, auth_*). Being clever is not ' +
226 'the same as being allowed off-platform. ' +
227 'The auth_* and sender_domain_status tools answer auth / sign-in-email ' +
228 'configuration questions with read-only facts plus "apply in your ' +
229 'project" guidance and docs citations. For ANY other briven question ' +
230 '(database, storage, functions, realtime, limits, migration, hosting) — ' +
231 'or if you genuinely believe something is a platform BUG or a MISSING ' +
232 'FEATURE — ask briven_ask FIRST: it gives the platform picture, the ' +
233 'available primitives, and the exact part you build in your own project, ' +
234 'and files genuinely-missing capabilities for the platform team. A ' +
235 'missing capability is a briven_ask filing, never a licence to go ' +
236 'off-platform or build a workaround.',
237 },
238 );
239
240 // Audit helper — actor is the keyId, project is the bound project id. Never
241 // logs row values, only the tool name + table touched.
242 const auditCall = (tool: string, metadata: Record<string, unknown>): Promise<void> =>
243 audit({
244 actorId: ctx.keyId,
245 projectId: ctx.projectId,
246 action: `mcp.tool.${tool}`,
247 ipHash: ctx.ipHash,
248 userAgent: ctx.userAgent,
249 metadata: { ...metadata, keyId: ctx.keyId, scope: ctx.scope },
250 });
251
252 /* ── read tools — available to EVERY key (read, read-write, admin) ──── */
253
254 server.registerTool(
255 'list_tables',
256 {
257 title: 'List tables',
258 description:
259 'List the tables in your project database (name, approx row count, size). ' +
260 'Platform-owned tables are hidden.',
261 annotations: { readOnlyHint: true },
262 },
263 async () => {
264 await auditCall('list_tables', {});
265 const tables = await listProjectTables(ctx.projectId);
266 return jsonResult({ tables });
267 },
268 );
269
270 server.registerTool(
271 'describe_table',
272 {
273 title: 'Describe a table',
274 description:
275 'Return the columns of one table (name, type, nullability, default, ' +
276 'primary key, foreign-key references).',
277 inputSchema: { table: z.string().describe('Table name in your project') },
278 annotations: { readOnlyHint: true },
279 },
280 async ({ table }) => {
281 await auditCall('describe_table', { table });
282 const columns = await getTableColumns(ctx.projectId, table);
283 return jsonResult({ table, columns });
284 },
285 );
286
287 server.registerTool(
288 'query',
289 {
290 title: 'Run a read-only query',
291 description:
292 'Run a single read-only SELECT (or WITH … SELECT) statement against your ' +
293 `project database. Writes are rejected. At most ${QUERY_ROW_CAP} rows are returned.`,
294 inputSchema: { sql: z.string().describe('A single read-only SELECT/WITH statement') },
295 annotations: { readOnlyHint: true },
296 },
297 async ({ sql }) => {
298 const safeSql = assertReadOnlyQuery(sql);
299 await auditCall('query', { length: safeSql.length });
300 const rows = (await runInProjectDatabase(ctx.projectId, async (tx) =>
301 tx.unsafe(safeSql),
302 )) as Array<Record<string, unknown>>;
303 const truncated = rows.length > QUERY_ROW_CAP;
304 return jsonResult({
305 rowCount: Math.min(rows.length, QUERY_ROW_CAP),
306 truncated,
307 rows: truncated ? rows.slice(0, QUERY_ROW_CAP) : rows,
308 });
309 },
310 );
311
312 /* ── storage tools — per-project object storage, bound to this key ──── */
313
314 server.registerTool(
315 'storage_list_files',
316 {
317 title: 'List storage files',
318 description:
319 'List the files in your project storage (id, name, content type, size, ' +
320 'timestamps). Deleted files are excluded.',
321 annotations: { readOnlyHint: true },
322 },
323 async () => {
324 await auditCall('storage_list_files', {});
325 const files = await listFiles(ctx.projectId);
326 return jsonResult({ files });
327 },
328 );
329
330 server.registerTool(
331 'storage_usage',
332 {
333 title: 'Storage usage',
334 description:
335 'Report your project storage usage: bytes used, file count, tier, and the ' +
336 'tier byte cap.',
337 annotations: { readOnlyHint: true },
338 },
339 async () => {
340 await auditCall('storage_usage', {});
341 const files = await listFiles(ctx.projectId);
342 const usedBytes = files.reduce((sum, f) => sum + Number(f.sizeBytes), 0);
343 const fileCount = files.length;
344 const tier = (await getProjectTier(ctx.projectId)) ?? 'free';
345 const capBytes = TIERS[tier].storageBytes;
346 return jsonResult({ usedBytes, capBytes, fileCount, tier });
347 },
348 );
349
350 server.registerTool(
351 'storage_upload_url',
352 {
353 title: 'Get an upload URL',
354 description:
355 'Reserve a file and get a presigned upload URL. You then PUT the raw bytes ' +
356 'directly to `uploadUrl`, sending every header in `requiredHeaders` — the ' +
357 'bytes never pass through this tool. The URL expires after `expiresInSec`.',
358 inputSchema: {
359 name: z.string().describe('File name (no forward slash)'),
360 contentType: z.string().describe("MIME type, e.g. 'image/png'"),
361 sizeBytes: z.number().describe('Exact byte size of the file you will upload'),
362 },
363 annotations: { readOnlyHint: false },
364 },
365 async ({ name, contentType, sizeBytes }) => {
366 await auditCall('storage_upload_url', { name });
367 const result = await presignUpload({
368 projectId: ctx.projectId,
369 name,
370 contentType,
371 sizeBytes,
372 uploadedBy: null,
373 });
374 return jsonResult({
375 fileId: result.file.id,
376 uploadUrl: result.uploadUrl,
377 requiredHeaders: result.requiredHeaders,
378 expiresInSec: result.expiresInSec,
379 });
380 },
381 );
382
383 server.registerTool(
384 'storage_download_url',
385 {
386 title: 'Get a download URL',
387 description:
388 'Get a presigned download URL for one of your project files. Fetch the bytes ' +
389 'directly from `downloadUrl`; it expires after `expiresInSec`.',
390 inputSchema: { fileId: z.string().describe('File id in your project') },
391 annotations: { readOnlyHint: true },
392 },
393 async ({ fileId }) => {
394 await auditCall('storage_download_url', { fileId });
395 const result = await presignDownload(fileId, ctx.projectId);
396 return jsonResult({
397 downloadUrl: result.downloadUrl,
398 expiresInSec: result.expiresInSec,
399 });
400 },
401 );
402
403 server.registerTool(
404 'storage_delete_file',
405 {
406 title: 'Delete a storage file',
407 description:
408 'Soft-delete one file from your project storage. It appears under ' +
409 'recently deleted and can be restored within your plan recovery window. ' +
410 'Returns the deleted file id.',
411 inputSchema: { fileId: z.string().describe('File id in your project') },
412 annotations: { readOnlyHint: false },
413 },
414 async ({ fileId }) => {
415 await auditCall('storage_delete_file', { fileId });
416 await deleteFile(fileId, ctx.projectId);
417 return jsonResult({ deleted: true, fileId });
418 },
419 );
420
421 server.registerTool(
422 'storage_list_deleted',
423 {
424 title: 'List recently deleted files',
425 description:
426 'List soft-deleted files still inside your plan recovery window ' +
427 '(newest first). Use storage_restore_file to undo a delete.',
428 annotations: { readOnlyHint: true },
429 },
430 async () => {
431 await auditCall('storage_list_deleted', {});
432 const files = await listDeletedFiles(ctx.projectId);
433 return jsonResult({
434 files: files.map((f) => ({
435 id: f.id,
436 name: f.name,
437 contentType: f.contentType,
438 sizeBytes: f.sizeBytes,
439 deletedAt: f.deletedAt,
440 })),
441 });
442 },
443 );
444
445 server.registerTool(
446 'storage_restore_file',
447 {
448 title: 'Restore a deleted file',
449 description:
450 'Undo a soft-delete: bring a file back from recently deleted within the ' +
451 'recovery window. Returns the restored file id and status.',
452 inputSchema: { fileId: z.string().describe('Deleted file id in your project') },
453 annotations: { readOnlyHint: false },
454 },
455 async ({ fileId }) => {
456 await auditCall('storage_restore_file', { fileId });
457 const result = await restoreFile(fileId, ctx.projectId);
458 return jsonResult({
459 restored: true,
460 fileId: result.file.id,
461 name: result.file.name,
462 status: result.status,
463 });
464 },
465 );
466
467 server.registerTool(
468 'storage_make_public',
469 {
470 title: 'Make a file public or private',
471 description:
472 'Flip a file between public and private serving. When public, it is served ' +
473 'at the returned `url`; when private, `url` is null.',
474 inputSchema: {
475 fileId: z.string().describe('File id in your project'),
476 public: z.boolean().describe('true = publicly served, false = private'),
477 },
478 annotations: { readOnlyHint: false },
479 },
480 async ({ fileId, public: publicFlag }) => {
481 await auditCall('storage_make_public', { fileId, public: publicFlag });
482 await setFilePublic(fileId, ctx.projectId, publicFlag);
483 return jsonResult({
484 fileId,
485 public: publicFlag,
486 url: publicFlag
487 ? `https://media.briven.tech/media/${ctx.projectId}/${fileId}`
488 : null,
489 });
490 },
491 );
492
493 server.registerTool(
494 'storage_mint_key',
495 {
496 title: 'Mint an S3 storage key',
497 description:
498 'Mint a bucket-scoped S3 access key for your project storage, usable in any ' +
499 'S3 tool. The secret is shown only once — save it now.',
500 inputSchema: { name: z.string().describe('A label for this key') },
501 annotations: { readOnlyHint: false },
502 },
503 async ({ name }) => {
504 await auditCall('storage_mint_key', { name });
505 const result = await createStorageKey({
506 projectId: ctx.projectId,
507 name,
508 createdBy: null,
509 publicEndpoint: env.BRIVEN_MINIO_PUBLIC_ENDPOINT ?? '',
510 });
511 return jsonResult({
512 accessKey: result.accessKey,
513 secretKey: result.secretKey,
514 endpoint: result.endpoint,
515 bucket: result.bucket,
516 note: 'the secret is shown only once',
517 });
518 },
519 );
520
521 /* ── storage: list the project's minted S3 keys (read) ─────────────── */
522
523 server.registerTool(
524 'storage_list_keys',
525 {
526 title: 'List storage keys',
527 description:
528 'List the bucket-scoped S3 access keys minted for your project (id, name, ' +
529 'access key id, bucket, enabled, timestamps). Secrets are never shown.',
530 annotations: { readOnlyHint: true },
531 },
532 async () => {
533 await auditCall('storage_list_keys', {});
534 const keys = await listStorageKeys(ctx.projectId);
535 return jsonResult({ keys });
536 },
537 );
538
539 /* ── storage: transform URL for a PUBLIC image (read, stateless) ────── */
540
541 server.registerTool(
542 'storage_transform_url',
543 {
544 title: 'Get an image transform URL',
545 description:
546 'Build a signed on-the-fly image-resize URL for a PUBLIC file in your ' +
547 'project. Returns a `url`; requires image transforms to be enabled on the api.',
548 inputSchema: {
549 fileId: z.string().describe('File id in your project (must be public to serve)'),
550 width: z.number().int().optional().describe('Target width in px (optional)'),
551 height: z.number().int().optional().describe('Target height in px (optional)'),
552 resize: z
553 .enum(['fit', 'fill', 'auto'])
554 .optional()
555 .describe("Resize mode (default 'fit')"),
556 },
557 annotations: { readOnlyHint: true },
558 },
559 async ({ fileId, width, height, resize }) => {
560 await auditCall('storage_transform_url', { fileId });
561 if (!isImageTransformConfigured()) {
562 throw new Error('image transforms are not enabled on this api');
563 }
564 const url = signedTransformUrl(ctx.projectId, fileId, { width, height, resize });
565 return jsonResult({ url });
566 },
567 );
568
569 /* ── cross-project sharing (M5): list grants the caller has CREATED ─── */
570
571 server.registerTool(
572 'storage_list_grants',
573 {
574 title: 'List storage grants',
575 description:
576 'List the cross-project sharing grants YOUR project has created (as the ' +
577 'granter): grantee project, resource, prefix flag, created/revoked times.',
578 annotations: { readOnlyHint: true },
579 },
580 async () => {
581 await auditCall('storage_list_grants', {});
582 const grants = await listGrants(ctx.projectId);
583 return jsonResult({ grants });
584 },
585 );
586
587 /* ── public share-links (M5): list the links YOUR project has minted ──
588 * A read — available to every key scope. Returns each link's id, file, url,
589 * expiry, and revoked state so the owner can manage them. */
590
591 server.registerTool(
592 'storage_list_links',
593 {
594 title: 'List public share-links',
595 description:
596 'List the tokenized public share-links YOUR project has minted: id, file ' +
597 'id, url, expiry, created/revoked times. Anyone with an active link URL can ' +
598 'download that one file until it expires or you revoke it.',
599 annotations: { readOnlyHint: true },
600 },
601 async () => {
602 await auditCall('storage_list_links', {});
603 const links = await listShareLinks(ctx.projectId);
604 return jsonResult({ links });
605 },
606 );
607
608 /* ── cross-project READ (M5): mint a download URL for a SHARED file ───
609 * The ONLY sanctioned cross-project read. Gated by isGranted(caller, granter,
610 * fileId) — strict-deny returns a clear `forbidden` error otherwise. This is a
611 * read, so it is available to every key scope. */
612
613 server.registerTool(
614 'storage_shared_download_url',
615 {
616 title: 'Download a file shared with you',
617 description:
618 'Get a presigned download URL for a file that ANOTHER project has granted ' +
619 'to you. Provide the granter project id + the file id. If no active grant ' +
620 'covers that file, returns a `forbidden` error — you cannot read anything ' +
621 'that was not explicitly shared.',
622 inputSchema: {
623 granterProjectId: z.string().describe('The project that owns + shared the file'),
624 fileId: z.string().describe('The shared file id (owned by the granter project)'),
625 },
626 annotations: { readOnlyHint: true },
627 },
628 async ({ granterProjectId, fileId }) => {
629 const allowed = await isGranted(ctx.projectId, granterProjectId, fileId);
630 await auditCall('storage.grant.access', {
631 granteeProjectId: ctx.projectId,
632 granterProjectId,
633 resource: fileId,
634 allowed,
635 });
636 if (!allowed) {
637 throw new Error('forbidden: no active grant covers that file for your project');
638 }
639 // Resolve the URL against the GRANTER's project (the owner of the bytes).
640 const result = await presignDownload(fileId, granterProjectId);
641 return jsonResult({
642 downloadUrl: result.downloadUrl,
643 expiresInSec: result.expiresInSec,
644 });
645 },
646 );
647
648 /* ── auth bridge — read + guidance tools (every scope) ──────────────── */
649
650 registerAuthBridgeTools(server, ctx, auditCall, jsonResult, {
651 allowWrites: ctx.scope === 'read-write' || ctx.scope === 'admin',
652 });
653
654 /* ── briven_ask — the general reception desk (every scope) ──────────── */
655
656 registerBrivenAskTool(server, ctx, auditCall, jsonResult);
657
658 /* ── database lifecycle — reads for every scope ──────────────────────── */
659
660 registerDbLifecycleReadTools(server, ctx, auditCall, jsonResult);
661
662 /* ── write tools — ONLY registered for read-write / admin keys ──────── */
663
664 if (ctx.scope === 'read-write' || ctx.scope === 'admin') {
665 /* ── storage: issue an S3 key (spec-named alias of storage_mint_key) ─ */
666 server.registerTool(
667 'storage_issue_key',
668 {
669 title: 'Issue an S3 storage key',
670 description:
671 'Issue a bucket-scoped S3 access key for your project storage, usable in ' +
672 'any S3 tool. The secret is shown only once — save it now. (Write scope.)',
673 inputSchema: { name: z.string().describe('A label for this key') },
674 annotations: { readOnlyHint: false },
675 },
676 async ({ name }) => {
677 await auditCall('storage_issue_key', { name });
678 const result = await createStorageKey({
679 projectId: ctx.projectId,
680 name,
681 createdBy: null,
682 publicEndpoint: env.BRIVEN_MINIO_PUBLIC_ENDPOINT ?? '',
683 });
684 return jsonResult({
685 accessKey: result.accessKey,
686 secretKey: result.secretKey,
687 endpoint: result.endpoint,
688 bucket: result.bucket,
689 note: 'the secret is shown only once',
690 });
691 },
692 );
693
694 /* ── storage: revoke one of the project's S3 keys ─────────────────── */
695 server.registerTool(
696 'storage_revoke_key',
697 {
698 title: 'Revoke a storage key',
699 description:
700 'Revoke (disable + remove) one of your project S3 keys by its key id. ' +
701 '(Write scope.)',
702 inputSchema: { keyId: z.string().describe('The storage key id to revoke') },
703 annotations: { readOnlyHint: false },
704 },
705 async ({ keyId }) => {
706 await auditCall('storage_revoke_key', { keyId });
707 await revokeStorageKey(ctx.projectId, keyId);
708 return jsonResult({ revoked: true, keyId });
709 },
710 );
711
712 /* ── cross-project sharing (M5): create a grant (caller = GRANTER) ── */
713 server.registerTool(
714 'storage_grant',
715 {
716 title: 'Grant a file / prefix to another project',
717 description:
718 'Share one of YOUR files (or a whole path prefix) with another project. ' +
719 'That project can then download exactly the granted resource — nothing ' +
720 'else. `resource` is a file id (isPrefix=false) or a path prefix ' +
721 '(isPrefix=true). (Write scope.)',
722 inputSchema: {
723 granteeProjectId: z.string().describe('The project you are sharing with'),
724 resource: z.string().describe('A file id, or a path prefix when isPrefix is true'),
725 isPrefix: z
726 .boolean()
727 .optional()
728 .describe('true = resource is a path prefix; false (default) = exact file id'),
729 },
730 annotations: { readOnlyHint: false },
731 },
732 async ({ granteeProjectId, resource, isPrefix }) => {
733 const grant = await createGrant({
734 granterProjectId: ctx.projectId,
735 granteeProjectId,
736 resource,
737 isPrefix: isPrefix ?? false,
738 createdBy: ctx.keyId,
739 });
740 await auditCall('storage.grant.create', {
741 granterProjectId: ctx.projectId,
742 granteeProjectId,
743 resource,
744 isPrefix: isPrefix ?? false,
745 grantId: grant.id,
746 });
747 return jsonResult({ grant });
748 },
749 );
750
751 /* ── cross-project sharing (M5): revoke a grant (only the GRANTER) ── */
752 server.registerTool(
753 'storage_revoke_grant',
754 {
755 title: 'Revoke a storage grant',
756 description:
757 'Revoke a cross-project sharing grant YOUR project created, by its grant ' +
758 'id. Only the granter can revoke. (Write scope.)',
759 inputSchema: { grantId: z.string().describe('The grant id to revoke') },
760 annotations: { readOnlyHint: false },
761 },
762 async ({ grantId }) => {
763 const grant = await revokeGrant(ctx.projectId, grantId);
764 await auditCall('storage.grant.revoke', {
765 granterProjectId: ctx.projectId,
766 granteeProjectId: grant.granteeProjectId,
767 resource: grant.resource,
768 grantId: grant.id,
769 });
770 return jsonResult({ grant });
771 },
772 );
773
774 /* ── public share-links (M5): mint a tokenized public link ──────────
775 * Exposes ONE of YOUR files to anyone with the link URL, for a limited time
776 * (clamped: min 60s, default 24h, max 30 days). The link works even if the
777 * file isn't marked public. Audit records the file id + expiry — NEVER the
778 * token. (Write scope.) */
779 server.registerTool(
780 'storage_create_link',
781 {
782 title: 'Create a public share-link',
783 description:
784 'Mint a tokenized public download link for one of YOUR files. Anyone with ' +
785 'the returned `url` can download that one file — no login — until it ' +
786 'expires or you revoke it. `expiresInSeconds` is clamped to [60s, 30 ' +
787 'days] and defaults to 24h. Works even if the file is not marked public. ' +
788 '(Write scope.)',
789 inputSchema: {
790 fileId: z.string().describe('The id of your file to share'),
791 expiresInSeconds: z
792 .number()
793 .int()
794 .optional()
795 .describe('Link lifetime in seconds (clamped 60..2592000; default 86400)'),
796 },
797 annotations: { readOnlyHint: false },
798 },
799 async ({ fileId, expiresInSeconds }) => {
800 const link = await createShareLink({
801 projectId: ctx.projectId,
802 fileId,
803 expiresInSeconds: expiresInSeconds ?? null,
804 createdBy: ctx.keyId,
805 });
806 // Audit the create — file id + expiry only. NEVER log the token.
807 await auditCall('storage.link.create', {
808 fileId,
809 linkId: link.id,
810 expiresAt: link.expiresAt,
811 });
812 return jsonResult({
813 id: link.id,
814 url: link.url,
815 token: link.token,
816 expiresAt: link.expiresAt,
817 });
818 },
819 );
820
821 /* ── public share-links (M5): revoke a link (only the owner) ─────── */
822 server.registerTool(
823 'storage_revoke_link',
824 {
825 title: 'Revoke a public share-link',
826 description:
827 'Revoke a public share-link YOUR project minted, by its link id. The link ' +
828 'stops working immediately. Only the owner can revoke. (Write scope.)',
829 inputSchema: { linkId: z.string().describe('The share-link id to revoke') },
830 annotations: { readOnlyHint: false },
831 },
832 async ({ linkId }) => {
833 const link = await revokeShareLink(ctx.projectId, linkId);
834 // Audit the revoke — file id + expiry only. NEVER log the token.
835 await auditCall('storage.link.revoke', {
836 fileId: link.fileId,
837 linkId: link.id,
838 expiresAt: link.expiresAt,
839 });
840 return jsonResult({ id: link.id, revoked: true });
841 },
842 );
843
844
845 server.registerTool(
846 'create_table',
847 {
848 title: 'Create a table',
849 description:
850 'Create a new table in your project. Exactly one column must be marked ' +
851 'primaryKey. Types: text, integer, bigint, boolean, timestamptz, jsonb, ' +
852 'uuid, numeric. Reuses the same validated path as the Studio "+ new table" button.',
853 inputSchema: {
854 table: z.string().describe('New table name (snake_case)'),
855 columns: z
856 .array(
857 z.object({
858 name: z.string().describe('Column name'),
859 type: z.enum(STUDIO_COLUMN_TYPES).describe('Column type'),
860 primaryKey: z.boolean().optional().describe('Exactly one column must be true'),
861 notNull: z.boolean().optional(),
862 defaultExpr: z
863 .string()
864 .nullable()
865 .optional()
866 .describe("SQL default, e.g. 'now()' or 'gen_random_uuid()'"),
867 references: z
868 .object({
869 table: z.string(),
870 column: z.string(),
871 onDelete: z
872 .enum(['cascade', 'restrict', 'setNull', 'noAction'])
873 .optional(),
874 })
875 .nullable()
876 .optional()
877 .describe('Optional foreign-key target (same project)'),
878 }),
879 )
880 .min(1)
881 .describe('Column definitions'),
882 },
883 annotations: { readOnlyHint: false },
884 },
885 async ({ table, columns }) => {
886 await auditCall('create_table', { table });
887 const result = await createTable({ projectId: ctx.projectId, tableName: table, columns });
888 return jsonResult(result);
889 },
890 );
891
892 server.registerTool(
893 'insert',
894 {
895 title: 'Insert a row',
896 description: 'Insert one row into a table. Returns the stored row (defaults filled in).',
897 inputSchema: {
898 table: z.string().describe('Target table in your project'),
899 values: z.record(z.string(), z.unknown()).describe('Column → value map'),
900 },
901 annotations: { readOnlyHint: false },
902 },
903 async ({ table, values }) => {
904 await auditCall('insert', { table });
905 const result = await insertRow({ projectId: ctx.projectId, tableName: table, values });
906 return jsonResult(result);
907 },
908 );
909
910 server.registerTool(
911 'update',
912 {
913 title: 'Update rows',
914 description:
915 'Update rows that match every column in `match`, setting the columns in `set`. ' +
916 'Both maps are required (an unbounded update is refused). Returns affected row count.',
917 inputSchema: {
918 table: z.string().describe('Target table in your project'),
919 match: z.record(z.string(), z.unknown()).describe('Column → value AND-matched WHERE'),
920 set: z.record(z.string(), z.unknown()).describe('Column → new value'),
921 },
922 annotations: { readOnlyHint: false },
923 },
924 async ({ table, match, set }) => {
925 await auditCall('update', { table });
926 const affected = await runUpdate(ctx.projectId, table, match, set);
927 return jsonResult({ affected });
928 },
929 );
930
931 server.registerTool(
932 'delete',
933 {
934 title: 'Delete rows',
935 description:
936 'Delete rows that match every column in `match`. `match` is required (an ' +
937 'unbounded delete is refused). Returns affected row count.',
938 inputSchema: {
939 table: z.string().describe('Target table in your project'),
940 match: z.record(z.string(), z.unknown()).describe('Column → value AND-matched WHERE'),
941 },
942 annotations: { readOnlyHint: false },
943 },
944 async ({ table, match }) => {
945 await auditCall('delete', { table });
946 const affected = await runDelete(ctx.projectId, table, match);
947 return jsonResult({ affected });
948 },
949 );
950
951 /* ── database lifecycle — restart + recover (write scope) ──────────── */
952
953 registerDbLifecycleWriteTools(server, ctx, auditCall, jsonResult);
954 }
955
956 /* ── admin-only — the destructive lifecycle tail ─────────────────────── */
957
958 if (ctx.scope === 'admin') {
959 registerDbLifecycleAdminTools(server, ctx, auditCall, jsonResult);
960 }
961
962 return server;
963}
964
965/**
966 * The tool names a key of each scope is allowed to see. Exported for tests.
967 *
968 * READ_TOOLS are registered for EVERY scope (read, read-write, admin);
969 * WRITE_TOOLS are registered ONLY for read-write / admin. Keep this list in
970 * lock-step with the `server.registerTool(...)` calls above — the mcp-server
971 * test asserts `tools/list` equals exactly these per scope.
972 */
973export const READ_TOOLS = [
974 // data-plane reads
975 'list_tables',
976 'describe_table',
977 'query',
978 // storage reads (available to every scope)
979 'storage_list_files',
980 'storage_usage',
981 'storage_upload_url',
982 'storage_download_url',
983 'storage_delete_file',
984 'storage_list_deleted',
985 'storage_restore_file',
986 'storage_make_public',
987 'storage_mint_key',
988 'storage_list_keys',
989 'storage_transform_url',
990 // cross-project sharing reads (M5)
991 'storage_list_grants',
992 'storage_shared_download_url',
993 // public share-link reads (M5)
994 'storage_list_links',
995 // auth bridge — read + guidance (mcp-auth-bridge.ts)
996 ...AUTH_BRIDGE_TOOLS,
997 // general reception desk (mcp-briven-ask.ts)
998 ...BRIVEN_ASK_TOOLS,
999 // database lifecycle reads (mcp-db-lifecycle.ts)
1000 ...DB_LIFECYCLE_READ_TOOLS,
1001] as const;
1002export const WRITE_TOOLS = [
1003 // data-plane writes
1004 'create_table',
1005 'insert',
1006 'update',
1007 'delete',
1008 // storage writes (M5 — read-write / admin only)
1009 'storage_issue_key',
1010 'storage_revoke_key',
1011 'storage_grant',
1012 'storage_revoke_grant',
1013 // public share-link writes (M5 — read-write / admin only)
1014 'storage_create_link',
1015 'storage_revoke_link',
1016 // auth bridge writes (mcp-auth-bridge.ts) — passwordless + mint pk_briven_auth_
1017 ...AUTH_BRIDGE_WRITE_TOOLS,
1018 // database lifecycle writes (mcp-db-lifecycle.ts)
1019 ...DB_LIFECYCLE_WRITE_TOOLS,
1020] as const;
1021/** Registered ONLY for admin-scope keys — the destructive lifecycle tail. */
1022export const ADMIN_TOOLS = [...DB_LIFECYCLE_ADMIN_TOOLS] as const;