@rebasepro/mcp 0.17.3 → 0.18.0

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/dist/index.js CHANGED
@@ -131,6 +131,14 @@ async function loadClientSdk() {
131
131
  const REGISTRY_PATH = resolve(homedir(), ".rebase", "projects.json");
132
132
  /** In-memory project registry. */
133
133
  let registry = { projects: {}, activeProject: null };
134
+ /**
135
+ * Whether this run's `default` was computed from the environment or the
136
+ * working directory rather than read from disk. A derived `default` is never
137
+ * written back: it is recomputed at every start, and persisting it puts one
138
+ * project's directory, URL and dev service key in a machine-wide file that
139
+ * every other project on the machine reads.
140
+ */
141
+ let defaultIsDerived = false;
134
142
  /**
135
143
  * Load the project registry from disk. Creates the file if it doesn't exist.
136
144
  */
@@ -152,6 +160,13 @@ function loadRegistry() {
152
160
  }
153
161
  /**
154
162
  * Save the project registry to disk.
163
+ *
164
+ * A `default` that this run derived (from the env block or from a `rebase.json`
165
+ * in the working directory) is left out. `~/.rebase/projects.json` is
166
+ * machine-wide, so persisting a derived `default` writes one project's
167
+ * directory, backend URL and dev service key into the file every other project
168
+ * on the machine reads at startup — which is how project A's admin key became
169
+ * project B's. It costs nothing to leave out: it is recomputed at every start.
155
170
  */
156
171
  function saveRegistry() {
157
172
  try {
@@ -159,9 +174,34 @@ function saveRegistry() {
159
174
  if (!existsSync(dir)) {
160
175
  mkdirSync(dir, { recursive: true });
161
176
  }
177
+ // Leaving the derived `default` out is not the same as deleting the
178
+ // key: the file may already hold somebody's registered `default`, and
179
+ // dropping it lost that project's URL and token the first time any
180
+ // other project's server saved. Whatever was there is put back.
181
+ const onDisk = defaultIsDerived
182
+ ? (() => {
183
+ let persisted;
184
+ try {
185
+ persisted = JSON.parse(readFileSync(REGISTRY_PATH, "utf-8"))
186
+ .projects?.["default"];
187
+ }
188
+ catch {
189
+ // No file yet, or unreadable — nothing to preserve.
190
+ }
191
+ const projects = Object.fromEntries(Object.entries(registry.projects).filter(([name]) => name !== "default"));
192
+ if (persisted)
193
+ projects["default"] = persisted;
194
+ return {
195
+ projects,
196
+ activeProject: registry.activeProject === "default" && !persisted
197
+ ? null
198
+ : registry.activeProject
199
+ };
200
+ })()
201
+ : registry;
162
202
  // Owner-only: this file holds bearer tokens (service keys / API keys).
163
203
  // `mode` only applies on create, so chmod covers pre-existing files.
164
- writeFileSync(REGISTRY_PATH, JSON.stringify(registry, null, 2), { encoding: "utf-8", mode: 0o600 });
204
+ writeFileSync(REGISTRY_PATH, JSON.stringify(onDisk, null, 2), { encoding: "utf-8", mode: 0o600 });
165
205
  chmodSync(REGISTRY_PATH, 0o600);
166
206
  }
167
207
  catch {
@@ -200,26 +240,53 @@ function readDevState(projectDir) {
200
240
  return null;
201
241
  }
202
242
  }
243
+ /** Warn about a discovered/registered `baseUrl` disagreement at most once. */
244
+ const warnedBaseUrlMismatch = new Set();
203
245
  /**
204
246
  * Try to auto-discover the backend from `.rebase/state.json` in the project dir.
205
247
  * Updates the project config in the registry if a running server is found.
248
+ *
249
+ * Discovery fills gaps. It never overrules what the operator wrote down —
250
+ * neither the token nor the URL. Overruling the URL was worse than overruling
251
+ * the token: a project registered against staging or production had its
252
+ * `baseUrl` replaced by `http://localhost:<devport>` for as long as
253
+ * `rebase dev` happened to be running in that directory, and every tool then
254
+ * delivered that project's `rk_live_` key to the local backend. The registered
255
+ * value is the deliberate one; the state file is a convenience.
256
+ *
257
+ * @param devState - Injected for tests; read from the project dir by default.
206
258
  */
207
- function autoDiscoverLocal(project) {
208
- if (!project.projectDir)
209
- return project;
210
- const devState = readDevState(project.projectDir);
211
- if (!devState)
259
+ export function autoDiscoverLocal(project, devState = undefined) {
260
+ const state = devState !== undefined
261
+ ? devState
262
+ : project.projectDir
263
+ ? readDevState(project.projectDir)
264
+ : null;
265
+ if (!state)
212
266
  return project;
267
+ if (project.baseUrl && state.baseUrl && project.baseUrl !== state.baseUrl) {
268
+ // stderr, not stdout: stdout is the MCP framing channel.
269
+ const key = `${project.name}::${project.baseUrl}::${state.baseUrl}`;
270
+ if (!warnedBaseUrlMismatch.has(key)) {
271
+ warnedBaseUrlMismatch.add(key);
272
+ process.stderr.write(`[rebase-mcp] project "${project.name}" is registered against ${project.baseUrl}, ` +
273
+ `but a dev server is running at ${state.baseUrl}. The registered URL wins — ` +
274
+ `call rebase_project_switch with a project whose baseUrl is the dev server, ` +
275
+ `or rebase_project_add one, to target it.\n`);
276
+ }
277
+ }
213
278
  return {
214
279
  ...project,
215
- baseUrl: devState.baseUrl,
280
+ // A registered baseUrl wins over the discovered one, for the same
281
+ // reason the token does.
282
+ baseUrl: project.baseUrl || state.baseUrl,
216
283
  // A registered token wins over the discovered one. What discovery finds
217
284
  // is the dev server's *service key* — the unscoped admin secret — so
218
285
  // letting it win meant a deliberately narrow API key registered for
219
286
  // this project was silently upgraded to full admin on every call.
220
287
  // Discovery now only fills a gap, which is all the zero-config story
221
288
  // ever needed it to do.
222
- token: project.token || devState.serviceKey || ""
289
+ token: project.token || state.serviceKey || ""
223
290
  };
224
291
  }
225
292
  /**
@@ -263,7 +330,17 @@ function readServiceKeyFromEnv(projectDir) {
263
330
  return readEnvVarFromProject(projectDir, "REBASE_SERVICE_KEY", (v) => v.length >= 32);
264
331
  }
265
332
  // ── Environment & Initialization ────────────────────────────────────────────
266
- const ENV_PROJECT_DIR = process.env.REBASE_PROJECT_DIR || process.cwd();
333
+ /**
334
+ * The project this server run is about.
335
+ *
336
+ * `resolve` is not decoration: the scaffolded `.mcp.json` sets
337
+ * `REBASE_PROJECT_DIR: "."` — relative to the client's cwd, which for a
338
+ * project-level `.mcp.json` is the project — and a relative path stored in a
339
+ * machine-wide registry would mean a different directory in every terminal.
340
+ */
341
+ const ENV_PROJECT_DIR = resolve(process.env.REBASE_PROJECT_DIR || process.cwd());
342
+ /** `true` when the server's working directory is itself a Rebase project. */
343
+ const CWD_IS_PROJECT = existsSync(resolve(process.cwd(), "rebase.json"));
267
344
  // Try to load .env from the project directory
268
345
  for (const envPath of [
269
346
  resolve(ENV_PROJECT_DIR, ".env"),
@@ -276,24 +353,76 @@ for (const envPath of [
276
353
  }
277
354
  const ENV_BASE_URL = process.env.REBASE_BASE_URL || "";
278
355
  const ENV_API_TOKEN = process.env.REBASE_API_TOKEN || process.env.REBASE_TOKEN || "";
356
+ /**
357
+ * The env vars that describe a `default` project, or `null` when the client
358
+ * declared none of them.
359
+ *
360
+ * `ENV_PROJECT_DIR` cannot answer this on its own: it falls back to
361
+ * `process.cwd()`, so it is always truthy and "was it set?" has to be asked of
362
+ * `process.env` directly.
363
+ */
364
+ export function envDeclaredProject(env = process.env) {
365
+ const projectDir = env.REBASE_PROJECT_DIR || undefined;
366
+ const baseUrl = env.REBASE_BASE_URL || undefined;
367
+ const token = env.REBASE_API_TOKEN || env.REBASE_TOKEN || undefined;
368
+ if (!projectDir && !baseUrl && !token)
369
+ return null;
370
+ return { projectDir, baseUrl, token };
371
+ }
279
372
  /**
280
373
  * Initialize the project registry.
281
374
  *
282
- * Priority:
283
- * 1. REBASE_PROJECT_DIR env → single-project mode (backward-compatible)
284
- * 2. Load ~/.rebase/projects.json
285
- * 3. Auto-discover from .rebase/state.json in the project dir
375
+ * One precedence, and it holds everywhere this is written down (the docs, the
376
+ * README and this comment):
377
+ *
378
+ * 1. `REBASE_PROJECT_DIR` / `REBASE_BASE_URL` / `REBASE_API_TOKEN` the block
379
+ * in the client's own MCP config. If any of them is set, the `default`
380
+ * project is rebuilt from them on **every** start.
381
+ * 2. The server's working directory, when it holds a `rebase.json`. That is a
382
+ * project the person is standing in; nothing in a home-directory cache
383
+ * outranks it.
384
+ * 3. The persisted `default` in `~/.rebase/projects.json`, when neither of the
385
+ * first two says anything.
386
+ *
387
+ * Auto-discovery from `.rebase/state.json` fills the gaps in every case; it
388
+ * never overrules a value one of the three supplied.
389
+ *
390
+ * Step 2 exists because the registry is machine-wide and `default` is one
391
+ * entry in it. The scaffold shipped no env block, so the first project on the
392
+ * machine to call `rebase_project_add` persisted a `default` carrying *its*
393
+ * directory, URL and dev service key — and every other project on that machine
394
+ * then resolved to it, silently. A derived `default` is therefore also never
395
+ * written back to disk (`saveRegistry`): it is recomputed at every start, so
396
+ * persisting it only leaks one project's admin key into another's session.
397
+ *
398
+ * Step 1 used to be `if (!registry.projects["default"])` — the env vars seeded
399
+ * the registry once and were dead ever after. That is the wrong way round:
400
+ * the env block is what the person editing `.mcp.json` just wrote, and
401
+ * `~/.rebase/projects.json` is a cache in their home directory they have
402
+ * probably forgotten exists. Pointing `REBASE_PROJECT_DIR` at a second project
403
+ * silently kept talking to the first one, which is the failure this file can
404
+ * least afford: every tool here acts on whatever `default` resolves to.
405
+ *
406
+ * The rebuild is whole-entry, not per-field, on purpose. A token registered
407
+ * for one `projectDir` is a credential for *that* backend; carrying it over
408
+ * because the new env block only named a directory would hand the wrong
409
+ * project an admin key.
286
410
  */
287
411
  function initializeRegistry() {
288
412
  registry = loadRegistry();
289
- // Ensure a "default" project exists from env vars or CWD
290
- if (!registry.projects["default"]) {
291
- const devState = readDevState(ENV_PROJECT_DIR);
413
+ const fromEnv = envDeclaredProject();
414
+ if (fromEnv || CWD_IS_PROJECT || !registry.projects["default"]) {
415
+ defaultIsDerived = true;
292
416
  const envServiceKey = readServiceKeyFromEnv(ENV_PROJECT_DIR);
293
417
  registry.projects["default"] = {
294
418
  name: "default",
295
419
  projectDir: ENV_PROJECT_DIR,
296
- baseUrl: ENV_BASE_URL || devState?.baseUrl || "http://localhost:3001",
420
+ // Deliberately no `devState.baseUrl` here either, for the same
421
+ // reason: `autoDiscoverLocal` fills an empty `baseUrl` per call, so
422
+ // a dev server started (or restarted on a new port) after this
423
+ // process booted is still found. `DEFAULT_BASE_URL` is applied by
424
+ // `getActiveProject` when discovery finds nothing.
425
+ baseUrl: ENV_BASE_URL || "",
297
426
  // Deliberately no `devState.serviceKey` here: this runs once, at
298
427
  // startup, and a key baked in now would outrank the freshly
299
428
  // discovered one for the rest of the process — so a dev server
@@ -306,10 +435,71 @@ function initializeRegistry() {
306
435
  if (!registry.activeProject || !registry.projects[registry.activeProject]) {
307
436
  registry.activeProject = "default";
308
437
  }
438
+ // A sticky `activeProject` is machine-wide too. Remembering that the last
439
+ // session switched to "staging" is the point of a registry — but only
440
+ // inside the project that registered it. When this run resolves a project
441
+ // of its own and the remembered entry belongs to a different directory,
442
+ // the remembered one is somebody else's backend and somebody else's token.
443
+ const active = registry.activeProject ? registry.projects[registry.activeProject] : undefined;
444
+ if (defaultIsDerived &&
445
+ registry.activeProject !== "default" &&
446
+ active?.projectDir &&
447
+ resolve(active.projectDir) !== ENV_PROJECT_DIR) {
448
+ process.stderr.write(`[rebase-mcp] the remembered project "${registry.activeProject}" is registered under ` +
449
+ `${active.projectDir}, but this server runs in ${ENV_PROJECT_DIR}. Targeting "default" ` +
450
+ `(this directory) instead; call rebase_project_switch to change that.\n`);
451
+ registry.activeProject = "default";
452
+ }
453
+ warnIfEnvIgnored(fromEnv);
454
+ }
455
+ /**
456
+ * Say so on stderr if the environment asked for something the registry is not
457
+ * doing.
458
+ *
459
+ * Two cases, and only the second one is reachable now:
460
+ *
461
+ * - The `default` entry does not carry the env values. That is the bug fixed
462
+ * above; the check stays as a canary, because the symptom of the old
463
+ * behaviour was an assistant confidently reading the wrong database with no
464
+ * output anywhere saying so.
465
+ * - The env block is set but a *different* project is active, because a
466
+ * previous session called `rebase_project_switch` and the registry remembers
467
+ * it. Tools target `activeProject`, so the env block really is inert — the
468
+ * registry is not overruled here, since sticky project selection is the
469
+ * point of having a registry, but silence is not an option either.
470
+ *
471
+ * stderr, not stdout: stdout is the MCP framing channel.
472
+ */
473
+ function warnIfEnvIgnored(fromEnv) {
474
+ if (!fromEnv)
475
+ return;
476
+ const def = registry.projects["default"];
477
+ const mismatched = [];
478
+ if (fromEnv.projectDir && def?.projectDir !== fromEnv.projectDir)
479
+ mismatched.push("REBASE_PROJECT_DIR");
480
+ if (fromEnv.baseUrl && def?.baseUrl !== fromEnv.baseUrl)
481
+ mismatched.push("REBASE_BASE_URL");
482
+ if (fromEnv.token && def?.token !== fromEnv.token)
483
+ mismatched.push("REBASE_API_TOKEN");
484
+ if (mismatched.length) {
485
+ process.stderr.write(`[rebase-mcp] ${mismatched.join(", ")} set but not reflected in the "default" project — ` +
486
+ `this is a bug in the server; report it.\n`);
487
+ }
488
+ if (registry.activeProject && registry.activeProject !== "default") {
489
+ process.stderr.write(`[rebase-mcp] the environment describes the "default" project, but "${registry.activeProject}" ` +
490
+ `is the active one, so tools target it instead. Call rebase_project_switch with "default" ` +
491
+ `to use the environment's values.\n`);
492
+ }
309
493
  }
310
494
  initializeRegistry();
311
495
  /** Client instances keyed by project name. */
312
496
  const clientCache = new Map();
497
+ /**
498
+ * Where a project points when nobody said and no dev server is running.
499
+ * The last resort, applied after discovery — never written into the registry,
500
+ * because a stored value would then outrank the dev server discovery is for.
501
+ */
502
+ export const DEFAULT_BASE_URL = "http://localhost:3001";
313
503
  /** Get the active project config, with auto-discovery applied. */
314
504
  function getActiveProject() {
315
505
  const name = registry.activeProject || "default";
@@ -317,7 +507,8 @@ function getActiveProject() {
317
507
  if (!project) {
318
508
  throw new Error(`No active project configured. Use rebase_project_add to register one.`);
319
509
  }
320
- return autoDiscoverLocal(project);
510
+ const discovered = autoDiscoverLocal(project);
511
+ return discovered.baseUrl ? discovered : { ...discovered, baseUrl: DEFAULT_BASE_URL };
321
512
  }
322
513
  /** Get the project directory for the active project. */
323
514
  function getProjectDir() {
@@ -358,8 +549,13 @@ function clearClientCache() {
358
549
  * auditable at a glance and a new tool now arrives protected.
359
550
  */
360
551
  export const READ_ONLY_TOOLS = new Set([
361
- // CLI tools that only inspect the database
362
- "rebase_schema_introspect",
552
+ // `rebase_schema_plan` posts to `/api/admin/schema/plan`, the live schema
553
+ // editor's planner: it computes the statements and returns them, and
554
+ // `apply` is a different route. It used to be `db push --dry-run`, which
555
+ // was a read of the database and a *write* of three generated files into
556
+ // the repository — the half that made "applies nothing" untrue.
557
+ "rebase_schema_plan",
558
+ // CLI tools that only inspect the database.
363
559
  "rebase_doctor",
364
560
  "rebase_db_branch_list",
365
561
  "rebase_db_branch_info",
@@ -369,11 +565,11 @@ export const READ_ONLY_TOOLS = new Set([
369
565
  // Admin
370
566
  "list_users",
371
567
  "list_roles",
372
- // Storage. `storage_get_metadata` mints a signed URL, which is a bearer
568
+ // Storage. `storage_get_download_url` mints a signed URL, which is a bearer
373
569
  // capability rather than a plain read — see L2 in the unit-67 audit — but
374
570
  // it does not change the environment, so it belongs here.
375
571
  "storage_list_objects",
376
- "storage_get_metadata",
572
+ "storage_get_download_url",
377
573
  // Cron
378
574
  "cron_list_jobs",
379
575
  "cron_get_job",
@@ -392,6 +588,16 @@ export const READ_ONLY_TOOLS = new Set([
392
588
  * open question 2).
393
589
  */
394
590
  export const LOCAL_ONLY_TOOLS = new Set([
591
+ // `rebase_schema_introspect` reads the database and *writes collection
592
+ // definition files* into the project. It was classified as a read, which is
593
+ // half true and the wrong half: the half that matters lands on this machine,
594
+ // overwriting hand-written collection files with generated ones. Local-only
595
+ // is what it is.
596
+ "rebase_schema_introspect",
597
+ // `rebase_db_branch_switch` writes a branch pointer under `.rebase/` and
598
+ // never touches `.env` or the database. Like `rebase_project_switch`, it
599
+ // retargets everything else rather than acting on a target itself.
600
+ "rebase_db_branch_switch",
395
601
  "rebase_schema_generate",
396
602
  "rebase_db_generate",
397
603
  "rebase_generate_sdk",
@@ -629,7 +835,7 @@ const CLI_TOOLS = [
629
835
  },
630
836
  {
631
837
  name: "rebase_db_push",
632
- description: "Apply the current Drizzle schema directly to the database (development shortcut, skips migration files).",
838
+ description: "Apply the current Drizzle schema directly to the database (development shortcut, skips migration files). Refuses changes that destroy data — use rebase_schema_plan first, then ask the human to run `rebase db push --allow-destructive`.",
633
839
  inputSchema: { type: "object",
634
840
  properties: {} },
635
841
  cmd: ["db", "push"]
@@ -710,6 +916,22 @@ const CLI_TOOLS = [
710
916
  required: ["name"]
711
917
  },
712
918
  cmd: ["db", "branch", "info"]
919
+ },
920
+ {
921
+ // create/delete/info/list without switch meant an agent could make a
922
+ // branch it had no way to use: the only route onto one was hand-editing
923
+ // `DATABASE_URL`, which is not a thing to ask an assistant to do.
924
+ name: "rebase_db_branch_switch",
925
+ description: "Point this checkout at a database branch, or back at the main database (Admins only). " +
926
+ "With no name, reports which branch is active. Writes a local pointer, never `.env`.",
927
+ inputSchema: {
928
+ type: "object",
929
+ properties: {
930
+ name: { type: "string", description: "Branch to switch to. Omit to report the active branch." },
931
+ off: { type: "boolean", description: "Switch back to the main database instead of a branch." }
932
+ }
933
+ },
934
+ cmd: ["db", "branch", "switch"]
713
935
  }
714
936
  ];
715
937
  const DATA_TOOLS = [
@@ -922,8 +1144,8 @@ const STORAGE_TOOLS = [
922
1144
  }
923
1145
  },
924
1146
  {
925
- name: "storage_get_metadata",
926
- description: "Get metadata and a temporary signed download URL for a file in Rebase storage.",
1147
+ name: "storage_get_download_url",
1148
+ description: "Mint a temporary signed download URL for a file in Rebase storage. It returns the URL and its expiry, not object metadata — the URL is a bearer capability that outlives the tool call.",
927
1149
  inputSchema: {
928
1150
  type: "object",
929
1151
  properties: {
@@ -987,6 +1209,51 @@ const CRON_TOOLS = [
987
1209
  }
988
1210
  }
989
1211
  ];
1212
+ /**
1213
+ * Showing a schema change before making it.
1214
+ *
1215
+ * This was `rebase db push --dry-run`, and that was wrong twice over. `db push`
1216
+ * runs `schema generate` first, so a tool documented as "applies nothing" wrote
1217
+ * `backend/drizzle/schema.sql`, `backend/drizzle/policies.sql` and a rewritten
1218
+ * `backend/src/schema.generated.ts` into the repository on every call. And
1219
+ * `db push` plans with Atlas, which needs a second empty database — the managed
1220
+ * development database serves exactly one, so on the default scaffold the tool
1221
+ * that `rebase_db_push`'s own refusal points at exited 1.
1222
+ *
1223
+ * `POST /api/admin/schema/plan` is the live schema editor's planner: it builds
1224
+ * the statements with `generateSchemaCommit`, not Atlas, so it answers on
1225
+ * PGlite; it has no side effects by construction (the route's own comment says
1226
+ * so, and `apply` is a separate route); and it works over the backend the other
1227
+ * HTTP tools already talk to, so there is no project checkout to write into.
1228
+ *
1229
+ * The cost is that a plan now needs a subject. That is honest: `db push
1230
+ * --dry-run` diffed whatever the working tree happened to contain, which is not
1231
+ * a question an agent asked.
1232
+ */
1233
+ const SCHEMA_TOOLS = [
1234
+ {
1235
+ name: "rebase_schema_plan",
1236
+ description: "Show the SQL a collection change would run, without running any of it. Posts to " +
1237
+ "/api/admin/schema/plan — it changes nothing, writes no files, and works on the " +
1238
+ "managed development database. Read this before proposing a schema change: it names " +
1239
+ "every statement and marks the ones that destroy data.",
1240
+ inputSchema: {
1241
+ type: "object",
1242
+ properties: {
1243
+ collectionId: {
1244
+ type: "string",
1245
+ description: "The collection's id — the filename under config/collections/, without the extension."
1246
+ },
1247
+ collection: {
1248
+ type: "object",
1249
+ description: "The whole collection as it should be AFTER the edit, in the shape defineCollection takes.",
1250
+ additionalProperties: true
1251
+ }
1252
+ },
1253
+ required: ["collectionId", "collection"]
1254
+ }
1255
+ }
1256
+ ];
990
1257
  const FUNCTION_TOOLS = [
991
1258
  {
992
1259
  name: "invoke_function",
@@ -1058,6 +1325,7 @@ const PROJECT_TOOLS = [
1058
1325
  ];
1059
1326
  export const ALL_TOOLS = [
1060
1327
  ...CLI_TOOLS.map(({ cmd: _c, ...rest }) => rest),
1328
+ ...SCHEMA_TOOLS,
1061
1329
  ...DATA_TOOLS,
1062
1330
  ...ADMIN_TOOLS,
1063
1331
  ...DEV_TOOLS,
@@ -1070,7 +1338,15 @@ export const ALL_TOOLS = [
1070
1338
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
1071
1339
  tools: ALL_TOOLS
1072
1340
  }));
1073
- /** Spawn the rebase CLI using the project's detected package manager. */
1341
+ /**
1342
+ * Spawn the rebase CLI using the project's detected package manager.
1343
+ *
1344
+ * The exit code comes back with the output. It used to be folded into a string
1345
+ * — `Command exited with code 1\n\n…` — and handed over as an ordinary result,
1346
+ * so a failed migration and a successful one differed only in prose. MCP has
1347
+ * `isError` for exactly this, and a model that has to parse a sentence to learn
1348
+ * whether a command worked will eventually parse it wrong.
1349
+ */
1074
1350
  function runRebaseCmd(commandArgs) {
1075
1351
  const projectDir = getProjectDir();
1076
1352
  const pm = detectPackageManager(projectDir);
@@ -1089,17 +1365,83 @@ function runRebaseCmd(commandArgs) {
1089
1365
  const chunks = [];
1090
1366
  child.stdout?.on("data", (d) => chunks.push(d.toString()));
1091
1367
  child.stderr?.on("data", (d) => chunks.push(d.toString()));
1092
- child.on("error", (err) => resolve(`Error spawning command: ${err.message}`));
1368
+ child.on("error", (err) => resolve({ output: `Error spawning command: ${err.message}`, code: -1 }));
1093
1369
  child.on("close", (code) => {
1094
1370
  const output = chunks.join("").trim();
1095
- resolve(code !== 0 ? `Command exited with code ${code}\n\n${output}` : output || "(no output)");
1371
+ resolve({
1372
+ output: code !== 0 ? `Command exited with code ${code}\n\n${output}` : output || "(no output)",
1373
+ code: code ?? -1
1374
+ });
1096
1375
  });
1097
1376
  });
1098
1377
  }
1378
+ /**
1379
+ * The one refusal an agent cannot act on alone, spelled out.
1380
+ *
1381
+ * `db push` refuses a destructive change on a non-TTY, which every MCP call is,
1382
+ * and prints the plan while exiting 1. Without this the model sees a failure
1383
+ * with a flag buried in it and its next move is to find a way to pass the flag
1384
+ * — which is the wrong move: dropping a column is a decision, and the person
1385
+ * whose data it is has to make it. Naming the command *for the human* is what
1386
+ * turns a dead end into a handoff.
1387
+ */
1388
+ function destructiveRefusalHint(toolName, output) {
1389
+ if (toolName !== "rebase_db_push")
1390
+ return null;
1391
+ if (!/destructive changes require confirmation|--allow-destructive/.test(output))
1392
+ return null;
1393
+ return ("\n\nThis push was refused because it destroys data, and that is not yours to approve. " +
1394
+ "Show the planned SQL above (rebase_schema_plan prints one collection's statements " +
1395
+ "without running anything), " +
1396
+ "say which statements drop data, and ask the person you are working with to run:\n\n" +
1397
+ " rebase db backup\n" +
1398
+ " rebase db push --allow-destructive\n");
1399
+ }
1099
1400
  // Dev server management
1100
1401
  let devProcess = null;
1101
1402
  const devLogs = [];
1102
1403
  const MAX_DEV_LOG_LINES = 500;
1404
+ /** The last `count` lines of a blob of output, without its trailing blank. */
1405
+ export function lastLines(text, count) {
1406
+ if (!text)
1407
+ return "";
1408
+ const lines = text.replace(/\n$/, "").split("\n");
1409
+ return lines.slice(-Math.max(1, count)).join("\n");
1410
+ }
1411
+ /**
1412
+ * A dev server this process did not spawn, named rather than denied.
1413
+ *
1414
+ * `devProcess` is only the child `rebase_dev_start` made, so "Dev server is not
1415
+ * running." was what `rebase_dev_logs` and `rebase_dev_stop` answered while one
1416
+ * was running in a terminal — in the same session where
1417
+ * `rebase_project_current` was reporting that server's URL, discovered from the
1418
+ * same `.rebase/state.json` these two never read. The output belongs to the
1419
+ * terminal that started it, and so does the decision to stop it.
1420
+ *
1421
+ * @returns The sentence to answer with, or `null` when nothing is running.
1422
+ */
1423
+ export function describeForeignDevServer(action, state = undefined) {
1424
+ const project = (() => {
1425
+ try {
1426
+ return getActiveProject();
1427
+ }
1428
+ catch {
1429
+ return null;
1430
+ }
1431
+ })();
1432
+ const dir = project?.projectDir;
1433
+ const found = state !== undefined ? state : dir ? readDevState(dir) : null;
1434
+ if (!found)
1435
+ return null;
1436
+ const where = `${found.baseUrl}${found.pid ? ` (PID ${found.pid})` : ""}`;
1437
+ return action === "logs"
1438
+ ? `A dev server is running at ${where}, but this session did not start it — ` +
1439
+ "its output goes to the terminal that did. Only a server started with " +
1440
+ "`rebase_dev_start` has logs here."
1441
+ : `A dev server is running at ${where}, but this session did not start it. ` +
1442
+ "Stop it in the terminal that did (Ctrl-C); this tool only stops a server " +
1443
+ "`rebase_dev_start` spawned.";
1444
+ }
1103
1445
  function appendDevLog(line) {
1104
1446
  devLogs.push(line);
1105
1447
  if (devLogs.length > MAX_DEV_LOG_LINES) {
@@ -1136,9 +1478,84 @@ export function untrustedEnvelope(source, body) {
1136
1478
  function untrustedJsonResult(source, data) {
1137
1479
  return textResult(untrustedEnvelope(source, JSON.stringify(data, null, 2)));
1138
1480
  }
1481
+ /**
1482
+ * A tool error with the two facts a caller needs to act on it.
1483
+ *
1484
+ * `fetch failed` is what Node says when nothing is listening, and on its own it
1485
+ * is the least useful sentence in this file: it names no host, no port and no
1486
+ * next step. The agent's actual situation — nine times out of ten — is that
1487
+ * `rebase dev` is not running, and it holds the tool that starts it.
1488
+ *
1489
+ * The URL matters as much as the remedy. The active project is sticky and lives
1490
+ * outside the repository, so "which backend did it even try?" is a real
1491
+ * question, and answering it is how somebody notices they are pointed at the
1492
+ * wrong project rather than at a stopped one.
1493
+ */
1494
+ export function explainToolError(err, baseUrl, toolName) {
1495
+ const msg = err instanceof Error ? err.message : String(err);
1496
+ const url = baseUrl ?? safeActiveBaseUrl();
1497
+ const surface = toolName ? surfaceNotMounted(err, toolName) : null;
1498
+ if (surface)
1499
+ return surface;
1500
+ const networkish = /fetch failed|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|socket hang up|network error/i;
1501
+ if (!networkish.test(msg))
1502
+ return msg;
1503
+ const where = url ? ` while calling ${url}` : "";
1504
+ return (`${msg}${where}. Nothing answered there — is \`rebase dev\` running? ` +
1505
+ "Start it with `rebase_dev_start`, or check `rebase_project_current`: " +
1506
+ "the active project is sticky and lives outside your repository.");
1507
+ }
1508
+ /**
1509
+ * A whole API surface a project never turned on, told apart from a missing row.
1510
+ *
1511
+ * `surfaces.cron` gates the mount, so on a project with no cron jobs — which is
1512
+ * every fresh scaffold — `/api/admin/cron` does not exist and the client raises
1513
+ * a bare `Not Found`. Passed through, that is the least useful sentence
1514
+ * available: no URL, no cause, and nothing an agent can do next except guess
1515
+ * that a *job* is missing and go looking for one. What is missing is the
1516
+ * surface, and the way to get it is a file.
1517
+ *
1518
+ * Keyed by tool prefix rather than by URL because the client raises the error,
1519
+ * not this process, and the prefix is the one fact that is certainly here.
1520
+ */
1521
+ const SURFACE_BY_TOOL_PREFIX = [
1522
+ {
1523
+ // `cron_list_jobs` only, not every `cron_` tool. The row tools take a
1524
+ // `jobId`, and the server answers a missing job with the same 404 —
1525
+ // `Cron job "nightly" not found`. Mapped by prefix, that sentence was
1526
+ // replaced with "this project declares no cron jobs" on a project that
1527
+ // has five, which is a worse answer than the one it hid. A tool that
1528
+ // addresses no row is the only one whose 404 can only be the surface.
1529
+ tool: "cron_list_jobs",
1530
+ message: "this project declares no cron jobs, so the cron surface is not mounted — " +
1531
+ "`/api/admin/cron` does not exist on this backend. Add a job at " +
1532
+ "`backend/crons/<name>.ts`, default-exporting a `CronJobDefinition`, and restart " +
1533
+ "`rebase dev`. (An older backend answers 404 here even when jobs exist; a current " +
1534
+ "one answers with an empty list.)"
1535
+ }
1536
+ ];
1537
+ function surfaceNotMounted(err, toolName) {
1538
+ const status = err?.status;
1539
+ const msg = err instanceof Error ? err.message : String(err);
1540
+ // `status` when the client supplied one; the message is the fallback for a
1541
+ // client old enough not to carry it.
1542
+ if (status !== 404 && !/^not found$/i.test(msg.trim()))
1543
+ return null;
1544
+ const surface = SURFACE_BY_TOOL_PREFIX.find((s) => s.tool === toolName);
1545
+ return surface ? surface.message : null;
1546
+ }
1547
+ /** The active project's baseUrl, or undefined if even that cannot be resolved. */
1548
+ function safeActiveBaseUrl() {
1549
+ try {
1550
+ return getActiveProject().baseUrl;
1551
+ }
1552
+ catch {
1553
+ return undefined;
1554
+ }
1555
+ }
1139
1556
  /** Raw text from the target environment (CLI stdout, dev-server logs), marked as untrusted. */
1140
- function untrustedTextResult(source, text) {
1141
- return textResult(untrustedEnvelope(source, text));
1557
+ function untrustedTextResult(source, text, isError = false) {
1558
+ return { ...textResult(untrustedEnvelope(source, text)), ...(isError ? { isError: true } : {}) };
1142
1559
  }
1143
1560
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
1144
1561
  try {
@@ -1168,11 +1585,52 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1168
1585
  const argsObj = args;
1169
1586
  cmdArgs.push(assertValidBranchName(argsObj.name, "name"));
1170
1587
  }
1171
- const result = await runRebaseCmd(cmdArgs);
1172
- return untrustedTextResult(`the "${name}" CLI command`, result);
1588
+ else if (name === "rebase_db_branch_switch") {
1589
+ // Both optional, and mutually exclusive in effect: with neither the
1590
+ // command reports the active branch, which is the question asked
1591
+ // most often.
1592
+ const argsObj = args;
1593
+ if (argsObj.off)
1594
+ cmdArgs.push("--off");
1595
+ else if (argsObj.name)
1596
+ cmdArgs.push(assertValidBranchName(argsObj.name, "name"));
1597
+ }
1598
+ const { output, code } = await runRebaseCmd(cmdArgs);
1599
+ const hint = destructiveRefusalHint(name, output);
1600
+ return untrustedTextResult(`the "${name}" CLI command`, output + (hint ?? ""), code !== 0);
1173
1601
  }
1174
1602
  // ── Project management tools ────────────────────────────────────────
1175
1603
  switch (name) {
1604
+ case "rebase_schema_plan": {
1605
+ const argsObj = args;
1606
+ const project = getActiveProject();
1607
+ const res = await fetch(`${project.baseUrl}/api/admin/schema/plan`, {
1608
+ method: "POST",
1609
+ headers: {
1610
+ "Content-Type": "application/json",
1611
+ ...(project.token ? { Authorization: `Bearer ${project.token}` } : {})
1612
+ },
1613
+ body: JSON.stringify({
1614
+ collectionId: argsObj.collectionId,
1615
+ collection: argsObj.collection
1616
+ })
1617
+ });
1618
+ const body = await res.text();
1619
+ if (!res.ok) {
1620
+ // 501 is the one refusal worth translating: the surface is
1621
+ // mounted but the server was started without a collections
1622
+ // directory or a repository, so there is nothing to plan
1623
+ // against. Every other status carries the server's own message,
1624
+ // which is written for a person.
1625
+ const hint = res.status === 501
1626
+ ? "\n\nThis backend was started without `collectionsDir` or `liveSchema.repository`, " +
1627
+ "so it has no collection source to plan against. `rebase dev` supplies one; a " +
1628
+ "deployment running from a built bundle does not."
1629
+ : "";
1630
+ return untrustedTextResult(`POST ${project.baseUrl}/api/admin/schema/plan`, `HTTP ${res.status}\n${body}${hint}`, true);
1631
+ }
1632
+ return untrustedJsonResult(`POST ${project.baseUrl}/api/admin/schema/plan`, JSON.parse(body));
1633
+ }
1176
1634
  case "rebase_project_list": {
1177
1635
  const projects = Object.values(registry.projects).map((p) => ({
1178
1636
  name: p.name,
@@ -1222,7 +1680,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1222
1680
  }
1223
1681
  }
1224
1682
  if (!baseUrl) {
1225
- return textResult("Error: Could not determine baseUrl. Provide --baseUrl or ensure the dev server is running in the project directory.");
1683
+ // `--baseUrl` is a CLI flag, and this is not a CLI. The
1684
+ // model was being told to pass an option this tool has no
1685
+ // notion of, in a message it could not act on.
1686
+ return {
1687
+ ...textResult(`Cannot register "${projectName}": no baseUrl. Pass \`baseUrl\` ` +
1688
+ '(for example "http://localhost:3001"), or pass a `projectDir` ' +
1689
+ "where `rebase dev` is running so it can be discovered from " +
1690
+ "`.rebase/state.json`."),
1691
+ isError: true
1692
+ };
1226
1693
  }
1227
1694
  registry.projects[projectName] = {
1228
1695
  name: projectName,
@@ -1231,6 +1698,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1231
1698
  token,
1232
1699
  addedAt: new Date().toISOString()
1233
1700
  };
1701
+ // Registering one *by name* is the operator saying it out loud, so
1702
+ // it is no longer derived and `saveRegistry` must write it. Without
1703
+ // this, `rebase_project_add` with the name "default" answered
1704
+ // "registered" and persisted nothing at all.
1705
+ if (projectName === "default")
1706
+ defaultIsDerived = false;
1234
1707
  saveRegistry();
1235
1708
  return jsonResult({
1236
1709
  message: `Project "${projectName}" registered`,
@@ -1419,7 +1892,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1419
1892
  await client.storage.deleteObject(key, bucket);
1420
1893
  return textResult(`Deleted object "${key}" successfully.`);
1421
1894
  }
1422
- case "storage_get_metadata": {
1895
+ case "storage_get_download_url": {
1423
1896
  const argsObj = args;
1424
1897
  const { key, bucket } = argsObj;
1425
1898
  const result = await client.storage.getSignedUrl(key, bucket);
@@ -1428,6 +1901,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1428
1901
  // ── Cron Tools ─────────────────────────────────────────────────────
1429
1902
  case "cron_list_jobs": {
1430
1903
  const result = await client.cron.listJobs();
1904
+ // An empty list is the same situation as an unmounted surface, and
1905
+ // `{ "jobs": [] }` on its own reads as a failure to an agent that
1906
+ // asked what runs on a schedule. Say what a job is made of.
1907
+ if (!result?.jobs?.length) {
1908
+ return textResult("This project declares no cron jobs. Add one at `backend/crons/<name>.ts`, " +
1909
+ "default-exporting a `CronJobDefinition`, and restart `rebase dev` — the " +
1910
+ "filename becomes the job id.");
1911
+ }
1431
1912
  return untrustedJsonResult("the cron scheduler", result);
1432
1913
  }
1433
1914
  case "cron_get_job": {
@@ -1493,15 +1974,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1493
1974
  case "rebase_dev_logs": {
1494
1975
  const argsObj = args;
1495
1976
  const lineCount = argsObj?.lines ?? 50;
1496
- const recent = devLogs.slice(-lineCount);
1497
- if (recent.length === 0) {
1498
- return textResult(devProcess ? "No output captured yet." : "Dev server is not running.");
1977
+ // `devLogs` holds stdout *chunks*, not lines: one `data` event can
1978
+ // carry a hundred lines or half of one. Slicing the chunk array by
1979
+ // `lines` therefore returned an amount of output unrelated to the
1980
+ // number asked for — usually far more — while the tool description
1981
+ // and the docs both promised lines.
1982
+ const recent = lastLines(devLogs.join(""), lineCount);
1983
+ if (!recent) {
1984
+ if (devProcess)
1985
+ return textResult("No output captured yet.");
1986
+ return textResult(describeForeignDevServer("logs") ?? "Dev server is not running.");
1499
1987
  }
1500
- return untrustedTextResult("the dev server's output", recent.join(""));
1988
+ return untrustedTextResult("the dev server's output", recent);
1501
1989
  }
1502
1990
  case "rebase_dev_stop": {
1503
1991
  if (!devProcess || devProcess.killed) {
1504
- return textResult("Dev server is not running.");
1992
+ return textResult(describeForeignDevServer("stop") ?? "Dev server is not running.");
1505
1993
  }
1506
1994
  devProcess.kill("SIGTERM");
1507
1995
  return textResult("Dev server stopped.");
@@ -1511,11 +1999,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1511
1999
  }
1512
2000
  }
1513
2001
  catch (err) {
1514
- const msg = err instanceof Error ? err.message : String(err);
1515
2002
  return {
1516
2003
  content: [{
1517
2004
  type: "text",
1518
- text: `Error: ${msg}`
2005
+ text: `Error: ${explainToolError(err, undefined, request.params.name)}`
1519
2006
  }],
1520
2007
  isError: true
1521
2008
  };
@@ -1647,7 +2134,40 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
1647
2134
  throw new Error(`Unknown resource: ${uri}`);
1648
2135
  });
1649
2136
  // ── Start ───────────────────────────────────────────────────────────────────
2137
+ /**
2138
+ * `--help` and `--version`, answered before anything connects.
2139
+ *
2140
+ * A stdio MCP server run by hand is the normal way to check that a config block
2141
+ * works, and this one answered `--version` by opening a transport and waiting
2142
+ * for a client that was never coming — a hang with no output, which reads as a
2143
+ * broken install rather than as a server doing exactly what it was told.
2144
+ *
2145
+ * @returns true when the process has answered and should exit.
2146
+ */
2147
+ export function answerCliFlags(argv = process.argv.slice(2)) {
2148
+ if (argv.includes("--version") || argv.includes("-v")) {
2149
+ process.stdout.write(`${MCP_SERVER_VERSION}\n`);
2150
+ return true;
2151
+ }
2152
+ if (argv.includes("--help") || argv.includes("-h")) {
2153
+ process.stdout.write("rebase-mcp — the Rebase MCP server\n\n" +
2154
+ "It speaks MCP over stdio and has no other interface: an MCP client\n" +
2155
+ "spawns it, and there is nothing to run interactively.\n\n" +
2156
+ " npx -y @rebasepro/mcp\n\n" +
2157
+ "Environment\n" +
2158
+ " REBASE_PROJECT_DIR the directory holding rebase.json\n" +
2159
+ " REBASE_BASE_URL backend URL (default http://localhost:3001)\n" +
2160
+ " REBASE_API_TOKEN a scoped rk_ API key, or a service key\n" +
2161
+ " REBASE_MCP_ALLOW_REMOTE_WRITES allow write tools off the loopback interface\n\n" +
2162
+ "Setup blocks for Claude Code, Cursor, Gemini CLI, Codex and Kiro:\n" +
2163
+ " https://rebase.pro/docs/ai/mcp\n");
2164
+ return true;
2165
+ }
2166
+ return false;
2167
+ }
1650
2168
  async function main() {
2169
+ if (answerCliFlags())
2170
+ return;
1651
2171
  const transport = new StdioServerTransport();
1652
2172
  await server.connect(transport);
1653
2173
  }