@motherbase/cli 0.2.2 → 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/README.md +24 -0
- package/package.json +4 -5
- package/src/api.js +11 -3
- package/src/commands/functions.js +274 -112
- package/src/commands/gen.js +126 -0
- package/src/commands/index.js +10 -1
- package/src/commands/keys.js +72 -0
- package/src/commands/local.js +273 -64
- package/src/commands/mcp.js +43 -0
- package/src/commands/rls.js +93 -2
- package/src/main.js +5 -0
- package/src/mcp/protocol.js +149 -0
- package/src/mcp/tools.js +166 -0
- package/src/prompt.js +61 -14
|
@@ -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 };
|
package/src/prompt.js
CHANGED
|
@@ -14,23 +14,70 @@ function question(query) {
|
|
|
14
14
|
});
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
// Hidden input
|
|
18
|
-
//
|
|
17
|
+
// Hidden input. Reads the TTY in raw mode so nothing echoes, masking each typed
|
|
18
|
+
// character with '*'. Handles backspace, Enter, Ctrl+C, Ctrl+D and swallows
|
|
19
|
+
// escape sequences (arrow keys etc.) so they never land in the value. When
|
|
20
|
+
// stdin is not a TTY (pipes, CI) we fall back to a plain, unmasked line read.
|
|
19
21
|
function password(query = 'Password: ') {
|
|
22
|
+
const stdin = process.stdin;
|
|
23
|
+
const stdout = process.stdout;
|
|
24
|
+
if (!stdin.isTTY) return question(query);
|
|
25
|
+
|
|
20
26
|
return new Promise(resolve => {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
let value = '';
|
|
28
|
+
let inEscape = false;
|
|
29
|
+
stdout.write(query);
|
|
30
|
+
stdin.setRawMode(true);
|
|
31
|
+
stdin.resume();
|
|
32
|
+
stdin.setEncoding('utf8');
|
|
33
|
+
|
|
34
|
+
const done = result => {
|
|
35
|
+
stdin.removeListener('data', onData);
|
|
36
|
+
stdin.setRawMode(false);
|
|
37
|
+
stdin.pause();
|
|
38
|
+
stdout.write('\n');
|
|
39
|
+
resolve(result);
|
|
25
40
|
};
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
41
|
+
|
|
42
|
+
const onData = chunk => {
|
|
43
|
+
for (const ch of chunk) {
|
|
44
|
+
if (inEscape) {
|
|
45
|
+
// Terminators of a CSI/escape sequence (e.g. arrow keys): stop swallowing.
|
|
46
|
+
if (/[a-zA-Z~]/.test(ch)) inEscape = false;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
switch (ch) {
|
|
50
|
+
case '\r':
|
|
51
|
+
case '\n':
|
|
52
|
+
case '\u0004': // Ctrl-D
|
|
53
|
+
return done(value);
|
|
54
|
+
case '\u0003': // Ctrl-C
|
|
55
|
+
stdin.setRawMode(false);
|
|
56
|
+
stdin.pause();
|
|
57
|
+
stdout.write('\n');
|
|
58
|
+
process.exit(130);
|
|
59
|
+
return;
|
|
60
|
+
case '\u007f': // Backspace / Delete
|
|
61
|
+
case '\b':
|
|
62
|
+
if (value.length > 0) {
|
|
63
|
+
value = value.slice(0, -1);
|
|
64
|
+
stdout.write('\b \b');
|
|
65
|
+
}
|
|
66
|
+
break;
|
|
67
|
+
case '\u001b': // ESC — start of an escape sequence
|
|
68
|
+
inEscape = true;
|
|
69
|
+
break;
|
|
70
|
+
default:
|
|
71
|
+
// Ignore remaining control characters; mask printable ones.
|
|
72
|
+
if (ch >= ' ') {
|
|
73
|
+
value += ch;
|
|
74
|
+
stdout.write('*');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
stdin.on('data', onData);
|
|
34
81
|
});
|
|
35
82
|
}
|
|
36
83
|
|