@motherbase/cli 0.2.4 → 0.2.6

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -18
  3. package/bin/motherbase.js +10 -10
  4. package/package.json +35 -26
  5. package/src/api.js +233 -229
  6. package/src/commands/_util.js +106 -50
  7. package/src/commands/backups.js +16 -0
  8. package/src/commands/branches.js +113 -0
  9. package/src/commands/completion.js +90 -0
  10. package/src/commands/config.js +47 -47
  11. package/src/commands/cron.js +65 -65
  12. package/src/commands/db-catalog.js +326 -0
  13. package/src/commands/db.js +103 -92
  14. package/src/commands/domains.js +124 -0
  15. package/src/commands/functions.js +63 -33
  16. package/src/commands/gen.js +63 -17
  17. package/src/commands/import.js +55 -35
  18. package/src/commands/index.js +85 -64
  19. package/src/commands/inspect.js +178 -41
  20. package/src/commands/link.js +35 -35
  21. package/src/commands/local.js +23 -2
  22. package/src/commands/login.js +39 -39
  23. package/src/commands/logout.js +16 -16
  24. package/src/commands/mcp.js +27 -9
  25. package/src/commands/migrate.js +207 -0
  26. package/src/commands/migration.js +108 -33
  27. package/src/commands/network.js +83 -0
  28. package/src/commands/orgs.js +40 -0
  29. package/src/commands/postgres-config.js +113 -0
  30. package/src/commands/projects.js +130 -125
  31. package/src/commands/queues.js +133 -0
  32. package/src/commands/realtime.js +35 -35
  33. package/src/commands/rls.js +176 -176
  34. package/src/commands/secrets.js +66 -45
  35. package/src/commands/signing.js +130 -0
  36. package/src/commands/snippets.js +44 -0
  37. package/src/commands/status.js +42 -42
  38. package/src/commands/storage.js +261 -113
  39. package/src/commands/test.js +9 -0
  40. package/src/commands/unlink.js +15 -15
  41. package/src/commands/users.js +142 -113
  42. package/src/commands/vault.js +82 -0
  43. package/src/commands/webhooks.js +62 -62
  44. package/src/commands/whoami.js +20 -20
  45. package/src/commands/wrappers.js +150 -0
  46. package/src/config.js +98 -98
  47. package/src/context.js +106 -102
  48. package/src/docs.js +68 -0
  49. package/src/edge-templates.js +194 -0
  50. package/src/format.js +92 -0
  51. package/src/inspect/csv.js +22 -0
  52. package/src/inspect/queries.js +562 -0
  53. package/src/main.js +224 -160
  54. package/src/mcp/platform-tools.js +233 -0
  55. package/src/mcp/protocol.js +4 -1
  56. package/src/mcp/storage-tools.js +93 -0
  57. package/src/mcp/tools.js +318 -6
  58. package/src/migration/commands.js +375 -0
  59. package/src/migration/files.js +245 -0
  60. package/src/prompt.js +91 -91
  61. package/src/storage/remote.js +62 -0
  62. package/src/ui.js +111 -111
  63. package/src/commands/migrations.js +0 -85
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ // Storage configuration over MCP, the counterpart of Supabase's
4
+ // get_storage_config / update_storage_config. Motherbase keeps that
5
+ // configuration in three settings groups plus the S3 endpoint, so these two
6
+ // tools fold them back into the single view an agent expects.
7
+
8
+ const api = require('../api');
9
+
10
+ /** Setting group -> the tool argument that edits each of its fields. */
11
+ const WRITABLE = {
12
+ 'storage.limits': { maxUploadBytes: 'maxUploadBytes', allowedMimeTypes: 'allowedMimeTypes' },
13
+ 'storage.imageTransform': { enabled: 'imageTransformEnabled', maxSourceBytes: 'imageTransformMaxSourceBytes' },
14
+ 'storage.policies': { enabled: 'policiesEnabled' },
15
+ };
16
+
17
+ /** Groups the arguments touch, each with the field values to change. */
18
+ function changesByGroup(args) {
19
+ const changes = {};
20
+ for (const [group, fields] of Object.entries(WRITABLE)) {
21
+ for (const [field, argName] of Object.entries(fields)) {
22
+ if (args[argName] === undefined) continue;
23
+ const value = Array.isArray(args[argName]) ? args[argName].join(', ') : args[argName];
24
+ (changes[group] ||= {})[field] = value;
25
+ }
26
+ }
27
+ return changes;
28
+ }
29
+
30
+ const valuesOf = group => (group && group.values) || {};
31
+
32
+ function readStorageTools(ctx) {
33
+ return [
34
+ {
35
+ name: 'get_storage_config',
36
+ description:
37
+ 'Storage configuration: the global upload size limit and allowed MIME types, image transformations, whether PostgreSQL policies authorize storage access, and the S3 protocol endpoint. Per-bucket limits are on list_storage_buckets.',
38
+ inputSchema: { type: 'object', properties: {} },
39
+ run: async () => {
40
+ const [limits, imageTransform, policies, s3] = await Promise.all([
41
+ api.adminRequest(ctx, 'GET', '/settings/storage.limits'),
42
+ api.adminRequest(ctx, 'GET', '/settings/storage.imageTransform'),
43
+ api.adminRequest(ctx, 'GET', '/settings/storage.policies'),
44
+ api.adminRequest(ctx, 'GET', '/storage/s3'),
45
+ ]);
46
+ return {
47
+ limits: valuesOf(limits),
48
+ imageTransform: valuesOf(imageTransform),
49
+ policies: valuesOf(policies),
50
+ s3,
51
+ };
52
+ },
53
+ },
54
+ ];
55
+ }
56
+
57
+ function writeStorageTools(ctx) {
58
+ return [
59
+ {
60
+ name: 'update_storage_config',
61
+ description:
62
+ 'Change the global storage configuration. Only the fields you pass change. Per-bucket size limits and MIME types are edited on the bucket itself.',
63
+ inputSchema: {
64
+ type: 'object',
65
+ properties: {
66
+ maxUploadBytes: { type: 'integer', minimum: 0, description: 'Largest upload in bytes. 0 uses the server default.' },
67
+ allowedMimeTypes: {
68
+ type: 'array',
69
+ items: { type: 'string' },
70
+ description: 'Allowed MIME types, wildcards like image/* supported. Empty allows everything.',
71
+ },
72
+ imageTransformEnabled: { type: 'boolean', description: 'Serve resized images from /storage/v1/render/image.' },
73
+ imageTransformMaxSourceBytes: { type: 'integer', minimum: 1, description: 'Largest image, in bytes, that will be transformed.' },
74
+ policiesEnabled: { type: 'boolean', description: 'Authorize storage access with RLS policies on storage.files.' },
75
+ },
76
+ },
77
+ run: async args => {
78
+ const changes = changesByGroup(args);
79
+ if (!Object.keys(changes).length) throw new Error('Pass at least one field to change.');
80
+ // The settings API replaces a group wholesale, so an untouched field
81
+ // would fall back to its default unless it is sent back as it is.
82
+ const saved = {};
83
+ for (const [group, fields] of Object.entries(changes)) {
84
+ const current = valuesOf(await api.adminRequest(ctx, 'GET', `/settings/${group}`));
85
+ saved[group] = await api.adminRequest(ctx, 'PUT', `/settings/${group}`, { body: { ...current, ...fields } });
86
+ }
87
+ return saved;
88
+ },
89
+ },
90
+ ];
91
+ }
92
+
93
+ module.exports = { readStorageTools, writeStorageTools, changesByGroup };
package/src/mcp/tools.js CHANGED
@@ -2,8 +2,9 @@
2
2
 
