autorouter-mcp 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -364,11 +364,40 @@ autorouter adopt --target claude --servers-only # skip skills and plugins
364
364
  autorouter adopt --target claude --keep project-tools --keep-plugin ui-toolkit
365
365
  autorouter restore --target claude # undo the most recent adopt
366
366
 
367
+ autorouter update # upgrade in place
368
+ autorouter update --check # is there a newer version?
369
+
367
370
  autorouter login # which servers need a grant
368
371
  autorouter login remote-server # authorize one (opens a browser)
372
+ autorouter login remote-server --device # headless: enter a code elsewhere
369
373
  autorouter logout remote-server # forget a stored grant
370
374
  ```
371
375
 
376
+ ## Updating
377
+
378
+ `autorouter update` upgrades in place, using the package manager that installed
379
+ the copy you are running:
380
+
381
+ ```sh
382
+ autorouter update # detect, then upgrade
383
+ autorouter update --check # report the available version, install nothing
384
+ autorouter update --dry-run # print the command, do not run it
385
+ ```
386
+
387
+ Which manager to use is decided by where the running file sits on disk, not by
388
+ what happens to be on `PATH` — running `npm i -g` against a pnpm-managed global
389
+ installs a second copy that shadows the first, and you would then be upgrading
390
+ one install while running the other.
391
+
392
+ Three cases do not run a package manager, and say so instead: a copy unpacked by
393
+ `npx`/`bunx`/`pnpm dlx` (nothing to upgrade — those refetch every run), a git
394
+ checkout (`git pull && bun install && bun run build`), and a layout it cannot
395
+ identify. All three still report whether a newer version exists.
396
+
397
+ The router is a long-lived stdio server, so a harness that already has it
398
+ running keeps the old build until it respawns it — restart the harness, or
399
+ reconnect the MCP server, after updating.
400
+
372
401
  ## OAuth servers
373
402
 
374
403
  Some remote MCP servers carry no credentials in their visible configuration.
@@ -391,6 +420,35 @@ fixed port (33418, `--port` or `$AUTOROUTER_OAUTH_PORT` to change it) because th
391
420
  redirect URI is baked into the registration a provider stores — a grant obtained
392
421
  on one port cannot be refreshed from another.
393
422
 
423
+ ### Headless machines
424
+
425
+ The browser flow cannot work over SSH: it binds a loopback listener and expects
426
+ a browser on the same host to redirect into it. Two flows replace it, and on a
427
+ box with no display autorouter picks one of them by itself rather than timing
428
+ out waiting for a browser that was never going to open.
429
+
430
+ ```sh
431
+ autorouter login remote-server --device # RFC 8628: enter a code on your phone
432
+ autorouter login remote-server --manual # paste the redirect URL back
433
+ ```
434
+
435
+ `--device` is the better one where the provider offers it. Nothing has to reach
436
+ back into the machine: it prints a short code and a URL, you enter them in a
437
+ browser on any other device, and the headless side polls until you are done.
438
+ Detection is the `device_authorization_endpoint` in the provider's metadata.
439
+
440
+ `--manual` is the fallback for providers that do not implement RFC 8628. It
441
+ prints the authorization URL, you open it elsewhere, and the browser is then
442
+ redirected to `http://localhost:33418/callback`, which will not load — nothing
443
+ is listening. That is expected: copy the URL out of the address bar and paste it
444
+ back. Pasting the whole URL is worth preferring over just the code, because the
445
+ `state` in it is what proves the code came from the login you started.
446
+
447
+ Neither flow binds a port. A device login registers a client that also works for
448
+ a later browser login from the same machine, so nothing has to be redone if the
449
+ box grows a display. Set `AUTOROUTER_ASSUME_HEADLESS=1` to force the detection
450
+ on a machine where it guesses wrong.
451
+
394
452
  ### Choosing permissions
395
453
 
396
454
  A dynamically registered client may default to every scope the provider
