@motherbase/cli 0.2.3 → 0.2.4
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/package.json +3 -2
- package/src/commands/index.js +6 -1
- package/src/commands/keys.js +72 -0
- package/src/commands/local.js +273 -64
- package/src/commands/mcp.js +43 -0
- package/src/mcp/protocol.js +149 -0
- package/src/mcp/tools.js +166 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@motherbase/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Motherbase operator CLI — drives the Platform Console and linked projects.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"bin": {
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"node": ">=18"
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
|
-
"test": "node --test"
|
|
13
|
+
"test": "node --test",
|
|
14
|
+
"lint": "eslint ."
|
|
14
15
|
},
|
|
15
16
|
"files": [
|
|
16
17
|
"bin",
|
package/src/commands/index.js
CHANGED
|
@@ -36,6 +36,7 @@ const registry = {
|
|
|
36
36
|
storage: require('./storage'),
|
|
37
37
|
functions: require('./functions'),
|
|
38
38
|
secrets: require('./secrets'),
|
|
39
|
+
keys: require('./keys'),
|
|
39
40
|
realtime: require('./realtime'),
|
|
40
41
|
webhooks: require('./webhooks'),
|
|
41
42
|
cron: require('./cron'),
|
|
@@ -44,6 +45,9 @@ const registry = {
|
|
|
44
45
|
|
|
45
46
|
// ── Codegen ────────────────────────────────────────────────────────────────
|
|
46
47
|
gen: require('./gen'),
|
|
48
|
+
|
|
49
|
+
// ── AI agents ──────────────────────────────────────────────────────────────
|
|
50
|
+
mcp: require('./mcp'),
|
|
47
51
|
};
|
|
48
52
|
|
|
49
53
|
// Grouping used only to render a tidy top-level help listing.
|
|
@@ -52,8 +56,9 @@ const groups = [
|
|
|
52
56
|
{ title: 'Projects (Console)', names: ['projects'] },
|
|
53
57
|
{ title: 'Local self-host', names: ['init', 'start', 'stop', 'deploy', 'logs'] },
|
|
54
58
|
{ title: 'Database', names: ['db', 'migrations', 'migration', 'import'] },
|
|
55
|
-
{ title: 'Resources', names: ['rls', 'users', 'storage', 'functions', 'secrets', 'realtime', 'webhooks', 'cron', 'config', 'inspect'] },
|
|
59
|
+
{ title: 'Resources', names: ['rls', 'users', 'storage', 'functions', 'secrets', 'keys', 'realtime', 'webhooks', 'cron', 'config', 'inspect'] },
|
|
56
60
|
{ title: 'Codegen', names: ['gen'] },
|
|
61
|
+
{ title: 'AI agents', names: ['mcp'] },
|
|
57
62
|
];
|
|
58
63
|
|
|
59
64
|
module.exports = { registry, groups };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Project API keys. `secrets` next door is a different thing: those are values
|
|
4
|
+
// an edge function reads, these are what a client presents to reach the project.
|
|
5
|
+
|
|
6
|
+
const api = require('../api');
|
|
7
|
+
const { output, requireArg } = require('./_util');
|
|
8
|
+
|
|
9
|
+
const TYPES = ['publishable', 'secret'];
|
|
10
|
+
|
|
11
|
+
const subcommands = {
|
|
12
|
+
list: {
|
|
13
|
+
summary: 'List project API keys',
|
|
14
|
+
usage: 'motherbase keys list',
|
|
15
|
+
async run(ctx) {
|
|
16
|
+
const data = await api.adminRequest(ctx, 'GET', '/api-keys');
|
|
17
|
+
const keys = (data && data.keys) || [];
|
|
18
|
+
output(ctx, data, (_d, ui) =>
|
|
19
|
+
ui.table(
|
|
20
|
+
keys.map(k => ({
|
|
21
|
+
name: k.name,
|
|
22
|
+
type: k.type,
|
|
23
|
+
// A secret key has no stored clear value; the prefix is all there is.
|
|
24
|
+
key: k.token || `${k.prefix}…`,
|
|
25
|
+
status: k.revokedAt ? 'revoked' : 'active',
|
|
26
|
+
})),
|
|
27
|
+
[
|
|
28
|
+
{ key: 'name', label: 'NAME' },
|
|
29
|
+
{ key: 'type', label: 'TYPE' },
|
|
30
|
+
{ key: 'key', label: 'KEY' },
|
|
31
|
+
{ key: 'status', label: 'STATUS' },
|
|
32
|
+
],
|
|
33
|
+
),
|
|
34
|
+
);
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
create: {
|
|
38
|
+
summary: 'Create a key. A secret key is printed once and never again',
|
|
39
|
+
usage: 'motherbase keys create <NAME> [--type publishable|secret]',
|
|
40
|
+
async run(ctx, args, flags) {
|
|
41
|
+
const name = requireArg(args[0], 'NAME');
|
|
42
|
+
const type = flags.type || 'secret';
|
|
43
|
+
if (!TYPES.includes(type)) {
|
|
44
|
+
throw new Error(`--type must be one of: ${TYPES.join(', ')}`);
|
|
45
|
+
}
|
|
46
|
+
const data = await api.adminRequest(ctx, 'POST', '/api-keys', { body: { name, type } });
|
|
47
|
+
output(ctx, data, (d, ui) => {
|
|
48
|
+
ui.success(`Created ${type} key "${name}"`);
|
|
49
|
+
ui.print(d.clearToken);
|
|
50
|
+
if (type === 'secret') {
|
|
51
|
+
ui.warn('Copy it now. It is stored hashed and cannot be shown again.');
|
|
52
|
+
ui.warn('It bypasses row level security: keep it server side.');
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
revoke: {
|
|
58
|
+
summary: 'Revoke a key by id',
|
|
59
|
+
usage: 'motherbase keys revoke <ID>',
|
|
60
|
+
async run(ctx, args) {
|
|
61
|
+
const id = requireArg(args[0], 'ID');
|
|
62
|
+
const data = await api.adminRequest(ctx, 'DELETE', `/api-keys/${encodeURIComponent(id)}`);
|
|
63
|
+
output(ctx, data, (d, ui) => ui.success(`Revoked key ${d.name || id}`));
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
module.exports = {
|
|
69
|
+
summary: 'Project API keys (publishable and secret)',
|
|
70
|
+
usage: 'motherbase keys <list|create|revoke> ...',
|
|
71
|
+
subcommands,
|
|
72
|
+
};
|
package/src/commands/local.js
CHANGED
|
@@ -1,64 +1,273 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// Local self-host workflow: thin wrappers over `docker compose`, plus `init`
|
|
4
|
-
// scaffolding.
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Local self-host workflow: thin wrappers over `docker compose`, plus `init`
|
|
4
|
+
// scaffolding.
|
|
5
|
+
//
|
|
6
|
+
// `init` used to write a `.env` and nothing else, which left a directory that
|
|
7
|
+
// `motherbase start` could not start: `docker compose up` in a folder with no
|
|
8
|
+
// compose file is a raw Docker error, and that was the first thing a new user
|
|
9
|
+
// saw. It now writes a self-contained stack that runs.
|
|
10
|
+
|
|
11
|
+
const crypto = require('crypto');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { spawn } = require('child_process');
|
|
15
|
+
const ui = require('../ui');
|
|
16
|
+
const { CliError } = require('../context');
|
|
17
|
+
|
|
18
|
+
const COMPOSE_FILE = 'docker-compose.yml';
|
|
19
|
+
const ENV_FILE = '.env';
|
|
20
|
+
|
|
21
|
+
// Where the images are built from when the caller has no checkout. Docker
|
|
22
|
+
// Compose accepts a git URL as a build context, so a scaffolded directory needs
|
|
23
|
+
// nothing but Docker: no clone, no registry, no published image. The first `up`
|
|
24
|
+
// pays for the build; every one after that is cached.
|
|
25
|
+
const DEFAULT_REPO = 'https://github.com/JulienGuinot/BoilerPlate-Backend.git';
|
|
26
|
+
const DEFAULT_REF = 'master';
|
|
27
|
+
|
|
28
|
+
function dockerCompose(composeArgs) {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const child = spawn('docker', ['compose', ...composeArgs], { stdio: 'inherit', windowsHide: true });
|
|
31
|
+
child.on('error', err => reject(new CliError(`failed to run docker: ${err.message}`)));
|
|
32
|
+
child.on('close', code => (code === 0 ? resolve() : reject(new CliError(`docker compose exited with code ${code}`))));
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Refuse to run compose from a directory that has no stack, and say what to do.
|
|
38
|
+
* Without this the user gets Docker's own "no configuration file provided",
|
|
39
|
+
* which does not mention `motherbase init`.
|
|
40
|
+
*/
|
|
41
|
+
function assertStack(ctx) {
|
|
42
|
+
const file = path.join(ctx.cwd, COMPOSE_FILE);
|
|
43
|
+
if (!fs.existsSync(file)) {
|
|
44
|
+
throw new CliError(
|
|
45
|
+
`no ${COMPOSE_FILE} here. Run \`motherbase init\` first, or cd into a directory that has one.`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const hex = n => crypto.randomBytes(n).toString('hex');
|
|
51
|
+
const b64 = n => crypto.randomBytes(n).toString('base64');
|
|
52
|
+
|
|
53
|
+
function renderEnv(context) {
|
|
54
|
+
return [
|
|
55
|
+
'# Generated by `motherbase init`. Secrets below are unique to this install.',
|
|
56
|
+
'# Keep this file out of version control.',
|
|
57
|
+
'',
|
|
58
|
+
`PORT=${context.port}`,
|
|
59
|
+
`BASE_HOST=localhost:${context.port}`,
|
|
60
|
+
'NODE_ENV=production',
|
|
61
|
+
'',
|
|
62
|
+
'# Secrets. Regenerating any of these invalidates what it protects:',
|
|
63
|
+
'# JWT_SECRET signs sessions, CONFIG_ENCRYPTION_KEY encrypts Studio-managed',
|
|
64
|
+
'# secrets (SMTP, OAuth credentials) at rest.',
|
|
65
|
+
`JWT_SECRET=${hex(32)}`,
|
|
66
|
+
`CONFIG_ENCRYPTION_KEY=${b64(32)}`,
|
|
67
|
+
`POSTGRES_PASSWORD=${hex(16)}`,
|
|
68
|
+
`S3_ACCESS_KEY_ID=${hex(8)}`,
|
|
69
|
+
`S3_SECRET_ACCESS_KEY=${hex(20)}`,
|
|
70
|
+
'',
|
|
71
|
+
'S3_BUCKET=uploads',
|
|
72
|
+
'S3_PUBLIC_BUCKET=public',
|
|
73
|
+
'S3_REGION=us-east-1',
|
|
74
|
+
'',
|
|
75
|
+
'# Host ports. Change these if something already listens on them.',
|
|
76
|
+
`POSTGRES_PORT=${context.postgresPort}`,
|
|
77
|
+
'MINIO_PORT=59000',
|
|
78
|
+
'MINIO_CONSOLE_PORT=59001',
|
|
79
|
+
'',
|
|
80
|
+
'# First operator account, created on first boot.',
|
|
81
|
+
`SEED_ADMIN_EMAIL=${context.adminEmail}`,
|
|
82
|
+
`SEED_ADMIN_PASSWORD=${context.adminPassword}`,
|
|
83
|
+
'',
|
|
84
|
+
].join('\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function renderCompose(context) {
|
|
88
|
+
const buildContext = `${context.repo}#${context.ref}`;
|
|
89
|
+
return `# Generated by \`motherbase init\`.
|
|
90
|
+
#
|
|
91
|
+
# Images are built from the public repository, so this directory needs Docker and
|
|
92
|
+
# nothing else: no checkout, no registry login. The first \`up\` builds them, which
|
|
93
|
+
# takes a few minutes; after that the layers are cached.
|
|
94
|
+
#
|
|
95
|
+
# Working from a checkout instead? Replace both \`context:\` values with the path
|
|
96
|
+
# to it and the build becomes local and incremental.
|
|
97
|
+
services:
|
|
98
|
+
db:
|
|
99
|
+
build:
|
|
100
|
+
context: ${buildContext}
|
|
101
|
+
dockerfile: deploy/postgres.Dockerfile
|
|
102
|
+
command: ['postgres', '-c', 'shared_preload_libraries=pg_cron,pg_stat_statements', '-c', 'cron.database_name=app']
|
|
103
|
+
restart: unless-stopped
|
|
104
|
+
environment:
|
|
105
|
+
POSTGRES_USER: postgres
|
|
106
|
+
POSTGRES_PASSWORD: \${POSTGRES_PASSWORD}
|
|
107
|
+
POSTGRES_DB: app
|
|
108
|
+
ports:
|
|
109
|
+
# Loopback only: reachable by psql, Prisma, Drizzle or a BI client on this
|
|
110
|
+
# machine, and by nothing on the network.
|
|
111
|
+
- '127.0.0.1:\${POSTGRES_PORT:-55432}:5432'
|
|
112
|
+
volumes:
|
|
113
|
+
- db_data:/var/lib/postgresql/data
|
|
114
|
+
healthcheck:
|
|
115
|
+
test: ['CMD-SHELL', 'pg_isready -U postgres']
|
|
116
|
+
interval: 5s
|
|
117
|
+
timeout: 5s
|
|
118
|
+
retries: 10
|
|
119
|
+
|
|
120
|
+
minio:
|
|
121
|
+
image: alpine/minio:RELEASE.2025-10-15T17-29-55Z
|
|
122
|
+
restart: unless-stopped
|
|
123
|
+
user: "0:0"
|
|
124
|
+
command: server /data --console-address ":9001"
|
|
125
|
+
environment:
|
|
126
|
+
MINIO_ROOT_USER: \${S3_ACCESS_KEY_ID}
|
|
127
|
+
MINIO_ROOT_PASSWORD: \${S3_SECRET_ACCESS_KEY}
|
|
128
|
+
ports:
|
|
129
|
+
- '127.0.0.1:\${MINIO_PORT:-59000}:9000'
|
|
130
|
+
- '127.0.0.1:\${MINIO_CONSOLE_PORT:-59001}:9001'
|
|
131
|
+
volumes:
|
|
132
|
+
- minio_data:/data
|
|
133
|
+
healthcheck:
|
|
134
|
+
test: ['CMD-SHELL', 'wget -qO- http://localhost:9000/minio/health/live >/dev/null']
|
|
135
|
+
interval: 5s
|
|
136
|
+
timeout: 5s
|
|
137
|
+
retries: 10
|
|
138
|
+
|
|
139
|
+
# One-shot: a private bucket and a separate anonymously-downloadable one.
|
|
140
|
+
createbuckets:
|
|
141
|
+
image: minio/mc:RELEASE.2025-08-13T08-35-41Z
|
|
142
|
+
depends_on:
|
|
143
|
+
minio:
|
|
144
|
+
condition: service_healthy
|
|
145
|
+
entrypoint: >
|
|
146
|
+
/bin/sh -c "
|
|
147
|
+
mc alias set local http://minio:9000 \${S3_ACCESS_KEY_ID} \${S3_SECRET_ACCESS_KEY};
|
|
148
|
+
mc mb --ignore-existing local/\${S3_BUCKET:-uploads};
|
|
149
|
+
mc anonymous set none local/\${S3_BUCKET:-uploads};
|
|
150
|
+
mc mb --ignore-existing local/\${S3_PUBLIC_BUCKET:-public};
|
|
151
|
+
mc anonymous set download local/\${S3_PUBLIC_BUCKET:-public};
|
|
152
|
+
echo 'buckets ready';
|
|
153
|
+
"
|
|
154
|
+
|
|
155
|
+
api:
|
|
156
|
+
build:
|
|
157
|
+
context: ${buildContext}
|
|
158
|
+
restart: unless-stopped
|
|
159
|
+
depends_on:
|
|
160
|
+
db:
|
|
161
|
+
condition: service_healthy
|
|
162
|
+
minio:
|
|
163
|
+
condition: service_healthy
|
|
164
|
+
createbuckets:
|
|
165
|
+
condition: service_completed_successfully
|
|
166
|
+
env_file:
|
|
167
|
+
- ${ENV_FILE}
|
|
168
|
+
# Container-internal wiring, overriding the localhost values in .env so the
|
|
169
|
+
# same file works for both host tooling and the containers.
|
|
170
|
+
environment:
|
|
171
|
+
NODE_ENV: production
|
|
172
|
+
PORT: 5001
|
|
173
|
+
DATABASE_URL: postgresql://postgres:\${POSTGRES_PASSWORD}@db:5432/app?schema=public
|
|
174
|
+
STORAGE_DRIVER: s3
|
|
175
|
+
S3_ENDPOINT: http://minio:9000
|
|
176
|
+
DB_PORT: \${POSTGRES_PORT:-55432}
|
|
177
|
+
ports:
|
|
178
|
+
- '\${PORT:-5001}:5001'
|
|
179
|
+
|
|
180
|
+
volumes:
|
|
181
|
+
db_data:
|
|
182
|
+
minio_data:
|
|
183
|
+
`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const init = {
|
|
187
|
+
summary: 'Scaffold a runnable local stack (compose file, .env, generated secrets)',
|
|
188
|
+
usage: 'motherbase init [dir] [--port 5001] [--repo URL] [--ref BRANCH] [--force]',
|
|
189
|
+
async run(ctx, args, flags = {}) {
|
|
190
|
+
const dir = args[0] || '.';
|
|
191
|
+
const target = path.resolve(ctx.cwd, dir);
|
|
192
|
+
fs.mkdirSync(target, { recursive: true });
|
|
193
|
+
|
|
194
|
+
const context = {
|
|
195
|
+
port: Number(flags.port) || 5001,
|
|
196
|
+
postgresPort: Number(flags['postgres-port']) || 55432,
|
|
197
|
+
repo: flags.repo || DEFAULT_REPO,
|
|
198
|
+
ref: flags.ref || DEFAULT_REF,
|
|
199
|
+
adminEmail: flags['admin-email'] || 'admin@example.com',
|
|
200
|
+
// Generated, never a placeholder: a scaffold that ships `change-me` is a
|
|
201
|
+
// scaffold that reaches production with `change-me`.
|
|
202
|
+
adminPassword: flags['admin-password'] || crypto.randomBytes(12).toString('base64url'),
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const written = [];
|
|
206
|
+
const skipped = [];
|
|
207
|
+
for (const [name, render] of [[ENV_FILE, renderEnv], [COMPOSE_FILE, renderCompose]]) {
|
|
208
|
+
const file = path.join(target, name);
|
|
209
|
+
// Never silently overwrite: .env holds the only copy of secrets that
|
|
210
|
+
// existing data was encrypted with.
|
|
211
|
+
if (fs.existsSync(file) && !flags.force) {
|
|
212
|
+
skipped.push(name);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
fs.writeFileSync(file, render(context), name === ENV_FILE ? { mode: 0o600 } : undefined);
|
|
216
|
+
written.push(name);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
ui.success(`Initialized Motherbase project at ${target}`);
|
|
220
|
+
if (written.length) ui.info(`wrote ${written.join(', ')}`);
|
|
221
|
+
if (skipped.length) {
|
|
222
|
+
ui.warn(`kept existing ${skipped.join(', ')} (use --force to overwrite, which discards their secrets)`);
|
|
223
|
+
}
|
|
224
|
+
if (written.includes(ENV_FILE)) {
|
|
225
|
+
ui.print('');
|
|
226
|
+
ui.print(` Studio: http://localhost:${context.port}`);
|
|
227
|
+
ui.print(` Sign in: ${context.adminEmail} / ${context.adminPassword}`);
|
|
228
|
+
ui.print('');
|
|
229
|
+
ui.print(' Next:');
|
|
230
|
+
if (dir !== '.') ui.print(` cd ${dir}`);
|
|
231
|
+
ui.print(' motherbase start # first run builds the images, give it a few minutes');
|
|
232
|
+
ui.print('');
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const start = {
|
|
238
|
+
summary: 'Start the local docker compose stack',
|
|
239
|
+
usage: 'motherbase start',
|
|
240
|
+
async run(ctx) {
|
|
241
|
+
assertStack(ctx);
|
|
242
|
+
await dockerCompose(['up', '-d']);
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const stop = {
|
|
247
|
+
summary: 'Stop the local docker compose stack',
|
|
248
|
+
usage: 'motherbase stop',
|
|
249
|
+
async run(ctx) {
|
|
250
|
+
assertStack(ctx);
|
|
251
|
+
await dockerCompose(['down']);
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const deploy = {
|
|
256
|
+
summary: 'Rebuild and start the local docker compose stack',
|
|
257
|
+
usage: 'motherbase deploy',
|
|
258
|
+
async run(ctx) {
|
|
259
|
+
assertStack(ctx);
|
|
260
|
+
await dockerCompose(['up', '-d', '--build']);
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const logs = {
|
|
265
|
+
summary: 'Follow logs from the local docker compose stack',
|
|
266
|
+
usage: 'motherbase logs [service...]',
|
|
267
|
+
async run(ctx, args) {
|
|
268
|
+
assertStack(ctx);
|
|
269
|
+
await dockerCompose(['logs', '-f', ...args]);
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
module.exports = { init, start, stop, deploy, logs, renderEnv, renderCompose };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// `motherbase mcp` — expose the linked project to an AI agent over the Model
|
|
4
|
+
// Context Protocol.
|
|
5
|
+
//
|
|
6
|
+
// The transport is stdio, which is how an MCP client launches a server: it spawns
|
|
7
|
+
// this process and talks JSON-RPC over its stdin/stdout. Nothing is printed to
|
|
8
|
+
// stdout except protocol messages, so diagnostics go to stderr.
|
|
9
|
+
|
|
10
|
+
const { serve, toolServer } = require('../mcp/protocol');
|
|
11
|
+
const { buildTools } = require('../mcp/tools');
|
|
12
|
+
|
|
13
|
+
const pkg = require('../../package.json');
|
|
14
|
+
|
|
15
|
+
const mcp = {
|
|
16
|
+
summary: 'Serve the linked project to an AI agent over MCP (stdio)',
|
|
17
|
+
usage: 'motherbase mcp [--write]',
|
|
18
|
+
async run(ctx, _args, flags = {}) {
|
|
19
|
+
// Resolve the project up front. Failing here is a clear CLI error; failing
|
|
20
|
+
// on the first tool call is an opaque one inside someone's agent.
|
|
21
|
+
ctx.requireProject();
|
|
22
|
+
|
|
23
|
+
const write = Boolean(flags.write);
|
|
24
|
+
const tools = buildTools(ctx, { write });
|
|
25
|
+
|
|
26
|
+
process.stderr.write(
|
|
27
|
+
`motherbase mcp: ${tools.length} tools, ${write ? 'read and write' : 'read only'}. `
|
|
28
|
+
+ `Project: ${ctx.projectName || ctx.projectId || 'direct'}\n`,
|
|
29
|
+
);
|
|
30
|
+
if (!write) {
|
|
31
|
+
process.stderr.write(' Pass --write to expose apply_migration and raw SQL writes.\n');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
await serve({
|
|
35
|
+
input: process.stdin,
|
|
36
|
+
output: process.stdout,
|
|
37
|
+
handlers: toolServer({ name: 'motherbase', version: pkg.version, tools }),
|
|
38
|
+
onError: err => process.stderr.write(`motherbase mcp: ${err && err.message ? err.message : err}\n`),
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
module.exports = mcp;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Minimal MCP server over stdio: newline-delimited JSON-RPC 2.0 on stdin/stdout.
|
|
4
|
+
//
|
|
5
|
+
// Why not the official SDK: a stdio tools server needs five methods, and
|
|
6
|
+
// `@modelcontextprotocol/sdk` pulls express, hono, cors, ajv, jose and a dozen
|
|
7
|
+
// more to provide them. This CLI is deliberately zero-dependency, and making
|
|
8
|
+
// `npx @motherbase/cli` install a web framework so that one subcommand can read
|
|
9
|
+
// stdin is not a trade worth making.
|
|
10
|
+
//
|
|
11
|
+
// ponytail: stdio transport and the tools capability only. No resources, no
|
|
12
|
+
// prompts, no sampling, no HTTP/SSE transport. Adopt the official SDK if any of
|
|
13
|
+
// those is ever needed rather than growing this file into a second one.
|
|
14
|
+
|
|
15
|
+
// Echoed back to the client. MCP clients negotiate by sending their own version;
|
|
16
|
+
// answering with a version we implement is the contract, and every client in the
|
|
17
|
+
// field accepts a server that names an older one.
|
|
18
|
+
const PROTOCOL_VERSION = '2024-11-05';
|
|
19
|
+
|
|
20
|
+
/** JSON-RPC error codes used here. */
|
|
21
|
+
const METHOD_NOT_FOUND = -32601;
|
|
22
|
+
const INTERNAL_ERROR = -32603;
|
|
23
|
+
const PARSE_ERROR = -32700;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Reads newline-delimited JSON from `input`, dispatches to `handlers`, writes
|
|
27
|
+
* responses to `output`.
|
|
28
|
+
*
|
|
29
|
+
* Split from the tool definitions so the transport can be driven by a test with
|
|
30
|
+
* two in-memory streams and no child process.
|
|
31
|
+
*/
|
|
32
|
+
function serve({ input, output, handlers, onError }) {
|
|
33
|
+
let buffer = '';
|
|
34
|
+
|
|
35
|
+
const write = message => {
|
|
36
|
+
output.write(JSON.stringify(message) + '\n');
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const respond = (id, result) => {
|
|
40
|
+
// A notification (no id) gets no response. Sending one is a protocol error
|
|
41
|
+
// that some clients treat as fatal.
|
|
42
|
+
if (id === undefined || id === null) return;
|
|
43
|
+
write({ jsonrpc: '2.0', id, result });
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const fail = (id, code, message) => {
|
|
47
|
+
if (id === undefined || id === null) return;
|
|
48
|
+
write({ jsonrpc: '2.0', id, error: { code, message } });
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const dispatch = async message => {
|
|
52
|
+
const { id, method, params } = message;
|
|
53
|
+
const handler = handlers[method];
|
|
54
|
+
if (!handler) {
|
|
55
|
+
// Unknown notifications are ignored rather than answered: the spec sends
|
|
56
|
+
// several the server is not required to know about.
|
|
57
|
+
if (id === undefined || id === null) return;
|
|
58
|
+
return fail(id, METHOD_NOT_FOUND, `Unknown method: ${method}`);
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
respond(id, await handler(params || {}));
|
|
62
|
+
} catch (err) {
|
|
63
|
+
if (onError) onError(err);
|
|
64
|
+
fail(id, INTERNAL_ERROR, err && err.message ? err.message : String(err));
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return new Promise(resolve => {
|
|
69
|
+
input.setEncoding('utf8');
|
|
70
|
+
input.on('data', chunk => {
|
|
71
|
+
buffer += chunk;
|
|
72
|
+
let index;
|
|
73
|
+
while ((index = buffer.indexOf('\n')) >= 0) {
|
|
74
|
+
const line = buffer.slice(0, index).trim();
|
|
75
|
+
buffer = buffer.slice(index + 1);
|
|
76
|
+
if (!line) continue;
|
|
77
|
+
let message;
|
|
78
|
+
try {
|
|
79
|
+
message = JSON.parse(line);
|
|
80
|
+
} catch {
|
|
81
|
+
write({ jsonrpc: '2.0', id: null, error: { code: PARSE_ERROR, message: 'Invalid JSON' } });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
// Deliberately not awaited: a slow tool call must not stall the reader,
|
|
85
|
+
// and JSON-RPC responses carry their id so they may arrive out of order.
|
|
86
|
+
void dispatch(message);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
input.on('end', resolve);
|
|
90
|
+
input.on('close', resolve);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build the five handlers a tools-only server needs.
|
|
96
|
+
*
|
|
97
|
+
* `tools` is a list of `{ name, description, inputSchema, run }`. `run` returns
|
|
98
|
+
* anything JSON-serialisable; it is rendered as a text content block, which is
|
|
99
|
+
* what every current client displays and what models read best.
|
|
100
|
+
*/
|
|
101
|
+
function toolServer({ name, version, tools }) {
|
|
102
|
+
const byName = new Map(tools.map(tool => [tool.name, tool]));
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
initialize: () => ({
|
|
106
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
107
|
+
capabilities: { tools: {} },
|
|
108
|
+
serverInfo: { name, version },
|
|
109
|
+
}),
|
|
110
|
+
'notifications/initialized': () => undefined,
|
|
111
|
+
ping: () => ({}),
|
|
112
|
+
'tools/list': () => ({
|
|
113
|
+
tools: tools.map(({ name: toolName, description, inputSchema }) => ({
|
|
114
|
+
name: toolName,
|
|
115
|
+
description,
|
|
116
|
+
inputSchema,
|
|
117
|
+
})),
|
|
118
|
+
}),
|
|
119
|
+
'tools/call': async params => {
|
|
120
|
+
const tool = byName.get(params.name);
|
|
121
|
+
if (!tool) {
|
|
122
|
+
// A missing tool is reported in-band, not as a JSON-RPC error: the model
|
|
123
|
+
// should see it and correct itself rather than the client showing a
|
|
124
|
+
// transport failure.
|
|
125
|
+
return {
|
|
126
|
+
isError: true,
|
|
127
|
+
content: [{ type: 'text', text: `Unknown tool: ${params.name}` }],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const result = await tool.run(params.arguments || {});
|
|
132
|
+
return { content: [{ type: 'text', text: render(result) }] };
|
|
133
|
+
} catch (err) {
|
|
134
|
+
return {
|
|
135
|
+
isError: true,
|
|
136
|
+
content: [{ type: 'text', text: err && err.message ? err.message : String(err) }],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function render(value) {
|
|
144
|
+
if (typeof value === 'string') return value;
|
|
145
|
+
if (value === undefined) return 'ok';
|
|
146
|
+
return JSON.stringify(value, null, 2);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = { serve, toolServer, PROTOCOL_VERSION };
|
package/src/mcp/tools.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The tools an agent gets over MCP.
|
|
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,
|
|
7
|
+
// console-proxy vs direct mode, and error mapping are solved once in the CLI and
|
|
8
|
+
// reused here, so an MCP session cannot reach anything a `motherbase` command
|
|
9
|
+
// could not, and cannot drift from it either.
|
|
10
|
+
//
|
|
11
|
+
// The gap this closes is the one self-hosters name about Supabase's MCP: "DDL
|
|
12
|
+
// statements not supported". `apply_migration` runs DDL, because the admin API
|
|
13
|
+
// this is built on already does.
|
|
14
|
+
|
|
15
|
+
const api = require('../api');
|
|
16
|
+
|
|
17
|
+
const str = description => ({ type: 'string', description });
|
|
18
|
+
|
|
19
|
+
/** Tools that only read. Safe to run without confirmation. */
|
|
20
|
+
function readTools(ctx) {
|
|
21
|
+
return [
|
|
22
|
+
{
|
|
23
|
+
name: 'list_tables',
|
|
24
|
+
description: 'List the tables in the project database, with their columns and row counts.',
|
|
25
|
+
inputSchema: { type: 'object', properties: {} },
|
|
26
|
+
run: () => api.adminRequest(ctx, 'GET', '/tables'),
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'describe_table',
|
|
30
|
+
description: 'Columns, types, nullability, defaults, primary key and indexes of one table.',
|
|
31
|
+
inputSchema: {
|
|
32
|
+
type: 'object',
|
|
33
|
+
properties: { table: str('Table name, in the public schema.') },
|
|
34
|
+
required: ['table'],
|
|
35
|
+
},
|
|
36
|
+
run: async args => {
|
|
37
|
+
const [tables, indexes] = await Promise.all([
|
|
38
|
+
api.adminRequest(ctx, 'GET', '/schema/tables'),
|
|
39
|
+
api.adminRequest(ctx, 'GET', `/schema/tables/${encodeURIComponent(args.table)}/indexes`),
|
|
40
|
+
]);
|
|
41
|
+
const list = Array.isArray(tables) ? tables : tables && tables.tables;
|
|
42
|
+
const table = (list || []).find(t => t.name === args.table || t.table === args.table);
|
|
43
|
+
if (!table) throw new Error(`No table named "${args.table}" in the public schema.`);
|
|
44
|
+
return { ...table, indexes };
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'list_policies',
|
|
49
|
+
description:
|
|
50
|
+
'Row level security state for every table: whether RLS is on, and the policies attached with their USING and WITH CHECK expressions.',
|
|
51
|
+
inputSchema: { type: 'object', properties: {} },
|
|
52
|
+
run: () => api.adminRequest(ctx, 'GET', '/rls/tables'),
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'simulate_rls',
|
|
56
|
+
description:
|
|
57
|
+
'Run a SELECT as a given caller and report the rows they can see plus the policies that decided. The statement really executes, inside a transaction that is always rolled back.',
|
|
58
|
+
inputSchema: {
|
|
59
|
+
type: 'object',
|
|
60
|
+
properties: {
|
|
61
|
+
sql: str('A single SELECT statement.'),
|
|
62
|
+
role: str("'anon' or 'authenticated'. Ignored when userId is given."),
|
|
63
|
+
userId: str('Run as this existing user instead of a bare role.'),
|
|
64
|
+
},
|
|
65
|
+
required: ['sql'],
|
|
66
|
+
},
|
|
67
|
+
run: args =>
|
|
68
|
+
api.adminRequest(ctx, 'POST', '/rls/simulate', {
|
|
69
|
+
body: { sql: args.sql, role: args.role, userId: args.userId },
|
|
70
|
+
}),
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'get_advisors',
|
|
74
|
+
description:
|
|
75
|
+
'Lint the schema and policies: tables exposed with RLS off, RLS on with no policy, policies that leak through a join, SECURITY DEFINER functions anon can call, unindexed policy columns.',
|
|
76
|
+
inputSchema: { type: 'object', properties: {} },
|
|
77
|
+
run: () => api.adminRequest(ctx, 'GET', '/advisor'),
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: 'get_logs',
|
|
81
|
+
description: 'Recent API and database log entries.',
|
|
82
|
+
inputSchema: {
|
|
83
|
+
type: 'object',
|
|
84
|
+
properties: {
|
|
85
|
+
limit: { type: 'number', description: 'How many entries. Defaults to 100.' },
|
|
86
|
+
level: str("Filter by level: 'info', 'warn' or 'error'."),
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
run: args =>
|
|
90
|
+
api.adminRequest(ctx, 'GET', '/logs', {
|
|
91
|
+
query: { limit: args.limit || 100, level: args.level },
|
|
92
|
+
}),
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: 'generate_types',
|
|
96
|
+
description:
|
|
97
|
+
'TypeScript types for the whole schema, in the supabase-js Database shape. The same output as `motherbase gen types typescript`.',
|
|
98
|
+
inputSchema: { type: 'object', properties: {} },
|
|
99
|
+
run: () => api.adminRequest(ctx, 'GET', '/schema/types'),
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: 'execute_sql',
|
|
103
|
+
description:
|
|
104
|
+
'Run a read-only SQL query. Statements that write are refused: use apply_migration for those.',
|
|
105
|
+
inputSchema: {
|
|
106
|
+
type: 'object',
|
|
107
|
+
properties: { sql: str('A read-only SQL statement.') },
|
|
108
|
+
required: ['sql'],
|
|
109
|
+
},
|
|
110
|
+
// The read-only endpoint, not /sql: a tool the model reaches for by default
|
|
111
|
+
// must not be able to write, whatever the model believed it was doing.
|
|
112
|
+
run: args => api.adminRequest(ctx, 'POST', '/sql/readonly', { body: { sql: args.sql } }),
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Tools that change the database. Only exposed with `--write`. */
|
|
118
|
+
function writeTools(ctx) {
|
|
119
|
+
return [
|
|
120
|
+
{
|
|
121
|
+
name: 'apply_migration',
|
|
122
|
+
description:
|
|
123
|
+
'Create a named migration from DDL and apply it. This is the tool for CREATE TABLE, ALTER TABLE, CREATE POLICY and every other schema change. The migration is recorded, so it can be rolled back.',
|
|
124
|
+
inputSchema: {
|
|
125
|
+
type: 'object',
|
|
126
|
+
properties: {
|
|
127
|
+
name: str('Short migration name, e.g. add_orders_table.'),
|
|
128
|
+
sql: str('The DDL to apply.'),
|
|
129
|
+
downSql: str('Optional SQL that undoes it, enabling rollback.'),
|
|
130
|
+
},
|
|
131
|
+
required: ['name', 'sql'],
|
|
132
|
+
},
|
|
133
|
+
run: async args => {
|
|
134
|
+
const created = await api.adminRequest(ctx, 'POST', '/migrations', {
|
|
135
|
+
body: { name: args.name, upSql: args.sql, downSql: args.downSql },
|
|
136
|
+
});
|
|
137
|
+
const id = created && (created.id || (created.migration && created.migration.id));
|
|
138
|
+
if (!id) throw new Error('The migration was created but the server returned no id to apply.');
|
|
139
|
+
const applied = await api.adminRequest(ctx, 'POST', `/migrations/${encodeURIComponent(id)}/apply`);
|
|
140
|
+
return { id, applied };
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: 'execute_sql_write',
|
|
145
|
+
description:
|
|
146
|
+
'Run arbitrary SQL, including writes. Prefer apply_migration for schema changes: this leaves no record and cannot be rolled back.',
|
|
147
|
+
inputSchema: {
|
|
148
|
+
type: 'object',
|
|
149
|
+
properties: { sql: str('The SQL to run.') },
|
|
150
|
+
required: ['sql'],
|
|
151
|
+
},
|
|
152
|
+
run: args => api.adminRequest(ctx, 'POST', '/sql', { body: { sql: args.sql } }),
|
|
153
|
+
},
|
|
154
|
+
];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* `write` defaults to false, so an agent that connects without being told
|
|
159
|
+
* otherwise can read the schema, lint it and simulate policies, but cannot
|
|
160
|
+
* change anything. Handing an agent DDL is a decision the operator makes.
|
|
161
|
+
*/
|
|
162
|
+
function buildTools(ctx, { write = false } = {}) {
|
|
163
|
+
return write ? [...readTools(ctx), ...writeTools(ctx)] : readTools(ctx);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = { buildTools, readTools, writeTools };
|