3
3
  // The tools an agent gets over MCP.
4
4
  //
5
- // Every one is a call to the project admin API through `api.adminRequest`, the
6
- // same path every CLI command takes. That is the whole design: authentication,
5
+ // Every one is a call through the CLI's own HTTP layer: `api.adminRequest` for
6
+ // the project, `api.consoleRequest` for the account tools in platform-tools.js,
7
+ // the same paths every CLI command takes. That is the whole design: authentication,
7
8
  // console-proxy vs direct mode, and error mapping are solved once in the CLI and
8
9
  // reused here, so an MCP session cannot reach anything a `motherbase` command
9
10
  // could not, and cannot drift from it either.
@@ -13,6 +14,9 @@
13
14
  // this is built on already does.
14
15
 
15
16
  const api = require('../api');
17
+ const platform = require('./platform-tools');
18
+ const storage = require('./storage-tools');
19
+ const { consoleProjectPath } = require('../commands/_util');
16
20
 
17
21
  const str = description => ({ type: 'string', description });
18
22
 
@@ -78,17 +82,22 @@ function readTools(ctx) {
78
82
  },
79
83
  {
80
84
  name: 'get_logs',
81
- description: 'Recent API and database log entries.',
85
+ description: 'Recent log entries of one service, or of all of them.',
82
86
  inputSchema: {
83
87
  type: 'object',
84
88
  properties: {
89
+ service: {
90
+ type: 'string',
91
+ enum: ['api', 'postgres', 'auth', 'storage', 'realtime', 'edge-function'],
92
+ description: 'Only this service. api is every HTTP request. Omit for all services.',
93
+ },
85
94
  limit: { type: 'number', description: 'How many entries. Defaults to 100.' },
86
95
  level: str("Filter by level: 'info', 'warn' or 'error'."),
87
96
  },
88
97
  },
89
98
  run: args =>
90
99
  api.adminRequest(ctx, 'GET', '/logs', {
91
- query: { limit: args.limit || 100, level: args.level },
100
+ query: { limit: args.limit || 100, level: args.level, service: args.service },
92
101
  }),
93
102
  },
