Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ mcpx @add "{ name: '<server>', url: '<mcp-url>' }"
mcpx @add "{ name: '<server>', url: '<mcp-url>', bearer: 'env:<TOKEN_ENV>' }"
mcpx @add "{ name: '<server>', transport: 'stdio', command: '<command>', args: ['<arg>'] }"
mcpx @remove "{ name: '<server>' }"
mcpx @login "{ name: '<server>' }"
mcpx @refresh
mcpx @daemon.status
mcpx @daemon.stop
Expand Down Expand Up @@ -194,9 +195,13 @@ discovers its authorization server from that challenge, authorizes, records
the server as OAuth, and retries the request. This covers servers whose tools
are partly anonymous — `@add` lists their tools without credentials, and the
first call to a protected tool starts sign-in. A `403 insufficient_scope`
challenge re-authorizes with the additional scope the same way. `@add` itself
never opens a browser; it reports `auth-required` when listing tools was
challenged.
challenge re-authorizes with the additional scope the same way. When `@add`
itself is challenged, it signs in right away and then lists the tools.

To switch accounts, run `mcpx @login "{ name: '<server>' }"`. It opens a new
browser sign-in (with OIDC `prompt=login`, so the old browser session is not
reused silently) and replaces the stored grant only once the new sign-in
succeeds.

For OAuth servers that support dynamic client registration, mcpx registers a
client automatically for each browser sign-in.
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0009-a-challenge-is-the-oauth-discovery-entry.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Whether a server needs OAuth is decided by the resource server's challenge, at a

`@add` used to `GET` the server URL and freeze the result as the server's auth kind. A mixed-auth server — anonymous tools beside protected ones, such as `whiteboardanimation.ai` — answers that `GET` with `405`, so it was recorded as unauthenticated, and its spec-compliant challenge on a protected tool could only end in `reauth-required`. The probe guessed from the wrong request; the challenge is the server's own answer.

- `@add` no longer probes. It lists tools; a challenged listing records the server as OAuth and reports `auth-required` without opening a browser.
- `@add` no longer probes. It lists tools; a challenged listing records the server as OAuth. (It used to stop there with `auth-required`; [ADR 0010](0010-adding-signs-in-and-login-replaces-a-grant.md) has it sign in.)
- The transport carries an mcpx `authProvider` whose `onUnauthorized` surfaces the challenge instead of running OAuth inside the request, so the Runtime keeps single-flight, Caller Input, progress events, and registry ownership (ADR 0002).
- OAuth itself — discovery, dynamic registration, PKCE, resource indicators, `iss` validation, refresh — is the official MCP SDK's `auth()`. mcpx supplies its `OAuthClientProvider`: per-server grants in the Credential Store, a loopback callback per sign-in, and the fixed `http://127.0.0.1:65245/callback` for manually registered clients. A refresh never opens the browser; a new sign-in never replays stored tokens or a client registered for another port, and replaces the stored grant only once it succeeds.
- A bearer credential that is challenged still fails with `reauth-required`; static credentials cannot be repaired by the Runtime.
17 changes: 17 additions & 0 deletions docs/adr/0010-adding-signs-in-and-login-replaces-a-grant.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
type: Decision
title: Adding Signs In, and @login Replaces a Grant
status: accepted
---

# Adding Signs In, and @login Replaces a Grant

`@add` is the user asking to use a server, so a challenged tool listing goes straight on to the shared Authentication Flow (ADR 0004) and then lists the tools: one command takes an OAuth server from unknown to callable. `mcpx @login "{ name }"` starts a new browser sign-in for a registered OAuth server even when its grant still works, which is how an account is switched.

[ADR 0009](0009-a-challenge-is-the-oauth-discovery-entry.md) kept `@add` from opening a browser because it was written when only explicit commands could (ADR 0007). [ADR 0008](0008-calls-wait-for-shared-authorization.md) already lets an ordinary Call open one, which left `@add` as the only entry that refused, and made switching accounts a `@remove` + `@add` detour whose only purpose was deleting the stored grant.

