@rebasepro/mcp 0.17.3 → 0.18.1
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 +103 -52
- package/dist/index.d.ts +83 -1
- package/dist/index.js +561 -41
- package/dist/index.js.map +1 -1
- package/package.json +33 -10
- package/dist/index.d.ts.map +0 -1
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(
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
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
|
-
|
|
290
|
-
if (!registry.projects["default"]) {
|
|
291
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
362
|
-
|
|
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. `
|
|
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
|
-
"
|
|
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: "
|
|
926
|
-
description: "
|
|
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
|
-
/**
|
|
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(
|
|
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
|
-
|
|
1172
|
-
|
|
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
|
-
|
|
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 "
|
|
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
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
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
|
|
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: ${
|
|
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
|
}
|