package/dist/cli.js CHANGED
@@ -18556,6 +18556,7 @@ async function clearAuth(server) {
18556
18556
  async function hasAuth(server) {
18557
18557
  return Boolean((await readAuth(server)).tokens?.access_token);
18558
18558
  }
18559
+ var DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
18559
18560
  var CLIENT_METADATA = {
18560
18561
  client_name: "autorouter",
18561
18562
  client_uri: "https://github.com/rileywebb/autorouter",
@@ -18583,6 +18584,12 @@ class FileTokenStore {
18583
18584
  get clientMetadata() {
18584
18585
  return { ...CLIENT_METADATA, redirect_uris: [this.redirectUrl] };
18585
18586
  }
18587
+ get deviceClientMetadata() {
18588
+ return {
18589
+ ...this.clientMetadata,
18590
+ grant_types: [...CLIENT_METADATA.grant_types, DEVICE_GRANT_TYPE]
18591
+ };
18592
+ }
18586
18593
  async state() {
18587
18594
  const value = randomUUID();
18588
18595
  await writeAuth(this.server, { state: value });
@@ -19467,6 +19474,18 @@ function run(command, args, opts = {}) {
19467
19474
  child.stdin.end();
19468
19475
  });
19469
19476
  }
19477
+ function runStreaming(command, args, opts = {}) {
19478
+ return new Promise((resolve, reject) => {
19479
+ const child = spawn2(command, args, {
19480
+ stdio: "inherit",
19481
+ cwd: opts.cwd,
19482
+ env: opts.env,
19483
+ shell: process.platform === "win32"
19484
+ });
19485
+ child.on("error", reject);
19486
+ child.on("close", (code) => resolve(code));
19487
+ });
19488
+ }
19470
19489
  function which(command) {
19471
19490
  const isWindows = process.platform === "win32";
19472
19491
  const exts = isWindows ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean) : [""];
@@ -22063,6 +22082,166 @@ function truncate2(s, max) {
22063
22082
  import { createServer } from "node:http";
22064
22083
  import { spawn as spawn3 } from "node:child_process";
22065
22084
 
22085
+ // src/cli/device.ts
22086
+ function deviceEndpoint(metadata) {
22087
+ const endpoint = metadata?.device_authorization_endpoint;
22088
+ return typeof endpoint === "string" && endpoint ? endpoint : undefined;
22089
+ }
22090
+ function looksHeadless(env = process.env) {
22091
+ if (env.AUTOROUTER_ASSUME_HEADLESS === "1")
22092
+ return true;
22093
+ if (process.platform === "darwin" || process.platform === "win32")
22094
+ return false;
22095
+ return !env.DISPLAY && !env.WAYLAND_DISPLAY;
22096
+ }
22097
+ function authenticateClient(client, metadata, headers, params) {
22098
+ const method = selectClientAuthMethod(client, metadata?.token_endpoint_auth_methods_supported ?? []);
22099
+ const secret = "client_secret" in client ? client.client_secret : undefined;
22100
+ if (method === "client_secret_basic" && secret) {
22101
+ headers.set("Authorization", `Basic ${btoa(`${client.client_id}:${secret}`)}`);
22102
+ return;
22103
+ }
22104
+ params.set("client_id", client.client_id);
22105
+ if (method === "client_secret_post" && secret)
22106
+ params.set("client_secret", secret);
22107
+ }
22108
+ async function errorCode(res) {
22109
+ const text = await res.text().catch(() => "");
22110
+ try {
22111
+ const body = JSON.parse(text);
22112
+ if (body.error)
22113
+ return { code: body.error, description: body.error_description };
22114
+ } catch {}
22115
+ return { code: `http_${res.status}`, description: text.slice(0, 200) || undefined };
22116
+ }
22117
+ async function runDeviceFlow(opts) {
22118
+ const log = opts.log ?? ((line) => console.log(line));
22119
+ const doFetch = opts.fetchFn ?? fetch;
22120
+ const info = opts.info ?? await discoverOAuthServerInfo(opts.url, { fetchFn: doFetch });
22121
+ const metadata = info.authorizationServerMetadata;
22122
+ const endpoint = deviceEndpoint(metadata);
22123
+ if (!endpoint) {
22124
+ return {
22125
+ ok: false,
22126
+ message: `${opts.server} does not advertise a device authorization endpoint, so RFC 8628 is not ` + `available for it.
22127
+ ` + ` Authorize from a machine with a browser, or use the paste-the-code flow:
22128
+ ` + ` autorouter login ${opts.server} --manual`
22129
+ };
22130
+ }
22131
+ const store = new FileTokenStore(opts.server, opts.port ?? CALLBACK_PORT, () => {
22132
+ throw new Error("unreachable: the device grant does not redirect");
22133
+ });
22134
+ const resource = await selectResourceURL(opts.url, store, info.resourceMetadata);
22135
+ let client = (await readAuth(opts.server)).clientInformation;
22136
+ const registeredForDevice = (await readAuth(opts.server)).deviceClient === true;
22137
+ if (!client || !registeredForDevice) {
22138
+ if (!metadata?.registration_endpoint && !client) {
22139
+ return {
22140
+ ok: false,
22141
+ message: `${opts.server} supports the device grant but not dynamic client registration, so it ` + `needs an OAuth app you register yourself.
22142
+ ` + ` Then: autorouter login ${opts.server} --device --client-id <id> [--client-secret <secret>]`
22143
+ };
22144
+ }
22145
+ if (metadata?.registration_endpoint) {
22146
+ try {
22147
+ client = await registerClient(info.authorizationServerUrl, {
22148
+ metadata,
22149
+ clientMetadata: store.deviceClientMetadata,
22150
+ scope: opts.scope,
22151
+ fetchFn: doFetch
22152
+ });
22153
+ await writeAuth(opts.server, { clientInformation: client, deviceClient: true });
22154
+ } catch (err) {
22155
+ if (!client) {
22156
+ return { ok: false, message: `${opts.server}: client registration failed — ${message(err)}` };
22157
+ }
22158
+ }
22159
+ }
22160
+ }
22161
+ if (!client)
22162
+ return { ok: false, message: `${opts.server}: no OAuth client available.` };
22163
+ const deviceParams = new URLSearchParams;
22164
+ const deviceHeaders = new Headers({ "content-type": "application/x-www-form-urlencoded" });
22165
+ authenticateClient(client, metadata, deviceHeaders, deviceParams);
22166
+ if (opts.scope)
22167
+ deviceParams.set("scope", opts.scope);
22168
+ if (resource)
22169
+ deviceParams.set("resource", resource.href);
22170
+ const deviceRes = await doFetch(endpoint, {
22171
+ method: "POST",
22172
+ headers: deviceHeaders,
22173
+ body: deviceParams
22174
+ });
22175
+ if (!deviceRes.ok) {
22176
+ const { code, description } = await errorCode(deviceRes);
22177
+ return {
22178
+ ok: false,
22179
+ message: `${opts.server}: the device authorization request was refused (${code})` + (description ? ` — ${description}` : "") + (code === "unauthorized_client" ? `
22180
+ The provider may not allow this grant for dynamically registered clients.
22181
+ ` + ` Try: autorouter login ${opts.server} --manual` : "")
22182
+ };
22183
+ }
22184
+ const grant = await deviceRes.json();
22185
+ if (!grant.device_code || !grant.user_code || !grant.verification_uri) {
22186
+ return { ok: false, message: `${opts.server}: the device authorization response was incomplete.` };
22187
+ }
22188
+ log(`
22189
+ To authorize ${opts.server}, open this on any device with a browser:
22190
+
22191
+ ` + ` ${grant.verification_uri_complete ?? grant.verification_uri}
22192
+
22193
+ ` + ` and enter the code: ${grant.user_code}
22194
+ ` + (grant.verification_uri_complete ? ` (that link has the code filled in; the plain URL is ${grant.verification_uri})
22195
+ ` : "") + `
22196
+ Waiting for you to finish${grant.expires_in ? ` — the code expires in ${Math.round(grant.expires_in / 60)} min` : ""}…`);
22197
+ let intervalMs = opts.pollIntervalMs ?? (grant.interval ?? 5) * 1000;
22198
+ const bumpMs = opts.pollIntervalMs ? 0 : 5000;
22199
+ const deadline = Date.now() + (grant.expires_in ?? 900) * 1000;
22200
+ while (Date.now() < deadline) {
22201
+ await sleep(intervalMs);
22202
+ const params = new URLSearchParams;
22203
+ const headers = new Headers({ "content-type": "application/x-www-form-urlencoded" });
22204
+ authenticateClient(client, metadata, headers, params);
22205
+ params.set("grant_type", DEVICE_GRANT_TYPE);
22206
+ params.set("device_code", grant.device_code);
22207
+ if (resource)
22208
+ params.set("resource", resource.href);
22209
+ const res = await doFetch(metadata.token_endpoint, { method: "POST", headers, body: params });
22210
+ if (res.ok) {
22211
+ const tokens = await res.json();
22212
+ await writeAuth(opts.server, {
22213
+ tokens,
22214
+ ...opts.scope ? { requestedScope: opts.scope } : {}
22215
+ });
22216
+ return { ok: true, message: "", grantedScope: tokens.scope ?? opts.scope };
22217
+ }
22218
+ const { code, description } = await errorCode(res);
22219
+ if (code === "authorization_pending")
22220
+ continue;
22221
+ if (code === "slow_down") {
22222
+ intervalMs += bumpMs;
22223
+ continue;
22224
+ }
22225
+ if (code === "access_denied") {
22226
+ return { ok: false, message: `${opts.server}: authorization was denied.` };
22227
+ }
22228
+ if (code === "expired_token") {
22229
+ return { ok: false, message: `${opts.server}: the code expired before it was entered. Run the command again.` };
22230
+ }
22231
+ return {
22232
+ ok: false,
22233
+ message: `${opts.server}: token request failed (${code})${description ? ` — ${description}` : ""}`
22234
+ };
22235
+ }
22236
+ return { ok: false, message: `${opts.server}: timed out waiting for the code to be entered.` };
22237
+ }
22238
+ function sleep(ms) {
22239
+ return new Promise((res) => setTimeout(res, ms));
22240
+ }
22241
+ function message(err) {
22242
+ return err instanceof Error ? err.message : String(err);
22243
+ }
22244
+
22066
22245
  // src/config/scopes.ts
22067
22246
  var MUTATING = /(^|[:._\-\/])(write|admin|manage|delete|destroy|create|update|modify|readwrite|rw|full|all)([:._\-\/]|$)/i;
22068
22247
  function isReadOnlyScope(scope) {
@@ -22110,6 +22289,9 @@ async function runLogin(opts) {
22110
22289
  Scopes: ${previousScope.split(/\s+/).join(", ")}` : "")
22111
22290
  };
22112
22291
  }
22292
+ if (opts.device && opts.manual) {
22293
+ return { ok: false, message: "--device and --manual are different flows; pick one." };
22294
+ }
22113
22295
  const port = opts.port ?? CALLBACK_PORT;
22114
22296
  if (opts.clientId) {
22115
22297
  await setClientInformation(entry.name, {
@@ -22117,12 +22299,67 @@ async function runLogin(opts) {
22117
22299
  ...opts.clientSecret ? { client_secret: opts.clientSecret } : {}
22118
22300
  });
22119
22301
  }
22120
- return await authorize(entry, port, {
22302
+ const want = {
22121
22303
  scopes: opts.scopes,
22122
22304
  readOnly: opts.readOnly,
22123
22305
  allScopes: opts.allScopes,
22124
22306
  previous: previousScope
22307
+ };
22308
+ const mode = await resolveMode(entry, opts);
22309
+ if (mode.kind === "device") {
22310
+ const chosen = chooseScopes(mode.advertised, want);
22311
+ if ("error" in chosen)
22312
+ return { ok: false, message: `${entry.name}: ${chosen.error}` };
22313
+ if (mode.announce)
22314
+ console.log(mode.announce);
22315
+ const result = await runDeviceFlow({
22316
+ server: entry.name,
22317
+ url: entry.url,
22318
+ scope: chosen.scope,
22319
+ info: mode.info,
22320
+ port
22321
+ });
22322
+ if (!result.ok)
22323
+ return result;
22324
+ return {
22325
+ ok: true,
22326
+ message: `${entry.name}: authorized.` + (result.grantedScope ? `
22327
+ ${summarizeScopes(result.grantedScope)}` : "")
22328
+ };
22329
+ }
22330
+ if (mode.announce)
22331
+ console.log(mode.announce);
22332
+ return await authorize(entry, port, want, mode.kind);
22333
+ }
22334
+ async function resolveMode(entry, opts) {
22335
+ if (opts.manual)
22336
+ return { kind: "manual" };
22337
+ if (opts.device) {
22338
+ const info = await discoverOAuthServerInfo(entry.url).catch(() => {
22339
+ return;
22340
+ });
22341
+ return { kind: "device", info, advertised: advertisedFrom(info) };
22342
+ }
22343
+ if (!looksHeadless())
22344
+ return { kind: "browser" };
22345
+ const info = await discoverOAuthServerInfo(entry.url).catch(() => {
22346
+ return;
22125
22347
  });
22348
+ if (info && deviceEndpoint(info.authorizationServerMetadata)) {
22349
+ return {
22350
+ kind: "device",
22351
+ info,
22352
+ advertised: advertisedFrom(info),
22353
+ announce: `No display detected, so this is using the device flow instead of opening a browser.
22354
+ ` + ` Pass --manual to paste a redirect URL instead, or --port with X11/SSH forwarding to
22355
+ ` + ` use the browser flow anyway.`
22356
+ };
22357
+ }
22358
+ return {
22359
+ kind: "manual",
22360
+ announce: `No display detected, and ${entry.name} does not offer the device flow — falling back to
22361
+ ` + ` the paste-the-code flow. Open the URL below on any machine with a browser.`
22362
+ };
22126
22363
  }
22127
22364
  async function describeScopes(entry) {
22128
22365
  const advertised = await advertisedScopes(entry.url);
@@ -22144,6 +22381,9 @@ ${lines.join(`
22144
22381
  ` : "") + ` autorouter login ${entry.name} --scopes "${(ro.length ? ro : advertised).slice(0, 2).join(",")}"`
22145
22382
  };
22146
22383
  }
22384
+ function advertisedFrom(info) {
22385
+ return requestableScopes(info?.resourceMetadata?.scopes_supported, info?.authorizationServerMetadata?.scopes_supported);
22386
+ }
22147
22387
  async function advertisedScopes(url) {
22148
22388
  try {
22149
22389
  const info = await discoverOAuthServerInfo(url);
@@ -22152,7 +22392,7 @@ async function advertisedScopes(url) {
22152
22392
  return [];
22153
22393
  }
22154
22394
  }
22155
- async function authorize(entry, port, want) {
22395
+ async function authorize(entry, port, want, mode = "browser") {
22156
22396
  let pendingState;
22157
22397
  let resolveCode;
22158
22398
  let rejectCode;
@@ -22174,12 +22414,14 @@ async function authorize(entry, port, want) {
22174
22414
  else
22175
22415
  resolveCode(code);
22176
22416
  });
22177
- await new Promise((res, rej) => {
22178
- server.once("error", rej);
22179
- server.listen(port, "127.0.0.1", res);
22180
- }).catch((err) => {
22181
- throw err.code === "EADDRINUSE" ? new Error(`Port ${port} is in use. Pass --port to pick another (it must stay the same across logins).`) : err;
22182
- });
22417
+ if (mode === "browser") {
22418
+ await new Promise((res, rej) => {
22419
+ server.once("error", rej);
22420
+ server.listen(port, "127.0.0.1", res);
22421
+ }).catch((err) => {
22422
+ throw err.code === "EADDRINUSE" ? new Error(`Port ${port} is in use. Pass --port to pick another (it must stay the same across logins).`) : err;
22423
+ });
22424
+ }
22183
22425
  let advertised = [];
22184
22426
  let canRegister = true;
22185
22427
  try {
@@ -22203,6 +22445,18 @@ async function authorize(entry, port, want) {
22203
22445
  };
22204
22446
  }
22205
22447
  const provider = new FileTokenStore(entry.name, port, (url) => {
22448
+ if (mode === "manual") {
22449
+ console.log(`
22450
+ Open this on any machine with a browser and authorize ${entry.name}:
22451
+
22452
+ ${url}
22453
+
22454
+ ` + `The browser will then be redirected to ${`http://localhost:${port}/callback`}, which
22455
+ ` + `will not load — that is expected, nothing is listening there. Copy the full URL out of
22456
+ ` + `the address bar and paste it below.
22457
+ `);
22458
+ return;
22459
+ }
22206
22460
  console.log(`
22207
22461
  Opening your browser to authorize ${entry.name}:
22208
22462
  ${url}
@@ -22215,7 +22469,7 @@ Opening your browser to authorize ${entry.name}:
22215
22469
  return { ok: true, message: `${entry.name}: already authorized (existing grant is still valid).` };
22216
22470
  }
22217
22471
  pendingState = (await readAuth(entry.name)).state;
22218
- const code = await withTimeout2(codePromise, 5 * 60000, "waiting for the browser callback");
22472
+ const code = mode === "manual" ? await readPastedCode(pendingState) : await withTimeout2(codePromise, 5 * 60000, "waiting for the browser callback");
22219
22473
  const result = await auth(provider, { serverUrl: entry.url, authorizationCode: code, scope });
22220
22474
  if (result !== "AUTHORIZED") {
22221
22475
  return { ok: false, message: `${entry.name}: token exchange did not complete (${result}).` };
@@ -22246,6 +22500,40 @@ function evaluateCallback(params, expectedState) {
22246
22500
  failure: description ? `${error ?? "error"}: ${description}` : error ?? "no code returned"
22247
22501
  };
22248
22502
  }
22503
+ async function readPastedCode(expectedState) {
22504
+ const { createInterface } = await import("node:readline/promises");
22505
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
22506
+ try {
22507
+ for (let attempt = 0;attempt < 3; attempt++) {
22508
+ const answer = (await rl.question("Paste the full redirect URL (or just the code): ")).trim();
22509
+ if (!answer)
22510
+ continue;
22511
+ const parsed = parsePastedRedirect(answer, expectedState);
22512
+ if ("code" in parsed)
22513
+ return parsed.code;
22514
+ console.log(` ${parsed.error}`);
22515
+ }
22516
+ throw new Error("no usable authorization code was pasted");
22517
+ } finally {
22518
+ rl.close();
22519
+ }
22520
+ }
22521
+ function parsePastedRedirect(input, expectedState) {
22522
+ const trimmed = input.trim();
22523
+ if (!/^https?:\/\//i.test(trimmed)) {
22524
+ if (/[\s?&]/.test(trimmed))
22525
+ return { error: "that does not look like a URL or a code — try again" };
22526
+ return { code: trimmed };
22527
+ }
22528
+ let params;
22529
+ try {
22530
+ params = new URL(trimmed).searchParams;
22531
+ } catch {
22532
+ return { error: "that URL could not be parsed — paste the whole address bar" };
22533
+ }
22534
+ const { code, failure } = evaluateCallback(params, expectedState);
22535
+ return code ? { code } : { error: failure ?? "no code in that URL" };
22536
+ }
22249
22537
  function openBrowser(url) {
22250
22538
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
22251
22539
  try {
@@ -22289,7 +22577,7 @@ function chooseScopes(advertised, want) {
22289
22577
  if (!ro.length) {
22290
22578
  return {
22291
22579
  error: `no read-only scopes are offered (available: ${advertised.join(", ")}).
22292
- ` + ` Authorize with full access, or pick explicitly with --scopes.`
22580
+ Authorize with full access, or pick explicitly with --scopes.`
22293
22581
  };
22294
22582
  }
22295
22583
  return { scope: ro.join(" ") };
@@ -22306,11 +22594,227 @@ function summarizeScopes(scope, limit = 6) {
22306
22594
  if (all.length <= limit) {
22307
22595
  return `scope: ${all.join(", ")}${writes.length ? "" : " (read-only)"}`;
22308
22596
  }
22309
- return `scope: ${all.length} granted, ${writes.length} of them write ` + `(${writes.slice(0, 3).join(", ") || "none"}${writes.length > 3 ? ", …" : ""})`;
22597
+ return `scope: ${all.length} granted, ${writes.length} of them write (${writes.slice(0, 3).join(", ") || "none"}${writes.length > 3 ? ", …" : ""})`;
22598
+ }
22599
+
22600
+ // src/cli/update.ts
22601
+ import { existsSync, realpathSync } from "node:fs";
22602
+ import { fileURLToPath } from "node:url";
22603
+ import { dirname as dirname3, join as join18, resolve as resolve2, sep } from "node:path";
22604
+ var PACKAGE_NAME = "autorouter-mcp";
22605
+ function entryPath() {
22606
+ let raw;
22607
+ try {
22608
+ raw = fileURLToPath(import.meta.url);
22609
+ } catch {
22610
+ raw = process.argv[1] ?? "";
22611
+ }
22612
+ try {
22613
+ return realpathSync(raw);
22614
+ } catch {
22615
+ return raw;
22616
+ }
22617
+ }
22618
+ function packageRoot(from, exists) {
22619
+ let dir = dirname3(from);
22620
+ for (let i = 0;i < 12; i++) {
22621
+ if (exists(join18(dir, "package.json")))
22622
+ return dir;
22623
+ const parent = dirname3(dir);
22624
+ if (parent === dir)
22625
+ break;
22626
+ dir = parent;
22627
+ }
22628
+ return;
22629
+ }
22630
+ function managerFromLockfile(dir, exists) {
22631
+ if (exists(join18(dir, "bun.lock")) || exists(join18(dir, "bun.lockb")))
22632
+ return "bun";
22633
+ if (exists(join18(dir, "pnpm-lock.yaml")))
22634
+ return "pnpm";
22635
+ if (exists(join18(dir, "yarn.lock")))
22636
+ return "yarn";
22637
+ return "npm";
22638
+ }
22639
+ function detectInstall(entry, exists = existsSync) {
22640
+ const root = packageRoot(entry, exists);
22641
+ if (!root)
22642
+ return { kind: "unknown", dir: dirname3(entry) };
22643
+ const path = root.split(sep).join("/");
22644
+ if (exists(join18(root, ".git")))
22645
+ return { kind: "source", dir: root };
22646
+ const transient = path.includes("/_npx/") && "npx" || /\/dlx(-|\/)/.test(path) && "pnpm dlx" || path.includes("/.bun/install/cache/") && "bunx" || null;
22647
+ if (transient)
22648
+ return { kind: "transient", runner: transient, dir: root };
22649
+ const nodeModules = dirname3(root);
22650
+ const parent = dirname3(nodeModules);
22651
+ const globalManager = path.includes("/.bun/install/global/") ? "bun" : /\/pnpm\/global\/|\/pnpm\/[0-9]+\/node_modules\//.test(path) ? "pnpm" : /\/\.config\/yarn\/global\/|\/\.yarn\/global\//.test(path) ? "yarn" : null;
22652
+ if (globalManager)
22653
+ return { kind: "global", manager: globalManager, dir: root };
22654
+ if (nodeModules.split(sep).pop() === "node_modules") {
22655
+ if (exists(join18(parent, "package.json"))) {
22656
+ return {
22657
+ kind: "project",
22658
+ manager: managerFromLockfile(parent, exists),
22659
+ dir: root,
22660
+ projectDir: parent
22661
+ };
22662
+ }
22663
+ return { kind: "global", manager: "npm", dir: root };
22664
+ }
22665
+ return { kind: "unknown", dir: root };
22666
+ }
22667
+ function updateCommand(install, pkg, version = "latest") {
22668
+ const spec = `${pkg}@${version}`;
22669
+ if (install.kind === "global") {
22670
+ switch (install.manager) {
22671
+ case "bun":
22672
+ return ["bun", "add", "-g", spec];
22673
+ case "pnpm":
22674
+ return ["pnpm", "add", "-g", spec];
22675
+ case "yarn":
22676
+ return ["yarn", "global", "add", spec];
22677
+ default:
22678
+ return ["npm", "install", "-g", spec];
22679
+ }
22680
+ }
22681
+ if (install.kind === "project") {
22682
+ switch (install.manager) {
22683
+ case "bun":
22684
+ return ["bun", "add", spec];
22685
+ case "pnpm":
22686
+ return ["pnpm", "add", spec];
22687
+ case "yarn":
22688
+ return ["yarn", "add", spec];
22689
+ default:
22690
+ return ["npm", "install", spec];
22691
+ }
22692
+ }
22693
+ return null;
22694
+ }
22695
+ function compareVersions2(a, b) {
22696
+ const split = (v) => {
22697
+ const [core = "", pre] = v.replace(/^v/, "").split("-", 2);
22698
+ const parts = core.split(".").map((n) => Number.parseInt(n, 10) || 0);
22699
+ return { parts, pre };
22700
+ };
22701
+ const x = split(a);
22702
+ const y = split(b);
22703
+ for (let i = 0;i < 3; i++) {
22704
+ const d = (x.parts[i] ?? 0) - (y.parts[i] ?? 0);
22705
+ if (d !== 0)
22706
+ return d < 0 ? -1 : 1;
22707
+ }
22708
+ if (x.pre && !y.pre)
22709
+ return -1;
22710
+ if (!x.pre && y.pre)
22711
+ return 1;
22712
+ if (x.pre && y.pre && x.pre !== y.pre)
22713
+ return x.pre < y.pre ? -1 : 1;
22714
+ return 0;
22715
+ }
22716
+ async function latestVersion(pkg, fetchFn = fetch) {
22717
+ const base = (process.env.npm_config_registry ?? process.env.NPM_CONFIG_REGISTRY ?? "https://registry.npmjs.org").replace(/\/+$/, "");
22718
+ try {
22719
+ const res = await fetchFn(`${base}/${encodeURIComponent(pkg)}/latest`, {
22720
+ headers: { accept: "application/json" },
22721
+ signal: AbortSignal.timeout(1e4)
22722
+ });
22723
+ if (!res.ok)
22724
+ return { error: `registry returned ${res.status}` };
22725
+ const body = await res.json();
22726
+ return body.version ? { version: body.version } : { error: "registry returned no version" };
22727
+ } catch (err) {
22728
+ return { error: err instanceof Error ? err.message : String(err) };
22729
+ }
22730
+ }
22731
+ function cannotUpdate(install, pkg) {
22732
+ if (install.kind === "transient") {
22733
+ return `This copy was unpacked by ${install.runner}, which fetches the package fresh on every run —
22734
+ ` + ` there is no install here to upgrade. You are already getting the latest each time.
22735
+ ` + ` To keep a copy that does not re-download: npm install -g ${pkg}`;
22736
+ }
22737
+ if (install.kind === "source") {
22738
+ return `This is a checkout running from source (${install.dir}), not a package install.
22739
+ ` + ` Update it with: git -C ${install.dir} pull && bun install && bun run build`;
22740
+ }
22741
+ return `Could not tell which package manager installed this copy (${install.dir}).
22742
+ ` + ` Upgrade it the way you installed it, e.g. npm install -g ${pkg}@latest`;
22743
+ }
22744
+ function describe2(install) {
22745
+ switch (install.kind) {
22746
+ case "global":
22747
+ return `${install.manager} global install at ${install.dir}`;
22748
+ case "project":
22749
+ return `${install.manager} dependency of ${install.projectDir}`;
22750
+ case "transient":
22751
+ return `${install.runner} temporary copy at ${install.dir}`;
22752
+ case "source":
22753
+ return `source checkout at ${install.dir}`;
22754
+ default:
22755
+ return `unrecognized install at ${install.dir}`;
22756
+ }
22757
+ }
22758
+ async function runUpdate(opts) {
22759
+ const entry = opts.entry ?? entryPath();
22760
+ const install = detectInstall(entry, opts.exists);
22761
+ const manifest = install.kind === "unknown" ? null : await readJson(join18(install.dir, "package.json"));
22762
+ const pkg = manifest?.name ?? PACKAGE_NAME;
22763
+ const latest = await latestVersion(pkg, opts.fetchFn);
22764
+ if ("error" in latest) {
22765
+ return { ok: false, message: `Could not reach the registry to check for updates: ${latest.error}` };
22766
+ }
22767
+ const behind = compareVersions2(opts.current, latest.version) < 0;
22768
+ const status = behind ? `autorouter ${opts.current} → ${latest.version} available` : `autorouter ${opts.current} is up to date (latest is ${latest.version})`;
22769
+ const command = updateCommand(install, pkg, latest.version);
22770
+ if (!command) {
22771
+ return { ok: !behind, message: `${status}
22772
+
22773
+ ${cannotUpdate(install, pkg)}` };
22774
+ }
22775
+ const printable = command.join(" ");
22776
+ if (opts.check) {
22777
+ return { ok: true, message: `${status}
22778
+ ${describe2(install)}
22779
+ Update with: ${printable}` };
22780
+ }
22781
+ if (!behind && !opts.force) {
22782
+ return { ok: true, message: `${status}
22783
+ ${describe2(install)}
22784
+ Re-install anyway with: --force` };
22785
+ }
22786
+ if (opts.dryRun) {
22787
+ return { ok: true, message: `${status}
22788
+ ${describe2(install)}
22789
+ Would run: ${printable}` };
22790
+ }
22791
+ console.log(`${status}
22792
+ ${describe2(install)}
22793
+ Running: ${printable}
22794
+ `);
22795
+ const code = await runStreaming(command[0], command.slice(1), {
22796
+ cwd: install.kind === "project" ? install.projectDir : undefined
22797
+ }).catch((err) => err);
22798
+ if (code instanceof Error) {
22799
+ return {
22800
+ ok: false,
22801
+ message: `Could not run ${command[0]}: ${code.message}
22802
+ ` + ` Run it yourself: ${printable}`
22803
+ };
22804
+ }
22805
+ if (code !== 0) {
22806
+ return { ok: false, message: `${command[0]} exited with code ${code}. Nothing was changed by autorouter.` };
22807
+ }
22808
+ return {
22809
+ ok: true,
22810
+ message: `
22811
+ Updated to ${latest.version}. Restart any harness with the router running to pick it up` + `
22812
+ (it is a long-lived stdio server, so an open session keeps the old build).`
22813
+ };
22310
22814
  }
22311
22815
 
22312
22816
  // src/cli.ts
22313
- var VERSION2 = "0.2.4";
22817
+ var VERSION2 = "0.2.6";
22314
22818
  var USAGE = `autorouter — one search tool instead of every tool
22315
22819
 
22316
22820
  autorouter serve Run as an MCP server over stdio (default)
@@ -22320,7 +22824,11 @@ var USAGE = `autorouter — one search tool instead of every tool
22320
22824
  autorouter list [--kind K] List everything in the catalog
22321
22825
  autorouter reindex Rebuild the catalog now
22322
22826
  autorouter doctor Show what is reachable and what it saves
22323
- autorouter login [server] Authorize an OAuth server (opens a browser);
22827
+ autorouter update Upgrade via the package manager that
22828
+ installed this copy. --check to look
22829
+ without installing.
22830
+ autorouter login [server] Authorize an OAuth server (opens a browser,
22831
+ or prints a code on a headless box);
22324
22832
  with no argument, lists what needs one
22325
22833
  autorouter logout <server> Forget a stored grant
22326
22834
  autorouter add <name> --url URL Register a server with the router directly
@@ -22346,6 +22854,8 @@ Options
22346
22854
  --json machine-readable output
22347
22855
  --yes init/adopt: do not prompt
22348
22856
  --dry-run adopt: show what would move, change nothing
22857
+ update: print the upgrade command without running it
22858
+ --check update: report the available version, install nothing
22349
22859
  --force adopt: proceed even if a server is unreachable
22350
22860
  --keep S adopt: leave server S registered in the harness (comma-separated)
22351
22861
  --keep-skill S adopt: leave skill S loaded (comma-separated)
@@ -22360,6 +22870,10 @@ Options
22360
22870
  any narrowing the previous grant carried
22361
22871
  --scopes S login: request exactly these scopes (comma or space separated)
22362
22872
  --list-scopes login: show what the server offers, authorize nothing
22873
+ --device login: RFC 8628 — print a code to enter on another device and
22874
+ poll for the result. No browser or open port needed here.
22875
+ --manual login: print the authorization URL, then read the redirect you
22876
+ paste back. Works where the provider has no device endpoint.
22363
22877
 
22364
22878
  \`add\` registers behind the router, so a new server never enters your context.
22365
22879
  Servers added to a harness the normal way (\`claude mcp add\`) are moved behind
@@ -22397,6 +22911,17 @@ async function main(argv) {
22397
22911
  case "doctor":
22398
22912
  console.log(await runDoctor(process.cwd()));
22399
22913
  return 0;
22914
+ case "update":
22915
+ case "upgrade": {
22916
+ const result = await runUpdate({
22917
+ current: VERSION2,
22918
+ check: Boolean(flags.check),
22919
+ dryRun: Boolean(flags["dry-run"]),
22920
+ force: Boolean(flags.force)
22921
+ });
22922
+ console.log(result.message);
22923
+ return result.ok ? 0 : 1;
22924
+ }
22400
22925
  case "init":
22401
22926
  return await cmdInit(flags);
22402
22927
  case "login":
@@ -22481,6 +23006,8 @@ async function cmdLogin(server, flags) {
22481
23006
  };
22482
23007
  }));
22483
23008
  console.log(`Usage: autorouter login <server> [--read-only | --all-scopes | --scopes a,b]
23009
+ `);
23010
+ console.log(` On a machine with no browser, add --device (or --manual).
22484
23011
  `);
22485
23012
  for (const s of states) {
22486
23013
  const scope = s.ok && s.scope ? ` ${summarizeScopes(s.scope)}` : "";
@@ -22506,7 +23033,9 @@ ${pending.length} need a grant; each is a separate authorization:`);
22506
23033
  scopes: flags.scopes,
22507
23034
  readOnly: Boolean(flags["read-only"]),
22508
23035
  allScopes: Boolean(flags["all-scopes"]),
22509
- listScopes: Boolean(flags["list-scopes"])
23036
+ listScopes: Boolean(flags["list-scopes"]),
23037
+ device: Boolean(flags.device),
23038
+ manual: Boolean(flags.manual)
22510
23039
  });
22511
23040
  console.log(result.message);
22512
23041
  if (result.ok && !flags["list-scopes"]) {
@@ -22797,7 +23326,7 @@ function parseArgs(argv) {
22797
23326
  command = `--${name}`;
22798
23327
  continue;
22799
23328
  }
22800
- const boolean = ["raw", "json", "yes", "dry-run", "force", "servers-only", "read-only", "all-scopes", "list-scopes"].includes(name) && !(name === "json" && command === "add");
23329
+ const boolean = ["raw", "json", "yes", "dry-run", "force", "servers-only", "read-only", "all-scopes", "list-scopes", "device", "manual", "check"].includes(name) && !(name === "json" && command === "add");
22801
23330
  if (boolean) {
22802
23331
  flags[name] = true;
22803
23332
  } else if (inline !== undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autorouter-mcp",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "One search tool instead of every tool: an MCP capability router for Claude Code, Codex, Cursor and anything else that speaks MCP.",
5
5
  "mcpName": "io.github.Webb-Ventures/autorouter",
6
6
  "license": "MIT",
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "io.github.Webb-Ventures/autorouter",
4
4
  "title": "autorouter",
5
5
  "description": "An MCP capability router: one search tool instead of every server's tool schema.",
6
- "version": "0.2.4",
6
+ "version": "0.2.6",
7
7
  "websiteUrl": "https://github.com/Webb-Ventures/autorouter",
8
8
  "repository": {
9
9
  "url": "https://github.com/Webb-Ventures/autorouter",
@@ -14,7 +14,7 @@
14
14
  "registryType": "npm",
15
15
  "registryBaseUrl": "https://registry.npmjs.org",
16
16
  "identifier": "autorouter-mcp",
17
- "version": "0.2.4",
17
+ "version": "0.2.6",
18
18
  "transport": { "type": "stdio" },
19
19
  "packageArguments": [{ "type": "positional", "value": "serve" }],
20
20
  "environmentVariables": [