94
103
  {
@@ -111,9 +120,148 @@ function readTools(ctx) {
111
120
  // must not be able to write, whatever the model believed it was doing.
112
121
  run: args => api.adminRequest(ctx, 'POST', '/sql/readonly', { body: { sql: args.sql } }),
113
122
  },
123
+
124
+ // ── db-core: Database section (indexes, enums, roles, publications, config) ──
125
+ {
126
+ name: 'list_indexes',
127
+ description: 'Every index in a schema, with its table, access method, key columns, uniqueness, size and definition.',
128
+ inputSchema: { type: 'object', properties: { schema: str('Postgres schema. Defaults to public.') } },
129
+ run: args => api.adminRequest(ctx, 'GET', '/schema/indexes', { query: { schema: args.schema } }),
130
+ },
131
+ {
132
+ name: 'list_enum_types',
133
+ description: 'Enumerated types in a schema, with their values in order and the columns that use each one.',
134
+ inputSchema: { type: 'object', properties: { schema: str('Postgres schema. Defaults to public.') } },
135
+ run: args => api.adminRequest(ctx, 'GET', '/schema/enums', { query: { schema: args.schema } }),
136
+ },
137
+ {
138
+ name: 'list_roles',
139
+ description:
140
+ 'Postgres roles with their attributes (login, superuser, bypassrls, connection limit), live connections and memberships. Reserved platform roles carry the reason they cannot be changed.',
141
+ inputSchema: { type: 'object', properties: {} },
142
+ run: () => api.adminRequest(ctx, 'GET', '/database/roles'),
143
+ },
144
+ {
145
+ name: 'list_column_privileges',
146
+ description: 'For one table, which columns each role may SELECT, INSERT, UPDATE or REFERENCE.',
147
+ inputSchema: {
148
+ type: 'object',
149
+ properties: { table: str('Table name.'), schema: str('Postgres schema. Defaults to public.') },
150
+ required: ['table'],
151
+ },
152
+ run: args => api.adminRequest(ctx, 'GET', '/database/column-privileges', { query: { table: args.table, schema: args.schema } }),
153
+ },
154
+ {
155
+ name: 'list_publications',
156
+ description:
157
+ 'Logical-replication publications, their tables and published actions, and wal_level. Motherbase Realtime does not use publications.',
158
+ inputSchema: { type: 'object', properties: {} },
159
+ run: () => api.adminRequest(ctx, 'GET', '/database/publications'),
160
+ },
161
+ {
162
+ name: 'get_postgres_config',
163
+ description:
164
+ 'Managed Postgres settings (statement_timeout, work_mem, max_connections, ...) with current values, allowed scopes and every database, role or system override.',
165
+ inputSchema: { type: 'object', properties: {} },
166
+ run: () => api.adminRequest(ctx, 'GET', '/database/config'),
167
+ },
168
+ // ── end db-core ──
169
+
170
+ // ── cli-mcp lot: Supabase MCP project tools (platform-tools.js) ──────────
171
+ ...platform.projectReadTools(ctx),
172
+ // ── Lot platform: preview branches (Console routes, console mode only) ──
173
+ {
174
+ name: 'list_branches',
175
+ description:
176
+ 'The preview branches of the linked project, main first, with status (creating, seeding, active, failed) and whether each is running.',
177
+ inputSchema: { type: 'object', properties: {} },
178
+ run: () => api.consoleRequest(ctx, 'GET', consoleProjectPath(ctx, '/branches')),
179
+ },
180
+
181
+ // ── db-ext ──────────────────────────────────────────────────────────────
182
+ {
183
+ name: 'list_queues',
184
+ description: 'List the message queues (pgmq) with their depth, type and whether RLS is on. Empty when queues are not enabled.',
185
+ inputSchema: { type: 'object', properties: {} },
186
+ run: () => api.adminRequest(ctx, 'GET', '/queues'),
187
+ },
188
+ {
189
+ name: 'list_vault_secrets',
190
+ description: 'List the Vault secrets: id, name, description and dates. Never returns a secret value.',
191
+ inputSchema: { type: 'object', properties: {} },
192
+ run: () => api.adminRequest(ctx, 'GET', '/vault'),
193
+ },
194
+ {
195
+ name: 'list_wrappers',
196
+ description: 'List the foreign data wrappers, foreign servers (never their passwords) and foreign tables.',
197
+ inputSchema: { type: 'object', properties: {} },
198
+ run: () => api.adminRequest(ctx, 'GET', '/wrappers'),
199
+ },
200
+ {
201
+ name: 'advise_indexes',
202
+ description:
203
+ 'Suggest indexes for one SELECT/UPDATE/DELETE statement: the CREATE INDEX statements and the estimated cost drop. The statement is only planned over hypothetical indexes, never run.',
204
+ inputSchema: {
205
+ type: 'object',
206
+ properties: { sql: str('The statement to advise on.') },
207
+ required: ['sql'],
208
+ },
209
+ run: args => api.adminRequest(ctx, 'POST', '/performance/index-advisor', { body: { query: args.sql } }),
210
+ },
211
+ // ── /db-ext ─────────────────────────────────────────────────────────────
212
+
213
+ // ── auth ──
214
+ {
215
+ name: 'get_auth_config',
216
+ description:
217
+ 'Every auth.* settings group (sign-in methods, URL configuration, rate limits, attack protection, third-party auth, hooks...) with its current values. Secrets are masked.',
218
+ inputSchema: { type: 'object', properties: {} },
219
+ run: async () => {
220
+ const list = await api.adminRequest(ctx, 'GET', '/settings');
221
+ const keys = ((list && list.groups) || []).map(g => g.key).filter(key => key.startsWith('auth.'));
222
+ const groups = await Promise.all(keys.map(key => api.adminRequest(ctx, 'GET', `/settings/${key}`)));
223
+ return { groups };
224
+ },
225
+ },
226
+ {
227
+ name: 'get_auth_audit_logs',
228
+ description:
229
+ 'Auth audit trail (auth.audit_log_entries), newest first: sign-ins, sign-ups, token refreshes, recoveries, admin changes. Same data as the Studio Audit Logs page.',
230
+ inputSchema: {
231
+ type: 'object',
232
+ properties: {
233
+ action: str("Filter by action, e.g. 'login', 'user_signedup', 'user_deleted'."),
234
+ actor: str('Substring of the actor id or email.'),
235
+ logType: str("Filter by log type: 'account', 'team', 'token', 'user'..."),
236
+ from: str('ISO date; only entries at or after it.'),
237
+ to: str('ISO date; only entries at or before it.'),
238
+ limit: { type: 'number', description: 'How many entries. Defaults to 50.' },
239
+ },
240
+ },
241
+ run: args =>
242
+ api.adminRequest(ctx, 'GET', '/auth/audit-logs', {
243
+ query: {
244
+ action: args.action,
245
+ actor: args.actor,
246
+ log_type: args.logType,
247
+ from: args.from,
248
+ to: args.to,
249
+ per_page: args.limit || 50,
250
+ },
251
+ }),
252
+ },
253
+ // ── /auth ──
254
+
255
+ // ── storage ──────────────────────────────────────────────────────────────
256
+ ...storage.readStorageTools(ctx),
257
+ // ── /storage ─────────────────────────────────────────────────────────────
114
258
  ];
