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.
335 lines (291 loc) • 11.5 kB
text/typescript
/**
* MCP OAuth Callback Server
*
* Simple HTTP server that handles OAuth callbacks from the authorization server.
* Uses Node.js http module for compatibility with no external dependencies.
*/
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "http";
import type { Socket } from "net";
import { URL } from "url";
import {
CallbackUrlValidationError,
DEFAULT_HOST,
DEFAULT_PORT,
parseCallbackUrl,
} from "./callback-url.js";
interface PendingAuth {
resolve: (code: string) => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout>;
}
export interface CallbackServerOptions {
/** Host to bind. Defaults to 127.0.0.1 for local-only callbacks. */
host?: string | undefined;
/** Whether to scan following ports when the preferred port is busy. */
allowPortFallback?: boolean | undefined;
}
const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
const HTML_SUCCESS = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Pi - Authorization Successful</title>
<style>body{font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e;color:#eee}
.container{text-align:center;padding:2rem}h1{color:#4ade80;margin-bottom:1rem}p{color:#aaa}</style>
</head><body>
<div class="container"><h1>✓ Authorization Successful</h1><p>You can close this window and return to Pi.</p></div>
<script>setTimeout(()=>window.close(),2000)</script>
</body></html>`;
const HTML_ERROR = (error: string) => `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Pi - Authorization Failed</title>
<style>body{font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e;color:#eee}
.container{text-align:center;padding:2rem}h1{color:#f87171;margin-bottom:1rem}p{color:#aaa}
.error{color:#fca5a5;font-family:monospace;margin-top:1rem;padding:1rem;background:rgba(248,113,113,0.1);border-radius:.5rem;white-space:pre-wrap;word-break:break-word}</style>
</head><body>
<div class="container"><h1>✗ Authorization Failed</h1><p>An error occurred during authorization.</p><div class="error">${escapeHtml(error)}</div></div>
</body></html>`;
/**
* Escape HTML entities to prevent XSS attacks.
*/
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
/**
* Whether a `listen` failure is a port already taken by another process.
*/
function isAddressInUseError(error: unknown): boolean {
return (error as NodeJS.ErrnoException).code === "EADDRINUSE";
}
/**
* Instance-scoped HTTP server that handles OAuth callbacks from the authorization server.
*/
export class CallbackServer {
private server: Server | null = null;
private actualServerPort: number | null = null;
private actualServerHost: string | null = null;
private readonly serverSockets = new Set<Socket>();
private readonly pendingAuths = new Map<string, PendingAuth>();
/**
* Handle incoming HTTP requests to the callback server.
*/
private handleRequest(req: IncomingMessage, res: ServerResponse): void {
let parsed: ReturnType<typeof parseCallbackUrl>;
try {
const requestUrl = new URL(req.url || "/", "http://localhost").toString();
parsed = parseCallbackUrl(requestUrl, {
expectedState: (state) => this.pendingAuths.has(state),
});
} catch (err) {
if (err instanceof CallbackUrlValidationError) {
if (err.reason === "wrong_path") {
res.writeHead(404, { "Content-Type": "text/plain", "Connection": "close" });
res.end("Not found");
return;
}
res.writeHead(400, { "Content-Type": "text/html", "Connection": "close" });
res.end(HTML_ERROR(err.message));
return;
}
res.writeHead(400, { "Content-Type": "text/html", "Connection": "close" });
res.end(HTML_ERROR("Invalid callback request"));
return;
}
// Handle OAuth errors
if ("error" in parsed) {
res.writeHead(200, { "Content-Type": "text/html", "Connection": "close" });
res.end(HTML_ERROR(parsed.error));
if (this.pendingAuths.has(parsed.state)) {
const pending = this.pendingAuths.get(parsed.state)!;
clearTimeout(pending.timeout);
this.pendingAuths.delete(parsed.state);
setTimeout(() => pending.reject(new Error(parsed.error)), 0);
}
return;
}
const pending = this.pendingAuths.get(parsed.state)!;
// Clear timeout and resolve the pending promise
clearTimeout(pending.timeout);
this.pendingAuths.delete(parsed.state);
pending.resolve(parsed.code);
res.writeHead(200, { "Content-Type": "text/html", "Connection": "close" });
res.end(HTML_SUCCESS);
}
private async closeCurrentServer(): Promise<void> {
if (!this.server) return;
const currentServer = this.server;
this.server = null;
this.actualServerPort = null;
this.actualServerHost = null;
for (const socket of Array.from(this.serverSockets)) {
socket.destroy();
}
this.serverSockets.clear();
await new Promise<void>((resolve) => {
currentServer.close(() => {
resolve();
});
});
}
/**
* Backfill `actualServerPort` from the live listener address when it was not
* recorded at listen time.
*/
private _syncActualServerPort(): void {
if (this.actualServerPort !== null) return;
const address = this.server?.address();
if (address && typeof address === "object" && "port" in address) {
this.actualServerPort = address.port;
}
}
/**
* Reuse the running listener when it already matches the requested port/host,
* otherwise close it. Returns the port to short-circuit `ensure` with, or
* `null` when the caller must bind a new listener.
*/
private async _reuseOrCloseExistingServer(preferredPort: number, host: string): Promise<number | null> {
if (!this.server) return null;
this._syncActualServerPort();
const serverMatchesRequest = this.actualServerPort === preferredPort && this.actualServerHost === host;
if (serverMatchesRequest && this.actualServerPort !== null) {
return this.actualServerPort;
}
if (this.pendingAuths.size > 0) {
throw new Error(
`OAuth callback server is already handling an authorization flow on ${this.actualServerHost}:${this.actualServerPort}`
);
}
await this.closeCurrentServer();
return null;
}
/**
* Bind a candidate listener, wiring socket tracking. Closes the candidate again
* before rethrowing so a failed attempt leaks neither a socket nor a listener.
*/
private async _tryListenOnPort(candidatePort: number, host: string): Promise<Server> {
const candidateServer = createServer((req, res) => this.handleRequest(req, res));
try {
candidateServer.on("connection", (socket: Socket) => {
this.serverSockets.add(socket);
socket.once("close", () => {
this.serverSockets.delete(socket);
});
});
await new Promise<void>((resolve, reject) => {
candidateServer.once("error", (err: Error) => {
reject(err);
});
candidateServer.listen(candidatePort, host, () => {
resolve();
});
});
return candidateServer;
} catch (error) {
await new Promise<void>((resolve) => {
candidateServer.close(() => resolve());
});
throw error;
}
}
/**
* Ensure the callback server is running.
* Scans forward for an available local port when port fallback is enabled.
*
* @param preferredPort - The preferred port to use (default: 19876)
* @returns The actual port the server is listening on
*/
async ensure(preferredPort: number = DEFAULT_PORT, options: CallbackServerOptions = {}): Promise<number> {
const host = options.host ?? DEFAULT_HOST;
const allowPortFallback = options.allowPortFallback ?? true;
const reusablePort = await this._reuseOrCloseExistingServer(preferredPort, host);
if (reusablePort !== null) return reusablePort;
const maxAttempts = allowPortFallback ? 25 : 1; // Try up to 25 ports unless a fixed redirect URI is configured
for (let offset = 0; offset < maxAttempts; offset++) {
const candidatePort = preferredPort + offset;
try {
this.server = await this._tryListenOnPort(candidatePort, host);
this.actualServerPort = candidatePort;
this.actualServerHost = host;
this.server.unref(); // Don't block process exit
return candidatePort;
} catch (error) {
// If not EADDRINUSE, rethrow
if (!isAddressInUseError(error)) {
throw error;
}
if (!allowPortFallback) {
throw new Error(`OAuth callback port ${preferredPort} is already in use, and redirect URL requires this exact port`);
}
}
}
throw new Error(
`OAuth callback port ${preferredPort} is already in use and no free port was found in range ${preferredPort}-${preferredPort + maxAttempts - 1}`
);
}
/**
* Wait for a callback with the given OAuth state.
* Returns a promise that resolves with the authorization code.
*
* @param oauthState - The OAuth state parameter to wait for
* @param timeoutMs - Timeout in milliseconds (default: 5 minutes)
* @returns Promise that resolves with the authorization code
*/
waitForCallback(
oauthState: string,
timeoutMs: number = TIMEOUT_MS
): Promise<string> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
if (this.pendingAuths.has(oauthState)) {
this.pendingAuths.delete(oauthState);
reject(new Error("OAuth callback timeout - authorization took too long"));
}
}, timeoutMs);
this.pendingAuths.set(oauthState, { resolve, reject, timeout });
});
}
/**
* Cancel a pending authorization by state.
*
* @param oauthState - The OAuth state to cancel
*/
cancel(oauthState: string): void {
const pending = this.pendingAuths.get(oauthState);
if (pending) {
clearTimeout(pending.timeout);
this.pendingAuths.delete(oauthState);
pending.reject(new Error("Authorization cancelled"));
}
}
/**
* Stop the callback server and reject all pending authorizations.
*/
async stop(): Promise<void> {
await this.closeCurrentServer();
// Reject all pending auths (defer to allow any pending operations to complete)
const pendingList = Array.from(this.pendingAuths.entries());
this.pendingAuths.clear();
setTimeout(() => {
for (const [, pending] of pendingList) {
clearTimeout(pending.timeout);
pending.reject(new Error("OAuth callback server stopped"));
}
}, 0);
}
/**
* Check if the callback server is running.
*/
isRunning(): boolean {
return this.server !== null;
}
/**
* Get the number of pending authorizations.
*/
pendingCount(): number {
return this.pendingAuths.size;
}
}
// Export constants and pure redirect-URL config builder for backwards compatibility
export { DEFAULT_PORT, DEFAULT_HOST, TIMEOUT_MS };
export { CALLBACK_PATH, callbackServerConfigFromRedirectUrl, type CallbackServerConfig } from "./callback-url.js";