pi-mcp-extension
Version:
Full-featured, lightweight MCP extension for the Pi coding agent: stdio, SSE & Streamable HTTP transports, OAuth 2.1 (PKCE + DCR), lazy/eager lifecycles — minimal dependencies, zero bloat.
603 lines (551 loc) • 19.9 kB
text/typescript
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import { CallbackServer } from "./callback-server.js";
import { callbackServerConfigFromRedirectUrl } from "./callback-url.js";
import {
discoverManualAuthChallenge,
createHeadersFetch,
stripAuthorizationHeader,
type ManualAuthChallenge,
} from "./auth-challenge.js";
import { acquireAuthLock, type AuthLockHandle } from "./auth-lock.js";
import { McpOAuthProvider } from "./oauth-provider.js";
import { McpError, AuthCancelledError, AuthLockError } from "./errors.js";
import { describeError } from "./debug-log-format.js";
import { reportDiagnostic } from "./debug-log.js";
import {
createAuthorizationBrowserLauncher,
openBrowser,
reopenAuthorizationBrowser,
} from "./authorize-browser.js";
import { waitForOAuthCallback } from "./authorize-dialog.js";
import type { ServerConfig } from "./config.js";
import type { ServerManager } from "./server-manager.js";
export { AuthCancelledError } from "./errors.js";
export {
browserOpenCommand,
openBrowser,
type BrowserOpenCommand,
} from "./authorize-browser.js";
export {
authCancelOption,
authPasteOption,
authRetryOption,
waitForOAuthCallback,
type OAuthPasteOptions,
} from "./authorize-dialog.js";
export interface AuthorizeDeps {
manager: ServerManager;
getServerConfig?: ((name: string) => ServerConfig | undefined) | undefined;
authStorageDir?: string | undefined;
openBrowser?: ((url: string) => void | Promise<void>) | undefined;
callbackServer?: CallbackServer | undefined;
}
// ─── Authorize Helpers ────────────────────────────────────────────────────────
/** Mutable per-run state shared across the authorize helpers. */
interface AuthorizeRunState {
/** CSRF state parameter; undefined until the callback server is listening. */
oauthState: string | undefined;
/** Last authorization URL handed to the browser, replayed by the "Retry" option. */
latestAuthorizationUrl: URL | undefined;
/** Set by the auth lock's `onLost` callback; re-checked after every await boundary. */
lockLost: boolean;
}
type SdkAuthResult = "AUTHORIZED" | "REDIRECT";
interface AuthOptionsBase {
serverUrl: string;
resourceMetadataUrl?: URL;
scope?: string;
fetchFn?: typeof fetch;
}
interface AcquireAuthorizeLockParams {
deps: AuthorizeDeps;
serverName: string;
signal: AbortSignal;
reset: boolean;
onLockLost: () => void;
}
interface StartCallbackServerParams {
serverName: string;
config: ServerConfig;
callbackServer: CallbackServer;
ctx: ExtensionContext;
signal: AbortSignal;
isLockLost: () => boolean;
}
interface InteractiveAuthProviderParams {
serverName: string;
config: ServerConfig;
serverUrl: string;
redirectUrl: string;
deps: AuthorizeDeps;
signal: AbortSignal;
unlock: AuthLockHandle;
onAuthRequired: (url: URL) => Promise<void>;
}
interface ChallengeDiscoveryParams {
serverName: string;
serverUrl: string;
headers: Record<string, string> | undefined;
signal: AbortSignal;
}
interface RedirectAuthFlowParams {
serverName: string;
serverUrl: string;
ctx: ExtensionContext;
authProvider: McpOAuthProvider;
authChallenge: ManualAuthChallenge;
customFetch: typeof fetch | undefined;
callbackPromise: Promise<string>;
oauthState: string;
reopenBrowser: () => Promise<void>;
onPasteResolved: () => void;
assertActive: () => void;
}
function resolveAuthorizeConfig(deps: AuthorizeDeps, serverName: string): ServerConfig {
const server = deps.manager.getServer(serverName);
const config = deps.getServerConfig ? deps.getServerConfig(serverName) : server?.config;
if (!config) {
throw new McpError(`Unknown server "${serverName}"`, serverName, "config");
}
return config;
}
function requireServerUrl(config: ServerConfig, serverName: string): string {
// Validate that we have a server URL (required for OAuth)
if (!config.url) {
throw new McpError(
`Server "${serverName}" has OAuth configured but no URL. OAuth requires a URL-based server transport.`,
serverName,
"config",
);
}
return config.url;
}
/**
* Fail fast at every await boundary once the caller aborted or the auth lock was
* taken over by another process.
*/
function assertAuthorizeActive(serverName: string, signal: AbortSignal, lockLost: boolean): void {
if (signal.aborted) throw new AuthCancelledError(serverName);
if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
}
function generateOAuthState(): string {
// 2. Generate a cryptographically secure state parameter for CSRF protection
return Array.from(crypto.getRandomValues(new Uint8Array(32)))
.map((b: number) => b.toString(16).padStart(2, "0"))
.join("");
}
function isCorruptOAuthStateError(err: unknown): err is McpError {
return err instanceof McpError && err.code === "config" && /Corrupt OAuth state file/i.test(err.message);
}
function reportCorruptOAuthState(serverName: string, err: McpError): void {
const { kind, message } = describeError(err);
reportDiagnostic({
level: "warn",
event: "oauth.state_corrupt",
server: serverName,
message,
fields: { kind },
});
}
function notifyAuthorizationSucceeded(ctx: ExtensionContext, serverName: string): void {
ctx.ui.notify(`pi-mcp: ${serverName} authenticated successfully. Starting MCP server...`, "info");
}
/**
* Report a failed authorize run, and surface a lost lock as `AuthLockError` so
* callers retry instead of seeing an unrelated downstream error.
*/
function reportAuthorizeFailure(
serverName: string,
err: unknown,
signal: AbortSignal,
lockLost: boolean,
): void {
const { kind, message } = describeError(err);
reportDiagnostic({
level: err instanceof AuthCancelledError ? "info" : "error",
event: "auth.failed",
server: serverName,
message: `OAuth authorization failed for "${serverName}": ${message}`,
fields: { kind },
});
if (lockLost && !signal.aborted) {
throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
}
}
/**
* Take the authorization lock, then clear stored auth when the caller requested
* a reset. Fails fast if the run was aborted before or during acquisition, and
* releases the lock if post-acquisition setup fails before returning the handle.
*/
async function acquireAuthorizeLock(params: AcquireAuthorizeLockParams): Promise<AuthLockHandle> {
const { deps, serverName, signal, reset, onLockLost } = params;
assertAuthorizeActive(serverName, signal, false);
const unlock = await acquireAuthLock(serverName, deps.authStorageDir, { onLost: onLockLost });
try {
assertAuthorizeActive(serverName, signal, false);
if (reset) {
await unlock.assertHeld();
await deps.manager.resetServerAuth(serverName);
}
return unlock;
} catch (err) {
await unlock();
throw err;
}
}
/**
* Require an interactive UI, then bind (or reuse) the loopback callback server.
* Returns the port that is actually listening.
*/
async function startAuthorizationCallbackServer(params: StartCallbackServerParams): Promise<number> {
const { serverName, config, callbackServer, ctx, signal, isLockLost } = params;
if (!ctx.hasUI) throw new Error(`Authorization for ${serverName} requires an interactive Pi session`);
assertAuthorizeActive(serverName, signal, isLockLost());
// 1. Start the callback server
const callbackServerConfig = callbackServerConfigFromRedirectUrl(config.auth?.redirectUrl);
const port = await callbackServer.ensure(callbackServerConfig.preferredPort, {
host: callbackServerConfig.host,
allowPortFallback: callbackServerConfig.allowPortFallback,
});
assertAuthorizeActive(serverName, signal, isLockLost());
return port;
}
/** Create the interactive-mode OAuth provider wired to the run's lock and signal. */
function createInteractiveAuthProvider(params: InteractiveAuthProviderParams): McpOAuthProvider {
const { serverName, config, serverUrl, redirectUrl, deps, signal, unlock, onAuthRequired } = params;
return new McpOAuthProvider({
serverName,
authConfig: config.auth || { type: "oauth" },
onAuthRequired,
storageDir: deps.authStorageDir,
mode: "interactive",
isCancelled: () => signal.aborted,
assertLockHeld: () => unlock.assertHeld(),
serverUrl,
redirectUrl,
});
}
/**
* Drop stored tokens before an interactive run so the SDK cannot silently refresh
* and return "AUTHORIZED" without opening the browser. A corrupt state file is
* reported and tolerated, because `_loadState()` already removed it.
*/
async function clearStoredTokensBeforeInteractiveAuth(
authProvider: McpOAuthProvider,
serverName: string,
): Promise<void> {
// WHY: authorize() is only entered after silent connect failed with AuthRequiredError.
// If a refresh_token remained on disk (e.g. accepted by the Authorization Server
// while the Resource Server rejects the refreshed access token), the SDK's auth()
// would silently refresh and return "AUTHORIZED" without opening the browser.
// Clear stored tokens while preserving clientInfo and discoveryState.
try {
await authProvider.invalidateCredentials("tokens");
} catch (err) {
if (!isCorruptOAuthStateError(err)) throw err;
reportCorruptOAuthState(serverName, err);
}
}
/**
* Discover protected-resource challenge metadata, falling back to standard
* discovery when the resource server does not advertise it.
*/
function discoverChallengeWithFallback(params: ChallengeDiscoveryParams): Promise<ManualAuthChallenge> {
const { serverName, serverUrl, headers, signal } = params;
// 4. Discover protected-resource challenge metadata from the server.
return discoverManualAuthChallenge(serverUrl, { headers, signal }).catch((): ManualAuthChallenge => {
if (signal.aborted) {
throw new AuthCancelledError(serverName);
}
reportDiagnostic({
level: "warn",
event: "auth.challenge_discovery_failed",
server: serverName,
message: `Failed to discover OAuth challenge for "${serverName}"; falling back to standard discovery`,
});
return {};
});
}
/**
* Register the callback waiter before `auth()` can open the browser. Attach a
* catch handler so cancellation after immediate authorization does not produce an
* unhandled rejection.
*/
function registerCallbackPromise(callbackServer: CallbackServer, oauthState: string): Promise<string> {
// 5. Register the callback immediately before auth() can open the browser.
const callbackPromise = callbackServer.waitForCallback(oauthState);
callbackPromise.catch(() => {
// WHY: Suppress unhandled rejection when callbackPromise is cancelled after immediate authorization.
});
return callbackPromise;
}
/** Apply the discovered challenge values and static headers to SDK auth options. */
function withChallengeOptions<T extends AuthOptionsBase>(
base: T,
authChallenge: ManualAuthChallenge,
customFetch: typeof fetch | undefined,
): T {
if (authChallenge.resourceMetadataUrl) {
base.resourceMetadataUrl = authChallenge.resourceMetadataUrl;
}
if (authChallenge.scope) {
base.scope = authChallenge.scope;
}
if (customFetch) {
base.fetchFn = customFetch;
}
return base;
}
function buildSdkAuthOptions(
serverUrl: string,
authChallenge: ManualAuthChallenge,
customFetch: typeof fetch | undefined,
): AuthOptionsBase {
return withChallengeOptions({ serverUrl }, authChallenge, customFetch);
}
function buildFinishAuthOptions(
serverUrl: string,
authChallenge: ManualAuthChallenge,
customFetch: typeof fetch | undefined,
authorizationCode: string,
): AuthOptionsBase & { authorizationCode: string } {
return withChallengeOptions({ serverUrl, authorizationCode }, authChallenge, customFetch);
}
/**
* Run the initial SDK auth call, retrying once when a corrupt state file was
* backed up and removed by `McpOAuthProvider._loadState()`.
*/
async function runInitialSdkAuth(
authProvider: McpOAuthProvider,
authOptions: AuthOptionsBase,
serverName: string,
): Promise<SdkAuthResult> {
// 6. Start the auth flow.
// Why branch on the return value: the SDK auth() call returns "AUTHORIZED" when
// stored or refreshed tokens already succeeded without opening a browser, or
// "REDIRECT" after invoking redirectToAuthorization(), in which case we must
// wait for the callback code and call auth() a second time with authorizationCode.
try {
return await auth(authProvider, authOptions);
} catch (err) {
if (!isCorruptOAuthStateError(err)) throw err;
// WHY: McpOAuthProvider._loadState() backed up and removed the corrupt state file.
// Retry auth() once so interactive authorization proceeds cleanly.
reportCorruptOAuthState(serverName, err);
return auth(authProvider, authOptions);
}
}
/**
* Drive the REDIRECT half of the flow: wait for the loopback callback (or a
* pasted redirect URL), then exchange the authorization code for tokens.
*/
async function completeRedirectAuthFlow(params: RedirectAuthFlowParams): Promise<void> {
const {
serverName,
serverUrl,
ctx,
authProvider,
authChallenge,
customFetch,
callbackPromise,
oauthState,
reopenBrowser,
onPasteResolved,
assertActive,
} = params;
// 7. Wait for the callback while giving the user an escape hatch.
const code = await waitForOAuthCallback(
serverName,
callbackPromise,
reopenBrowser,
ctx,
{
redirectUrl: authProvider.redirectUrl,
expectedState: oauthState,
onPasteResolved,
},
);
assertActive();
ctx.ui.notify(
`pi-mcp: Authorization callback received for ${serverName}. Exchanging token...`,
"info",
);
// 8. Complete the OAuth flow with the authorization code
const finishAuthResult = await auth(
authProvider,
buildFinishAuthOptions(serverUrl, authChallenge, customFetch, code),
);
assertActive();
if (finishAuthResult !== "AUTHORIZED") {
throw new McpError(
`Unexpected auth completion result: ${finishAuthResult}`,
serverName,
"protocol",
);
}
notifyAuthorizationSucceeded(ctx, serverName);
}
export async function authorize(
deps: AuthorizeDeps,
serverName: string,
ctx: ExtensionContext,
signal: AbortSignal,
reset: boolean,
): Promise<void> {
const config = resolveAuthorizeConfig(deps, serverName);
const callbackServer = deps.callbackServer ?? new CallbackServer();
const browserOpener = deps.openBrowser ?? openBrowser;
const state: AuthorizeRunState = {
oauthState: undefined,
latestAuthorizationUrl: undefined,
lockLost: false,
};
const assertActive = (): void => assertAuthorizeActive(serverName, signal, state.lockLost);
const cancel = (): void => {
if (state.oauthState) callbackServer.cancel(state.oauthState);
};
signal.addEventListener("abort", cancel, { once: true });
let unlock: AuthLockHandle | undefined;
reportDiagnostic({
level: "info",
event: "auth.started",
server: serverName,
message: `Starting OAuth authorization for "${serverName}"`,
fields: { outcome: reset ? "reset" : "authorize" },
});
try {
const authLock = await acquireAuthorizeLock({
deps,
serverName,
signal,
reset,
onLockLost: () => {
state.lockLost = true;
reportDiagnostic({
level: "warn",
event: "auth.lock_lost",
server: serverName,
message: `Authorization lock lost for "${serverName}"`,
fields: { outcome: "contended" },
});
cancel();
},
});
unlock = authLock;
const serverUrl = requireServerUrl(config, serverName);
ctx.ui.notify(
`pi-mcp: Starting OAuth flow for ${serverName}...`,
"info",
);
const port = await startAuthorizationCallbackServer({
serverName,
config,
callbackServer,
ctx,
signal,
isLockLost: () => state.lockLost,
});
// WHY: In interactive mode, redirectUrl must match the port that the callback server
// actually bound (which may differ from preferredPort if port fallback was used).
const boundRedirectUrl = config.auth?.redirectUrl ?? `http://127.0.0.1:${port}/callback`;
const oauthState = generateOAuthState();
state.oauthState = oauthState;
// 3. Create the auth provider.
const launchAuthorizationBrowser = createAuthorizationBrowserLauncher({
serverName,
port,
ctx,
browserOpener,
isInactive: () => signal.aborted || state.lockLost,
onUrlRecorded: (url) => {
state.latestAuthorizationUrl = url;
},
});
const authProvider = createInteractiveAuthProvider({
serverName,
config,
serverUrl,
redirectUrl: boundRedirectUrl,
deps,
signal,
unlock: authLock,
onAuthRequired: launchAuthorizationBrowser,
});
if (!reset) {
await clearStoredTokensBeforeInteractiveAuth(authProvider, serverName);
assertActive();
}
// Why set state before calling auth(): the SDK reads provider.state() while
// building the authorization URL inside auth(), and omits the state parameter
// if empty, which the callback server then rejects as a CSRF error.
authProvider.setState(oauthState);
// WHY: `Authorization` belongs to the OAuth flow here. Filter the same set the transport
// uses, so this flow's own challenge probe and its SDK `fetchFn` (metadata, registration,
// token) never carry a static credential that was configured for the MCP server — the
// authorization server is usually a different origin.
const staticHeaders = stripAuthorizationHeader(config.headers);
const authChallenge = await discoverChallengeWithFallback({
serverName,
serverUrl,
headers: staticHeaders,
signal,
});
assertActive();
const callbackPromise = registerCallbackPromise(callbackServer, oauthState);
const customFetch = staticHeaders ? createHeadersFetch(staticHeaders) : undefined;
const authResult = await runInitialSdkAuth(
authProvider,
buildSdkAuthOptions(serverUrl, authChallenge, customFetch),
serverName,
);
assertActive();
if (authResult === "AUTHORIZED") {
// Auth succeeded without needing browser interaction (e.g., had valid tokens).
notifyAuthorizationSucceeded(ctx, serverName);
} else if (authResult === "REDIRECT") {
await completeRedirectAuthFlow({
serverName,
serverUrl,
ctx,
authProvider,
authChallenge,
customFetch,
callbackPromise,
oauthState,
reopenBrowser: () =>
reopenAuthorizationBrowser(
launchAuthorizationBrowser,
() => state.latestAuthorizationUrl,
serverName,
),
onPasteResolved: cancel,
assertActive,
});
} else {
throw new McpError(
`Unexpected auth result: ${authResult}`,
serverName,
"protocol",
);
}
assertActive();
reportDiagnostic({
level: "info",
event: "auth.completed",
server: serverName,
message: `OAuth authorization completed for "${serverName}"`,
fields: { outcome: "authorized" },
});
} catch (err) {
reportAuthorizeFailure(serverName, err, signal, state.lockLost);
throw err;
} finally {
signal.removeEventListener("abort", cancel);
cancel();
await callbackServer.stop().catch(() => {
// WHY: Ignore callback server stop errors during authorize cleanup.
});
await unlock?.();
}
}