115
259
  }
116
260
 
261
+ /** `/branches/<name>/<action>` on the linked project. */
262
+ const branchAction = (ctx, action) => args =>
263
+ api.consoleRequest(ctx, 'POST', consoleProjectPath(ctx, `/branches/${encodeURIComponent(args.name)}/${action}`));
264
+
117
265
  /** Tools that change the database. Only exposed with `--write`. */
118
266
  function writeTools(ctx) {
119
267
  return [
@@ -151,6 +299,163 @@ function writeTools(ctx) {
151
299
  },
152
300
  run: args => api.adminRequest(ctx, 'POST', '/sql', { body: { sql: args.sql } }),
153
301
  },
302
+
303
+ // ── db-core: Postgres settings ──────────────────────────────────────────
304
+ // A dedicated tool rather than execute_sql_write: the server checks the
305
+ // name against its curated list and picks the narrowest scope Postgres allows.
306
+ {
307
+ name: 'update_postgres_config',
308
+ description:
309
+ 'Set or reset (value null) managed Postgres settings. Without scope, a session setting such as statement_timeout is pinned on the database; server-wide ones use ALTER SYSTEM and may need a restart, which the result reports.',
310
+ inputSchema: {
311
+ type: 'object',
312
+ properties: {
313
+ changes: {
314
+ type: 'array',
315
+ items: {
316
+ type: 'object',
317
+ properties: {
318
+ name: str('Setting name, e.g. statement_timeout.'),
319
+ value: { type: ['string', 'null'], description: "New value with its unit, e.g. '30s', or null to reset." },
320
+ scope: str("'database', 'role' or 'system'. Optional."),
321
+ role: str('Role name, for scope role.'),
322
+ },
323
+ required: ['name', 'value'],
324
+ },
325
+ },
326
+ },
327
+ required: ['changes'],
328
+ },
329
+ run: args => api.adminRequest(ctx, 'PATCH', '/database/config', { body: { changes: args.changes } }),
330
+ },
331
+ // ── end db-core ──
332
+
333
+ // ── cli-mcp lot: Supabase MCP project tools (platform-tools.js) ──────────
334
+ ...platform.projectWriteTools(ctx),
335
+ // ── Lot platform: preview branches (Console routes, console mode only) ──
336
+ {
337
+ name: 'create_branch',
338
+ description:
339
+ 'Create a preview branch: an isolated copy of the project with its own database, cut from its applied migrations (or its data with withData). Takes minutes; poll list_branches until it is active.',
340
+ inputSchema: {
341
+ type: 'object',
342
+ properties: {
343
+ name: str('Branch name, e.g. the git branch.'),
344
+ withData: { type: 'boolean', description: 'Copy the rows too, not only the schema. Defaults to false.' },
345
+ },
346
+ required: ['name'],
347
+ },
348
+ run: args =>
349
+ api.consoleRequest(ctx, 'POST', consoleProjectPath(ctx, '/branches'), {
350
+ body: { name: args.name, withData: Boolean(args.withData) },
351
+ }),
352
+ },
353
+ {
354
+ name: 'delete_branch',
355
+ description: 'Delete a preview branch and its whole stack.',
356
+ inputSchema: { type: 'object', properties: { name: str('Branch name.') }, required: ['name'] },
357
+ run: args =>
358
+ api.consoleRequest(ctx, 'DELETE', consoleProjectPath(ctx, `/branches/${encodeURIComponent(args.name)}`)),
359
+ },
360
+ {
361
+ name: 'merge_branch',
362
+ description:
363
+ "Apply the branch's new migrations to the main project. Refused while the branch is behind main: rebase_branch first.",
364
+ inputSchema: { type: 'object', properties: { name: str('Branch name.') }, required: ['name'] },
365
+ run: branchAction(ctx, 'merge'),
366
+ },
367
+ {
368
+ name: 'rebase_branch',
369
+ description: "Apply the main project's newer migrations to the branch.",
370
+ inputSchema: { type: 'object', properties: { name: str('Branch name.') }, required: ['name'] },
371
+ run: branchAction(ctx, 'rebase'),
372
+ },
373
+ {
374
+ name: 'reset_branch',
375
+ description: 'Recreate the branch from the main project as it is now. Everything done on the branch is lost.',
376
+ inputSchema: { type: 'object', properties: { name: str('Branch name.') }, required: ['name'] },
377
+ run: branchAction(ctx, 'reset'),
378
+ },
379
+
380
+ // ── db-ext ──────────────────────────────────────────────────────────────
381
+ {
382
+ name: 'create_queue',
383
+ description: 'Create a message queue (pgmq).',
384
+ inputSchema: {
385
+ type: 'object',
386
+ properties: {
387
+ name: str('Queue name.'),
388
+ type: str("'basic' (default), 'unlogged' or 'partitioned'."),
389
+ },
390
+ required: ['name'],
391
+ },
392
+ run: args => api.adminRequest(ctx, 'POST', '/queues', { body: { name: args.name, type: args.type } }),
393
+ },
394
+ {
395
+ name: 'drop_queue',
396
+ description: 'Drop a queue and every message in it. Cannot be undone.',
397
+ inputSchema: { type: 'object', properties: { name: str('Queue name.') }, required: ['name'] },
398
+ run: args => api.adminRequest(ctx, 'DELETE', `/queues/${encodeURIComponent(args.name)}`),
399
+ },
400
+ {
401
+ name: 'send_queue_message',
402
+ description: 'Send a JSON message to a queue.',
403
+ inputSchema: {
404
+ type: 'object',
405
+ properties: {
406
+ name: str('Queue name.'),
407
+ message: { description: 'Any JSON value.' },
408
+ delay: { type: 'number', description: 'Seconds before the message becomes visible. Defaults to 0.' },
409
+ },
410
+ required: ['name', 'message'],
411
+ },
412
+ run: args =>
413
+ api.adminRequest(ctx, 'POST', `/queues/${encodeURIComponent(args.name)}/messages`, {
414
+ body: { message: args.message, delay: args.delay },
415
+ }),
416
+ },
417
+ {
418
+ name: 'read_queue_messages',
419
+ description:
420
+ 'Read messages from a queue. A write, because it hides them from other readers for the visibility timeout.',
421
+ inputSchema: {
422
+ type: 'object',
423
+ properties: {
424
+ name: str('Queue name.'),
425
+ qty: { type: 'number', description: 'How many messages, 1 to 100. Defaults to 1.' },
426
+ vt: { type: 'number', description: 'Visibility timeout in seconds. Defaults to 30.' },
427
+ },
428
+ required: ['name'],
429
+ },
430
+ run: args =>
431
+ api.adminRequest(ctx, 'POST', `/queues/${encodeURIComponent(args.name)}/read`, {
432
+ body: { qty: args.qty, vt: args.vt },
433
+ }),
434
+ },
435
+ {
436
+ name: 'create_vault_secret',
437
+ description: 'Store an encrypted secret in the Vault. There is no tool that reads a value back.',
438
+ inputSchema: {
439
+ type: 'object',
440
+ properties: { name: str('Unique secret name.'), secret: str('The value.'), description: str('Optional note.') },
441
+ required: ['name', 'secret'],
442
+ },
443
+ run: args =>
444
+ api.adminRequest(ctx, 'POST', '/vault/secrets', {
445
+ body: { name: args.name, secret: args.secret, description: args.description },
446
+ }),
447
+ },
448
+ {
449
+ name: 'delete_vault_secret',
450
+ description: 'Delete a Vault secret by id. Cannot be undone.',
451
+ inputSchema: { type: 'object', properties: { id: str('Secret id, from list_vault_secrets.') }, required: ['id'] },
452
+ run: args => api.adminRequest(ctx, 'DELETE', `/vault/secrets/${encodeURIComponent(args.id)}`),
453
+ },
454
+ // ── /db-ext ─────────────────────────────────────────────────────────────
455
+
456
+ // ── storage ──────────────────────────────────────────────────────────────
457
+ ...storage.writeStorageTools(ctx),
458
+ // ── /storage ─────────────────────────────────────────────────────────────
154
459
  ];
155
460
  }
156
461
 
@@ -158,9 +463,16 @@ function writeTools(ctx) {
158
463
  * `write` defaults to false, so an agent that connects without being told
159
464
  * otherwise can read the schema, lint it and simulate policies, but cannot
160
465
  * change anything. Handing an agent DDL is a decision the operator makes.
466
+ *
467
+ * `project` and `console` say what this session can reach: the project tools
468
+ * need a linked (or direct) project, the account tools a console login, and a
469
+ * tool that can only fail is left out rather than offered.
161
470
  */
162
- function buildTools(ctx, { write = false } = {}) {
163
- return write ? [...readTools(ctx), ...writeTools(ctx)] : readTools(ctx);
471
+ function buildTools(ctx, { write = false, project = true, console = false } = {}) {
472
+ const tools = [];
473
+ if (project) tools.push(...readTools(ctx), ...(write ? writeTools(ctx) : []));
474
+ if (console) tools.push(...platform.consoleReadTools(ctx), ...(write ? platform.consoleWriteTools(ctx) : []));
475
+ return tools;
164
476
  }
165
477
 
166
478
  module.exports = { buildTools, readTools, writeTools };