autorouter-mcp 0.2.4 → 0.2.5
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 +30 -0
- package/dist/cli.js +300 -15
- package/package.json +1 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -366,6 +366,7 @@ autorouter restore --target claude # undo the most recent adopt
|
|
|
366
366
|
|
|
367
367
|
autorouter login # which servers need a grant
|
|
368
368
|
autorouter login remote-server # authorize one (opens a browser)
|
|
369
|
+
autorouter login remote-server --device # headless: enter a code elsewhere
|
|
369
370
|
autorouter logout remote-server # forget a stored grant
|
|
370
371
|
```
|
|
371
372
|
|
|
@@ -391,6 +392,35 @@ fixed port (33418, `--port` or `$AUTOROUTER_OAUTH_PORT` to change it) because th
|
|
|
391
392
|
redirect URI is baked into the registration a provider stores — a grant obtained
|
|
392
393
|
on one port cannot be refreshed from another.
|
|
393
394
|
|
|
395
|
+
### Headless machines
|
|
396
|
+
|
|
397
|
+
The browser flow cannot work over SSH: it binds a loopback listener and expects
|
|
398
|
+
a browser on the same host to redirect into it. Two flows replace it, and on a
|
|
399
|
+
box with no display autorouter picks one of them by itself rather than timing
|
|
400
|
+
out waiting for a browser that was never going to open.
|
|
401
|
+
|
|
402
|
+
```sh
|
|
403
|
+
autorouter login remote-server --device # RFC 8628: enter a code on your phone
|
|
404
|
+
autorouter login remote-server --manual # paste the redirect URL back
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
`--device` is the better one where the provider offers it. Nothing has to reach
|
|
408
|
+
back into the machine: it prints a short code and a URL, you enter them in a
|
|
409
|
+
browser on any other device, and the headless side polls until you are done.
|
|
410
|
+
Detection is the `device_authorization_endpoint` in the provider's metadata.
|
|
411
|
+
|
|
412
|
+
`--manual` is the fallback for providers that do not implement RFC 8628. It
|
|
413
|
+
prints the authorization URL, you open it elsewhere, and the browser is then
|
|
414
|
+
redirected to `http://localhost:33418/callback`, which will not load — nothing
|
|
415
|
+
is listening. That is expected: copy the URL out of the address bar and paste it
|
|
416
|
+
back. Pasting the whole URL is worth preferring over just the code, because the
|
|
417
|
+
`state` in it is what proves the code came from the login you started.
|
|
418
|
+
|
|
419
|
+
Neither flow binds a port. A device login registers a client that also works for
|
|
420
|
+
a later browser login from the same machine, so nothing has to be redone if the
|
|
421
|
+
box grows a display. Set `AUTOROUTER_ASSUME_HEADLESS=1` to force the detection
|
|
422
|
+
on a machine where it guesses wrong.
|
|
423
|
+
|
|
394
424
|
### Choosing permissions
|
|
395
425
|
|
|
396
426
|
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 });
|
|
@@ -22063,6 +22070,166 @@ function truncate2(s, max) {
|
|
|
22063
22070
|
import { createServer } from "node:http";
|
|
22064
22071
|
import { spawn as spawn3 } from "node:child_process";
|
|
22065
22072
|
|
|
22073
|
+
// src/cli/device.ts
|
|
22074
|
+
function deviceEndpoint(metadata) {
|
|
22075
|
+
const endpoint = metadata?.device_authorization_endpoint;
|
|
22076
|
+
return typeof endpoint === "string" && endpoint ? endpoint : undefined;
|
|
22077
|
+
}
|
|
22078
|
+
function looksHeadless(env = process.env) {
|
|
22079
|
+
if (env.AUTOROUTER_ASSUME_HEADLESS === "1")
|
|
22080
|
+
return true;
|
|
22081
|
+
if (process.platform === "darwin" || process.platform === "win32")
|
|
22082
|
+
return false;
|
|
22083
|
+
return !env.DISPLAY && !env.WAYLAND_DISPLAY;
|
|
22084
|
+
}
|
|
22085
|
+
function authenticateClient(client, metadata, headers, params) {
|
|
22086
|
+
const method = selectClientAuthMethod(client, metadata?.token_endpoint_auth_methods_supported ?? []);
|
|
22087
|
+
const secret = "client_secret" in client ? client.client_secret : undefined;
|
|
22088
|
+
if (method === "client_secret_basic" && secret) {
|
|
22089
|
+
headers.set("Authorization", `Basic ${btoa(`${client.client_id}:${secret}`)}`);
|
|
22090
|
+
return;
|
|
22091
|
+
}
|
|
22092
|
+
params.set("client_id", client.client_id);
|
|
22093
|
+
if (method === "client_secret_post" && secret)
|
|
22094
|
+
params.set("client_secret", secret);
|
|
22095
|
+
}
|
|
22096
|
+
async function errorCode(res) {
|
|
22097
|
+
const text = await res.text().catch(() => "");
|
|
22098
|
+
try {
|
|
22099
|
+
const body = JSON.parse(text);
|
|
22100
|
+
if (body.error)
|
|
22101
|
+
return { code: body.error, description: body.error_description };
|
|
22102
|
+
} catch {}
|
|
22103
|
+
return { code: `http_${res.status}`, description: text.slice(0, 200) || undefined };
|
|
22104
|
+
}
|
|
22105
|
+
async function runDeviceFlow(opts) {
|
|
22106
|
+
const log = opts.log ?? ((line) => console.log(line));
|
|
22107
|
+
const doFetch = opts.fetchFn ?? fetch;
|
|
22108
|
+
const info = opts.info ?? await discoverOAuthServerInfo(opts.url, { fetchFn: doFetch });
|
|
22109
|
+
const metadata = info.authorizationServerMetadata;
|
|
22110
|
+
const endpoint = deviceEndpoint(metadata);
|
|
22111
|
+
if (!endpoint) {
|
|
22112
|
+
return {
|
|
22113
|
+
ok: false,
|
|
22114
|
+
message: `${opts.server} does not advertise a device authorization endpoint, so RFC 8628 is not ` + `available for it.
|
|
22115
|
+
` + ` Authorize from a machine with a browser, or use the paste-the-code flow:
|
|
22116
|
+
` + ` autorouter login ${opts.server} --manual`
|
|
22117
|
+
};
|
|
22118
|
+
}
|
|
22119
|
+
const store = new FileTokenStore(opts.server, opts.port ?? CALLBACK_PORT, () => {
|
|
22120
|
+
throw new Error("unreachable: the device grant does not redirect");
|
|
22121
|
+
});
|
|
22122
|
+
const resource = await selectResourceURL(opts.url, store, info.resourceMetadata);
|
|
22123
|
+
let client = (await readAuth(opts.server)).clientInformation;
|
|
22124
|
+
const registeredForDevice = (await readAuth(opts.server)).deviceClient === true;
|
|
22125
|
+
if (!client || !registeredForDevice) {
|
|
22126
|
+
if (!metadata?.registration_endpoint && !client) {
|
|
22127
|
+
return {
|
|
22128
|
+
ok: false,
|
|
22129
|
+
message: `${opts.server} supports the device grant but not dynamic client registration, so it ` + `needs an OAuth app you register yourself.
|
|
22130
|
+
` + ` Then: autorouter login ${opts.server} --device --client-id <id> [--client-secret <secret>]`
|
|
22131
|
+
};
|
|
22132
|
+
}
|
|
22133
|
+
if (metadata?.registration_endpoint) {
|
|
22134
|
+
try {
|
|
22135
|
+
client = await registerClient(info.authorizationServerUrl, {
|
|
22136
|
+
metadata,
|
|
22137
|
+
clientMetadata: store.deviceClientMetadata,
|
|
22138
|
+
scope: opts.scope,
|
|
22139
|
+
fetchFn: doFetch
|
|
22140
|
+
});
|
|
22141
|
+
await writeAuth(opts.server, { clientInformation: client, deviceClient: true });
|
|
22142
|
+
} catch (err) {
|
|
22143
|
+
if (!client) {
|
|
22144
|
+
return { ok: false, message: `${opts.server}: client registration failed — ${message(err)}` };
|
|
22145
|
+
}
|
|
22146
|
+
}
|
|
22147
|
+
}
|
|
22148
|
+
}
|
|
22149
|
+
if (!client)
|
|
22150
|
+
return { ok: false, message: `${opts.server}: no OAuth client available.` };
|
|
22151
|
+
const deviceParams = new URLSearchParams;
|
|
22152
|
+
const deviceHeaders = new Headers({ "content-type": "application/x-www-form-urlencoded" });
|
|
22153
|
+
authenticateClient(client, metadata, deviceHeaders, deviceParams);
|
|
22154
|
+
if (opts.scope)
|
|
22155
|
+
deviceParams.set("scope", opts.scope);
|
|
22156
|
+
if (resource)
|
|
22157
|
+
deviceParams.set("resource", resource.href);
|
|
22158
|
+
const deviceRes = await doFetch(endpoint, {
|
|
22159
|
+
method: "POST",
|
|
22160
|
+
headers: deviceHeaders,
|
|
22161
|
+
body: deviceParams
|
|
22162
|
+
});
|
|
22163
|
+
if (!deviceRes.ok) {
|
|
22164
|
+
const { code, description } = await errorCode(deviceRes);
|
|
22165
|
+
return {
|
|
22166
|
+
ok: false,
|
|
22167
|
+
message: `${opts.server}: the device authorization request was refused (${code})` + (description ? ` — ${description}` : "") + (code === "unauthorized_client" ? `
|
|
22168
|
+
The provider may not allow this grant for dynamically registered clients.
|
|
22169
|
+
` + ` Try: autorouter login ${opts.server} --manual` : "")
|
|
22170
|
+
};
|
|
22171
|
+
}
|
|
22172
|
+
const grant = await deviceRes.json();
|
|
22173
|
+
if (!grant.device_code || !grant.user_code || !grant.verification_uri) {
|
|
22174
|
+
return { ok: false, message: `${opts.server}: the device authorization response was incomplete.` };
|
|
22175
|
+
}
|
|
22176
|
+
log(`
|
|
22177
|
+
To authorize ${opts.server}, open this on any device with a browser:
|
|
22178
|
+
|
|
22179
|
+
` + ` ${grant.verification_uri_complete ?? grant.verification_uri}
|
|
22180
|
+
|
|
22181
|
+
` + ` and enter the code: ${grant.user_code}
|
|
22182
|
+
` + (grant.verification_uri_complete ? ` (that link has the code filled in; the plain URL is ${grant.verification_uri})
|
|
22183
|
+
` : "") + `
|
|
22184
|
+
Waiting for you to finish${grant.expires_in ? ` — the code expires in ${Math.round(grant.expires_in / 60)} min` : ""}…`);
|
|
22185
|
+
let intervalMs = opts.pollIntervalMs ?? (grant.interval ?? 5) * 1000;
|
|
22186
|
+
const bumpMs = opts.pollIntervalMs ? 0 : 5000;
|
|
22187
|
+
const deadline = Date.now() + (grant.expires_in ?? 900) * 1000;
|
|
22188
|
+
while (Date.now() < deadline) {
|
|
22189
|
+
await sleep(intervalMs);
|
|
22190
|
+
const params = new URLSearchParams;
|
|
22191
|
+
const headers = new Headers({ "content-type": "application/x-www-form-urlencoded" });
|
|
22192
|
+
authenticateClient(client, metadata, headers, params);
|
|
22193
|
+
params.set("grant_type", DEVICE_GRANT_TYPE);
|
|
22194
|
+
params.set("device_code", grant.device_code);
|
|
22195
|
+
if (resource)
|
|
22196
|
+
params.set("resource", resource.href);
|
|
22197
|
+
const res = await doFetch(metadata.token_endpoint, { method: "POST", headers, body: params });
|
|
22198
|
+
if (res.ok) {
|
|
22199
|
+
const tokens = await res.json();
|
|
22200
|
+
await writeAuth(opts.server, {
|
|
22201
|
+
tokens,
|
|
22202
|
+
...opts.scope ? { requestedScope: opts.scope } : {}
|
|
22203
|
+
});
|
|
22204
|
+
return { ok: true, message: "", grantedScope: tokens.scope ?? opts.scope };
|
|
22205
|
+
}
|
|
22206
|
+
const { code, description } = await errorCode(res);
|
|
22207
|
+
if (code === "authorization_pending")
|
|
22208
|
+
continue;
|
|
22209
|
+
if (code === "slow_down") {
|
|
22210
|
+
intervalMs += bumpMs;
|
|
22211
|
+
continue;
|
|
22212
|
+
}
|
|
22213
|
+
if (code === "access_denied") {
|
|
22214
|
+
return { ok: false, message: `${opts.server}: authorization was denied.` };
|
|
22215
|
+
}
|
|
22216
|
+
if (code === "expired_token") {
|
|
22217
|
+
return { ok: false, message: `${opts.server}: the code expired before it was entered. Run the command again.` };
|
|
22218
|
+
}
|
|
22219
|
+
return {
|
|
22220
|
+
ok: false,
|
|
22221
|
+
message: `${opts.server}: token request failed (${code})${description ? ` — ${description}` : ""}`
|
|
22222
|
+
};
|
|
22223
|
+
}
|
|
22224
|
+
return { ok: false, message: `${opts.server}: timed out waiting for the code to be entered.` };
|
|
22225
|
+
}
|
|
22226
|
+
function sleep(ms) {
|
|
22227
|
+
return new Promise((res) => setTimeout(res, ms));
|
|
22228
|
+
}
|
|
22229
|
+
function message(err) {
|
|
22230
|
+
return err instanceof Error ? err.message : String(err);
|
|
22231
|
+
}
|
|
22232
|
+
|
|
22066
22233
|
// src/config/scopes.ts
|
|
22067
22234
|
var MUTATING = /(^|[:._\-\/])(write|admin|manage|delete|destroy|create|update|modify|readwrite|rw|full|all)([:._\-\/]|$)/i;
|
|
22068
22235
|
function isReadOnlyScope(scope) {
|
|
@@ -22110,6 +22277,9 @@ async function runLogin(opts) {
|
|
|
22110
22277
|
Scopes: ${previousScope.split(/\s+/).join(", ")}` : "")
|
|
22111
22278
|
};
|
|
22112
22279
|
}
|
|
22280
|
+
if (opts.device && opts.manual) {
|
|
22281
|
+
return { ok: false, message: "--device and --manual are different flows; pick one." };
|
|
22282
|
+
}
|
|
22113
22283
|
const port = opts.port ?? CALLBACK_PORT;
|
|
22114
22284
|
if (opts.clientId) {
|
|
22115
22285
|
await setClientInformation(entry.name, {
|
|
@@ -22117,12 +22287,67 @@ async function runLogin(opts) {
|
|
|
22117
22287
|
...opts.clientSecret ? { client_secret: opts.clientSecret } : {}
|
|
22118
22288
|
});
|
|
22119
22289
|
}
|
|
22120
|
-
|
|
22290
|
+
const want = {
|
|
22121
22291
|
scopes: opts.scopes,
|
|
22122
22292
|
readOnly: opts.readOnly,
|
|
22123
22293
|
allScopes: opts.allScopes,
|
|
22124
22294
|
previous: previousScope
|
|
22295
|
+
};
|
|
22296
|
+
const mode = await resolveMode(entry, opts);
|
|
22297
|
+
if (mode.kind === "device") {
|
|
22298
|
+
const chosen = chooseScopes(mode.advertised, want);
|
|
22299
|
+
if ("error" in chosen)
|
|
22300
|
+
return { ok: false, message: `${entry.name}: ${chosen.error}` };
|
|
22301
|
+
if (mode.announce)
|
|
22302
|
+
console.log(mode.announce);
|
|
22303
|
+
const result = await runDeviceFlow({
|
|
22304
|
+
server: entry.name,
|
|
22305
|
+
url: entry.url,
|
|
22306
|
+
scope: chosen.scope,
|
|
22307
|
+
info: mode.info,
|
|
22308
|
+
port
|
|
22309
|
+
});
|
|
22310
|
+
if (!result.ok)
|
|
22311
|
+
return result;
|
|
22312
|
+
return {
|
|
22313
|
+
ok: true,
|
|
22314
|
+
message: `${entry.name}: authorized.` + (result.grantedScope ? `
|
|
22315
|
+
${summarizeScopes(result.grantedScope)}` : "")
|
|
22316
|
+
};
|
|
22317
|
+
}
|
|
22318
|
+
if (mode.announce)
|
|
22319
|
+
console.log(mode.announce);
|
|
22320
|
+
return await authorize(entry, port, want, mode.kind);
|
|
22321
|
+
}
|
|
22322
|
+
async function resolveMode(entry, opts) {
|
|
22323
|
+
if (opts.manual)
|
|
22324
|
+
return { kind: "manual" };
|
|
22325
|
+
if (opts.device) {
|
|
22326
|
+
const info = await discoverOAuthServerInfo(entry.url).catch(() => {
|
|
22327
|
+
return;
|
|
22328
|
+
});
|
|
22329
|
+
return { kind: "device", info, advertised: advertisedFrom(info) };
|
|
22330
|
+
}
|
|
22331
|
+
if (!looksHeadless())
|
|
22332
|
+
return { kind: "browser" };
|
|
22333
|
+
const info = await discoverOAuthServerInfo(entry.url).catch(() => {
|
|
22334
|
+
return;
|
|
22125
22335
|
});
|
|
22336
|
+
if (info && deviceEndpoint(info.authorizationServerMetadata)) {
|
|
22337
|
+
return {
|
|
22338
|
+
kind: "device",
|
|
22339
|
+
info,
|
|
22340
|
+
advertised: advertisedFrom(info),
|
|
22341
|
+
announce: `No display detected, so this is using the device flow instead of opening a browser.
|
|
22342
|
+
` + ` Pass --manual to paste a redirect URL instead, or --port with X11/SSH forwarding to
|
|
22343
|
+
` + ` use the browser flow anyway.`
|
|
22344
|
+
};
|
|
22345
|
+
}
|
|
22346
|
+
return {
|
|
22347
|
+
kind: "manual",
|
|
22348
|
+
announce: `No display detected, and ${entry.name} does not offer the device flow — falling back to
|
|
22349
|
+
` + ` the paste-the-code flow. Open the URL below on any machine with a browser.`
|
|
22350
|
+
};
|
|
22126
22351
|
}
|
|
22127
22352
|
async function describeScopes(entry) {
|
|
22128
22353
|
const advertised = await advertisedScopes(entry.url);
|
|
@@ -22144,6 +22369,9 @@ ${lines.join(`
|
|
|
22144
22369
|
` : "") + ` autorouter login ${entry.name} --scopes "${(ro.length ? ro : advertised).slice(0, 2).join(",")}"`
|
|
22145
22370
|
};
|
|
22146
22371
|
}
|
|
22372
|
+
function advertisedFrom(info) {
|
|
22373
|
+
return requestableScopes(info?.resourceMetadata?.scopes_supported, info?.authorizationServerMetadata?.scopes_supported);
|
|
22374
|
+
}
|
|
22147
22375
|
async function advertisedScopes(url) {
|
|
22148
22376
|
try {
|
|
22149
22377
|
const info = await discoverOAuthServerInfo(url);
|
|
@@ -22152,7 +22380,7 @@ async function advertisedScopes(url) {
|
|
|
22152
22380
|
return [];
|
|
22153
22381
|
}
|
|
22154
22382
|
}
|
|
22155
|
-
async function authorize(entry, port, want) {
|
|
22383
|
+
async function authorize(entry, port, want, mode = "browser") {
|
|
22156
22384
|
let pendingState;
|
|
22157
22385
|
let resolveCode;
|
|
22158
22386
|
let rejectCode;
|
|
@@ -22174,12 +22402,14 @@ async function authorize(entry, port, want) {
|
|
|
22174
22402
|
else
|
|
22175
22403
|
resolveCode(code);
|
|
22176
22404
|
});
|
|
22177
|
-
|
|
22178
|
-
|
|
22179
|
-
|
|
22180
|
-
|
|
22181
|
-
|
|
22182
|
-
|
|
22405
|
+
if (mode === "browser") {
|
|
22406
|
+
await new Promise((res, rej) => {
|
|
22407
|
+
server.once("error", rej);
|
|
22408
|
+
server.listen(port, "127.0.0.1", res);
|
|
22409
|
+
}).catch((err) => {
|
|
22410
|
+
throw err.code === "EADDRINUSE" ? new Error(`Port ${port} is in use. Pass --port to pick another (it must stay the same across logins).`) : err;
|
|
22411
|
+
});
|
|
22412
|
+
}
|
|
22183
22413
|
let advertised = [];
|
|
22184
22414
|
let canRegister = true;
|
|
22185
22415
|
try {
|
|
@@ -22203,6 +22433,18 @@ async function authorize(entry, port, want) {
|
|
|
22203
22433
|
};
|
|
22204
22434
|
}
|
|
22205
22435
|
const provider = new FileTokenStore(entry.name, port, (url) => {
|
|
22436
|
+
if (mode === "manual") {
|
|
22437
|
+
console.log(`
|
|
22438
|
+
Open this on any machine with a browser and authorize ${entry.name}:
|
|
22439
|
+
|
|
22440
|
+
${url}
|
|
22441
|
+
|
|
22442
|
+
` + `The browser will then be redirected to ${`http://localhost:${port}/callback`}, which
|
|
22443
|
+
` + `will not load — that is expected, nothing is listening there. Copy the full URL out of
|
|
22444
|
+
` + `the address bar and paste it below.
|
|
22445
|
+
`);
|
|
22446
|
+
return;
|
|
22447
|
+
}
|
|
22206
22448
|
console.log(`
|
|
22207
22449
|
Opening your browser to authorize ${entry.name}:
|
|
22208
22450
|
${url}
|
|
@@ -22215,7 +22457,7 @@ Opening your browser to authorize ${entry.name}:
|
|
|
22215
22457
|
return { ok: true, message: `${entry.name}: already authorized (existing grant is still valid).` };
|
|
22216
22458
|
}
|
|
22217
22459
|
pendingState = (await readAuth(entry.name)).state;
|
|
22218
|
-
const code = await withTimeout2(codePromise, 5 * 60000, "waiting for the browser callback");
|
|
22460
|
+
const code = mode === "manual" ? await readPastedCode(pendingState) : await withTimeout2(codePromise, 5 * 60000, "waiting for the browser callback");
|
|
22219
22461
|
const result = await auth(provider, { serverUrl: entry.url, authorizationCode: code, scope });
|
|
22220
22462
|
if (result !== "AUTHORIZED") {
|
|
22221
22463
|
return { ok: false, message: `${entry.name}: token exchange did not complete (${result}).` };
|
|
@@ -22246,6 +22488,40 @@ function evaluateCallback(params, expectedState) {
|
|
|
22246
22488
|
failure: description ? `${error ?? "error"}: ${description}` : error ?? "no code returned"
|
|
22247
22489
|
};
|
|
22248
22490
|
}
|
|
22491
|
+
async function readPastedCode(expectedState) {
|
|
22492
|
+
const { createInterface } = await import("node:readline/promises");
|
|
22493
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
22494
|
+
try {
|
|
22495
|
+
for (let attempt = 0;attempt < 3; attempt++) {
|
|
22496
|
+
const answer = (await rl.question("Paste the full redirect URL (or just the code): ")).trim();
|
|
22497
|
+
if (!answer)
|
|
22498
|
+
continue;
|
|
22499
|
+
const parsed = parsePastedRedirect(answer, expectedState);
|
|
22500
|
+
if ("code" in parsed)
|
|
22501
|
+
return parsed.code;
|
|
22502
|
+
console.log(` ${parsed.error}`);
|
|
22503
|
+
}
|
|
22504
|
+
throw new Error("no usable authorization code was pasted");
|
|
22505
|
+
} finally {
|
|
22506
|
+
rl.close();
|
|
22507
|
+
}
|
|
22508
|
+
}
|
|
22509
|
+
function parsePastedRedirect(input, expectedState) {
|
|
22510
|
+
const trimmed = input.trim();
|
|
22511
|
+
if (!/^https?:\/\//i.test(trimmed)) {
|
|
22512
|
+
if (/[\s?&]/.test(trimmed))
|
|
22513
|
+
return { error: "that does not look like a URL or a code — try again" };
|
|
22514
|
+
return { code: trimmed };
|
|
22515
|
+
}
|
|
22516
|
+
let params;
|
|
22517
|
+
try {
|
|
22518
|
+
params = new URL(trimmed).searchParams;
|
|
22519
|
+
} catch {
|
|
22520
|
+
return { error: "that URL could not be parsed — paste the whole address bar" };
|
|
22521
|
+
}
|
|
22522
|
+
const { code, failure } = evaluateCallback(params, expectedState);
|
|
22523
|
+
return code ? { code } : { error: failure ?? "no code in that URL" };
|
|
22524
|
+
}
|
|
22249
22525
|
function openBrowser(url) {
|
|
22250
22526
|
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
22251
22527
|
try {
|
|
@@ -22289,7 +22565,7 @@ function chooseScopes(advertised, want) {
|
|
|
22289
22565
|
if (!ro.length) {
|
|
22290
22566
|
return {
|
|
22291
22567
|
error: `no read-only scopes are offered (available: ${advertised.join(", ")}).
|
|
22292
|
-
|
|
22568
|
+
Authorize with full access, or pick explicitly with --scopes.`
|
|
22293
22569
|
};
|
|
22294
22570
|
}
|
|
22295
22571
|
return { scope: ro.join(" ") };
|
|
@@ -22306,11 +22582,11 @@ function summarizeScopes(scope, limit = 6) {
|
|
|
22306
22582
|
if (all.length <= limit) {
|
|
22307
22583
|
return `scope: ${all.join(", ")}${writes.length ? "" : " (read-only)"}`;
|
|
22308
22584
|
}
|
|
22309
|
-
return `scope: ${all.length} granted, ${writes.length} of them write
|
|
22585
|
+
return `scope: ${all.length} granted, ${writes.length} of them write (${writes.slice(0, 3).join(", ") || "none"}${writes.length > 3 ? ", …" : ""})`;
|
|
22310
22586
|
}
|
|
22311
22587
|
|
|
22312
22588
|
// src/cli.ts
|
|
22313
|
-
var VERSION2 = "0.2.
|
|
22589
|
+
var VERSION2 = "0.2.5";
|
|
22314
22590
|
var USAGE = `autorouter — one search tool instead of every tool
|
|
22315
22591
|
|
|
22316
22592
|
autorouter serve Run as an MCP server over stdio (default)
|
|
@@ -22320,7 +22596,8 @@ var USAGE = `autorouter — one search tool instead of every tool
|
|
|
22320
22596
|
autorouter list [--kind K] List everything in the catalog
|
|
22321
22597
|
autorouter reindex Rebuild the catalog now
|
|
22322
22598
|
autorouter doctor Show what is reachable and what it saves
|
|
22323
|
-
autorouter login [server] Authorize an OAuth server (opens a browser
|
|
22599
|
+
autorouter login [server] Authorize an OAuth server (opens a browser,
|
|
22600
|
+
or prints a code on a headless box);
|
|
22324
22601
|
with no argument, lists what needs one
|
|
22325
22602
|
autorouter logout <server> Forget a stored grant
|
|
22326
22603
|
autorouter add <name> --url URL Register a server with the router directly
|
|
@@ -22360,6 +22637,10 @@ Options
|
|
|
22360
22637
|
any narrowing the previous grant carried
|
|
22361
22638
|
--scopes S login: request exactly these scopes (comma or space separated)
|
|
22362
22639
|
--list-scopes login: show what the server offers, authorize nothing
|
|
22640
|
+
--device login: RFC 8628 — print a code to enter on another device and
|
|
22641
|
+
poll for the result. No browser or open port needed here.
|
|
22642
|
+
--manual login: print the authorization URL, then read the redirect you
|
|
22643
|
+
paste back. Works where the provider has no device endpoint.
|
|
22363
22644
|
|
|
22364
22645
|
\`add\` registers behind the router, so a new server never enters your context.
|
|
22365
22646
|
Servers added to a harness the normal way (\`claude mcp add\`) are moved behind
|
|
@@ -22481,6 +22762,8 @@ async function cmdLogin(server, flags) {
|
|
|
22481
22762
|
};
|
|
22482
22763
|
}));
|
|
22483
22764
|
console.log(`Usage: autorouter login <server> [--read-only | --all-scopes | --scopes a,b]
|
|
22765
|
+
`);
|
|
22766
|
+
console.log(` On a machine with no browser, add --device (or --manual).
|
|
22484
22767
|
`);
|
|
22485
22768
|
for (const s of states) {
|
|
22486
22769
|
const scope = s.ok && s.scope ? ` ${summarizeScopes(s.scope)}` : "";
|
|
@@ -22506,7 +22789,9 @@ ${pending.length} need a grant; each is a separate authorization:`);
|
|
|
22506
22789
|
scopes: flags.scopes,
|
|
22507
22790
|
readOnly: Boolean(flags["read-only"]),
|
|
22508
22791
|
allScopes: Boolean(flags["all-scopes"]),
|
|
22509
|
-
listScopes: Boolean(flags["list-scopes"])
|
|
22792
|
+
listScopes: Boolean(flags["list-scopes"]),
|
|
22793
|
+
device: Boolean(flags.device),
|
|
22794
|
+
manual: Boolean(flags.manual)
|
|
22510
22795
|
});
|
|
22511
22796
|
console.log(result.message);
|
|
22512
22797
|
if (result.ok && !flags["list-scopes"]) {
|
|
@@ -22797,7 +23082,7 @@ function parseArgs(argv) {
|
|
|
22797
23082
|
command = `--${name}`;
|
|
22798
23083
|
continue;
|
|
22799
23084
|
}
|
|
22800
|
-
const boolean = ["raw", "json", "yes", "dry-run", "force", "servers-only", "read-only", "all-scopes", "list-scopes"].includes(name) && !(name === "json" && command === "add");
|
|
23085
|
+
const boolean = ["raw", "json", "yes", "dry-run", "force", "servers-only", "read-only", "all-scopes", "list-scopes", "device", "manual"].includes(name) && !(name === "json" && command === "add");
|
|
22801
23086
|
if (boolean) {
|
|
22802
23087
|
flags[name] = true;
|
|
22803
23088
|
} else if (inline !== undefined) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "autorouter-mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
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.
|
|
6
|
+
"version": "0.2.5",
|
|
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.
|
|
17
|
+
"version": "0.2.5",
|
|
18
18
|
"transport": { "type": "stdio" },
|
|
19
19
|
"packageArguments": [{ "type": "positional", "value": "serve" }],
|
|
20
20
|
"environmentVariables": [
|