- `@add` still never probes; it lists tools, and only a challenged listing on a server without a configured bearer signs in. A failed sign-in keeps the registration and reports the failure; `@login` retries it.
- A sign-in skips the refresh grant and asks again for the previously granted scope. It replaces the stored grant only once the new one is earned (the existing interactive-mode guarantee), so an abandoned or failed `@login` leaves the old account working.
- The authorization URL carries OIDC `prompt=login`, so a browser still signed in to the old account cannot silently authorize it again. Authorization servers without OIDC ignore the parameter.
- `@login` on a bearer or stdio server fails: their credentials are declared, not earned, and change by re-adding.
- A sign-in that arrives while a refresh flow for the same Credential Identity is running joins that flow (ADR 0004) and gets its result; run `@login` again afterwards.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mcpx",
"version": "0.14.3",
"version": "0.15.0",
"license": "MIT",
"bin": {
"mcpx": "./src/main.ts"
Expand Down
6 changes: 3 additions & 3 deletions src/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,15 @@ export async function discoverServer(
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
// The challenge, not a probe, says the server wants OAuth (ADR 0009).
// @add never starts OAuth, so name the commands that do.
// The challenge, not a probe, says the server wants OAuth (ADR 0009); the
// Runtime's @add goes on to sign in (ADR 0010).
if (!configuredAuth && authorizationChallenge(error)) {
server.auth = { kind: 'oauth' }
return {
server,
status: 'auth-required',
message:
'Authentication is required before tool schemas can be listed. Run `mcpx @refresh` or call a tool to authenticate.',
'Authentication is required before tool schemas can be listed. Run `mcpx @login` to sign in.',
}
}
if (configuredAuth && authorizationChallenge(error)) {
Expand Down
23 changes: 19 additions & 4 deletions src/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ export type AuthorizeOAuthOptions = {
serverUrl: URL
grants: OAuthGrantStore
challenge?: OAuthChallenge | undefined
/**
* A new sign-in the user asked for, such as switching accounts: skip the
* refresh grant and ask the authorization server to prompt for login again.
*/
signIn?: boolean
signal: AbortSignal
requestManualClient: (
request: ManualOAuthClientRequest,
Expand Down Expand Up @@ -98,14 +103,15 @@ export function oauthAccessToken(
export async function authorizeOAuth(
options: AuthorizeOAuthOptions,
): Promise<void> {
const { challenge, signal } = options
const { challenge, signal, signIn } = options
const grant = await options.grants.read()
const grantedScope = grant?.tokens?.value.scope
// A 403 step-up keeps what was granted and adds what the server asked for.
// A 403 step-up keeps what was granted and adds what the server asked for;
// a new sign-in asks again for what the old grant held.
const scope =
challenge?.status === 403
? computeScopeUnion(grantedScope, challenge.scope)
: challenge?.scope
: (challenge?.scope ?? (signIn ? grantedScope : undefined))
const widensScope =
challenge?.status === 403 && isStrictScopeSuperset(scope, grantedScope)
const base = {
Expand All @@ -118,7 +124,12 @@ export async function authorizeOAuth(
}

// A refresh grant cannot widen scope (RFC 6749 §6), so step-up skips it.
if (grant?.client && grant.tokens?.value.refresh_token && !widensScope) {
if (
!signIn &&
grant?.client &&
grant.tokens?.value.refresh_token &&
!widensScope
) {
options.report({
phase: 'refreshing-token',
message: 'refreshing OAuth token',
Expand Down Expand Up @@ -322,6 +333,10 @@ class McpxOAuthProvider implements OAuthClientProvider {

redirectToAuthorization(authorizationUrl: URL): void {
if (this.#mode === 'refresh') throw new InteractionRequired()
// OIDC `prompt=login`: a browser still signed in to the old account must
// not silently authorize it again. Servers without OIDC ignore it.
if (this.#options.signIn)
authorizationUrl.searchParams.set('prompt', 'login')
const url = authorizationUrl.toString()
// The Runtime's stderr is detached; callers must receive the URL as an event
// so a failed browser launch never turns into a silent wait.
Expand Down
34 changes: 33 additions & 1 deletion src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ const removeInput = s(
}),
)

const loginInput = s(
v.object({
name: v.pipe(v.string(), v.description('Registered OAuth server name')),
}),
)

const skillInput = s(
v.object({
servers: v.optional(
Expand Down Expand Up @@ -169,6 +175,13 @@ function buildRouter(registry: RegistryView): Router {
],
})
.input(removeInput),
'@login': c
.meta({
description:
'Sign in to an OAuth MCP server in the browser, replacing its current grant (e.g. to switch accounts). The old grant stays until the new sign-in succeeds.',
examples: [`mcpx @login "{ name: 'higgsfield' }"`],
})
.input(loginInput),
'@refresh': c.meta({
description:
'Refresh all registered MCP server schemas and report auth status.',
Expand Down Expand Up @@ -331,7 +344,26 @@ function buildHandlers(
if (bearer) intent.bearer = bearer
}
return await renderOutput(
await requestRuntime(intent, mainPath),
await requestRuntime(intent, mainPath, {
onEvent: renderRuntimeProgress,
onInput: promptForRuntimeInput,
}),
options.context,
)
}

handlers['@login'] = async (options: HandlerOptions<{ name: string }>) => {
return await renderOutput(
await requestRuntime(
{
requestId: crypto.randomUUID(),
op: 'refreshServers',
serverNames: [assertServerName(options.input.name)],
signIn: true,
},
mainPath,
{ onEvent: renderRuntimeProgress, onInput: promptForRuntimeInput },
),
options.context,
)
}
Expand Down
18 changes: 16 additions & 2 deletions src/runtime-authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export type AuthorizationProgress = AuthenticationProgress & {
export type AuthorizeOptions = {
/** The server rejected a request; its challenge says what it wants. */
challenge?: OAuthChallenge | undefined
/** Replace the grant with a new browser sign-in, even if it still works. */
signIn?: boolean
signal?: AbortSignal
onProgress?: (progress: AuthorizationProgress) => void
}
Expand Down Expand Up @@ -77,20 +79,29 @@ export class RuntimeAuthentication {
`Unknown MCP server: ${serverName}.`,
)
}
const { challenge, signIn } = options
if (
signIn &&
(server.transport === 'stdio' || server.auth.kind === 'bearer')
)
throw new RuntimeOperationError(
'operation-failed',
`${serverName} does not sign in through OAuth; re-add it with new credentials.`,
)
if (server.transport === 'stdio') return 'ready'

const { challenge } = options
if (server.auth.kind === 'bearer') {
// Static credentials cannot be repaired by the Runtime.
if (challenge) throw reauthRequired(serverName)
return 'ready'
}
if (server.auth.kind === 'none' && !challenge) return 'ready'
if (server.auth.kind === 'none' && !challenge && !signIn) return 'ready'

const observed = credentials.oauth[serverName]
if (
server.auth.kind === 'oauth' &&
!challenge &&
!signIn &&
oauthAccessToken(observed)
)
return 'ready'
Expand All @@ -101,7 +112,9 @@ export class RuntimeAuthentication {
]
// A caller can arrive after a shared flow persisted but before it observed
// completion; reuse that rotation instead of authorizing again.
// A sign-in wants a new grant, never one that merely rotated.
if (
!signIn &&
current?.tokens?.value.access_token !==
observed?.tokens?.value.access_token &&
oauthAccessToken(current)
Expand All @@ -112,6 +125,7 @@ export class RuntimeAuthentication {
serverUrl: new URL(server.url),
grants: this.#grantStore(serverName),
challenge,
signIn,
signal,
report,
openBrowser: this.#openBrowser,
Expand Down
13 changes: 10 additions & 3 deletions src/runtime-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,13 @@ export type RuntimeIntent =
env?: Record<string, string>
}
| { requestId: string; op: 'removeServers'; serverNames: string[] }
| { requestId: string; op: 'refreshServers'; serverNames?: string[] }
| {
requestId: string
op: 'refreshServers'
serverNames?: string[]
/** Replace each OAuth grant with a new browser sign-in. */
signIn?: boolean
}
| { requestId: string; op: 'status' }
| { requestId: string; op: 'stop' }

Expand Down Expand Up @@ -176,9 +182,10 @@ export function parseRuntimeIntent(
? (value as RuntimeIntent)
: invalidFrame()
case 'refreshServers':
return hasOnly(value, ['requestId', 'op', 'serverNames']) &&
return hasOnly(value, ['requestId', 'op', 'serverNames', 'signIn']) &&
hasRequired(value, ['requestId', 'op']) &&
(value.serverNames === undefined || isStringArray(value.serverNames))
(value.serverNames === undefined || isStringArray(value.serverNames)) &&
(value.signIn === undefined || typeof value.signIn === 'boolean')
? (value as RuntimeIntent)
: invalidFrame()
default:
Expand Down
58 changes: 46 additions & 12 deletions src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,11 @@ export class McpRuntime {
return
case 'refreshServers':
try {
const outcome = await this.#refreshServers(intent.serverNames, caller)
const outcome = await this.#refreshServers(
intent.serverNames,
caller,
{ signIn: intent.signIn === true },
)
if (outcome.status === 'disconnected') return
await caller.send({
requestId: intent.requestId,
Expand All @@ -106,20 +110,48 @@ export class McpRuntime {
try {
const result = await discoverServer(discoveryOptions(intent))
await this.#stores.upsertServer(intent.serverName, result.server)
const added = {
name: intent.serverName,
transport: result.server.transport ?? 'http',
status: result.status,
auth:
result.server.transport === 'stdio'
? undefined
: result.server.auth,
tools: result.server.tools?.length ?? 0,
message: result.message,
}
// Adding is the user asking to use the server: a challenged listing
// goes straight on to browser sign-in, then lists the tools (ADR 0010).
if (
result.status === 'auth-required' &&
result.server.transport !== 'stdio' &&
result.server.auth.kind === 'oauth'
) {
const outcome = await this.#refreshServers(
[intent.serverName],
caller,
{ signIn: false },
)
if (outcome.status === 'disconnected') return
const failure = outcome.failed[0]
const snapshot = await this.#stores.readSnapshot()
Object.assign(
added,
failure
? { message: failure.message }
: {
status: 'ready',
tools:
snapshot.servers[intent.serverName]?.tools?.length ?? 0,
message: 'Signed in and discovered tools.',
},
)
}
await caller.send({
requestId: intent.requestId,
kind: 'result',
result: {
name: intent.serverName,
transport: result.server.transport ?? 'http',
status: result.status,
auth:
result.server.transport === 'stdio'
? undefined
: result.server.auth,
tools: result.server.tools?.length ?? 0,
message: result.message,
},
result: added,
})
} catch (error) {
await caller.send({
Expand Down Expand Up @@ -166,6 +198,7 @@ export class McpRuntime {
async #refreshServers(
serverNames: string[] | undefined,
caller: RuntimeCaller,
options: { signIn: boolean },
): Promise<RuntimeRefreshOutcome> {
const registry = await this.#stores.registry.read()
const names = serverNames ?? Object.keys(registry.servers).sort()
Expand Down Expand Up @@ -215,6 +248,7 @@ export class McpRuntime {
name,
caller,
{
signIn: options.signIn,
onProgress: (progress) =>
report(name, progress.phase, progress.message, progress.url),
},
Expand Down
3 changes: 3 additions & 0 deletions src/skill-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,9 @@ mcpx @refresh

If the current session is not a TTY, ask the user to run it in their terminal.

To sign in with a different account, run
\`mcpx @login "{ name: '<server>' }"\` and share the printed URL with the user.

Once \`mcpx\` lists the server and a focused \`@schema\` call succeeds, return
to the skill.
`
Expand Down
Loading
Loading