UNPKG

@civic/auth-mcp

Version:

Civic Auth integration for MCP servers

346 lines (339 loc) 10.8 kB
// src/client/providers/persistence/InMemoryTokenPersistence.ts var InMemoryTokenPersistence = class { saveTokens(tokens) { this.tokens = tokens; } loadTokens() { return this.tokens; } clearTokens() { this.tokens = void 0; } }; // src/client/providers/CivicAuthProvider.ts var CivicAuthProvider = class { constructor(options) { this.clientSecret = options.clientSecret; this.tokenPersistence = options.tokenPersistence ?? new InMemoryTokenPersistence(); } saveTokens(tokens) { return this.tokenPersistence.saveTokens(tokens); } /** * Returns the stored tokens */ tokens() { return this.tokenPersistence.loadTokens(); } /** * Clears the stored tokens */ clearTokens() { return this.tokenPersistence.clearTokens(); } }; // src/client/providers/CLIAuthProvider.ts import { execFile } from "child_process"; import crypto from "crypto"; import http from "http"; import url from "url"; import { promisify } from "util"; import escapeHtml from "escape-html"; // src/constants.ts var DEFAULT_WELLKNOWN_URL = "https://auth.civic.com/oauth/.well-known/openid-configuration"; var DEFAULT_SCOPES = ["openid", "profile", "email", "offline_access"]; var DEFAULT_CALLBACK_PORT = 8080; var DEFAULT_MCP_ROUTE = "/mcp"; var PUBLIC_CIVIC_CLIENT_ID = "12220cf4-1a9a-4964-8eb7-7c6d7d049f34"; // src/client/providers/CLIAuthProvider.ts var CLIAuthProvider = class extends CivicAuthProvider { constructor(options) { super(options); this.clientId = options.clientId; this.scope = options.scope ?? DEFAULT_SCOPES.join(" "); this.callbackPort = options.callbackPort ?? DEFAULT_CALLBACK_PORT; this.enablePortFallback = options.enablePortFallback ?? true; this.authTimeoutMs = options.authTimeoutMs ?? 5 * 60 * 1e3; this.successHtml = options.successHtml ?? '<html lang="en"><body><h1>Authorization Successful</h1><p>You can now close this window.</p></body></html>'; this.errorHtml = options.errorHtml ?? '<html lang="en"><body><h1>Authorization Failed</h1><p>{{error}}</p></body></html>'; } clientInformation() { const info = { client_id: this.clientId }; if (this.clientSecret) { info.client_secret = this.clientSecret; } return info; } get clientMetadata() { return { redirect_uris: [this.getCallbackUrl(this.callbackPort)], client_name: this.clientId, scope: this.scope }; } codeVerifier() { if (!this.storedCodeVerifier) { this.storedCodeVerifier = crypto.randomBytes(32).toString("base64url"); } return this.storedCodeVerifier; } async redirectToAuthorization(authorizationUrl) { if (this.callbackServer) { throw new Error("Authorization flow already in progress. Please wait for it to complete."); } console.log(`Opening authorization URL in browser: ${authorizationUrl.href}`); const actualPort = await this.startCallbackServer(); let urlToOpen = authorizationUrl.href; if (actualPort) { this.callbackPort = actualPort; const authUrlObj = new URL(authorizationUrl); authUrlObj.searchParams.set("redirect_uri", this.getCallbackUrl(actualPort)); urlToOpen = authUrlObj.href; } await this.openInBrowser(urlToOpen); console.log("Please complete the authorization in your browser."); } /** * Registers the transport with the auth provider so that we can call finishAuth when the code is received. * @param transport */ registerTransport(transport) { this.transport = transport; } get redirectUrl() { return new URL(this.getCallbackUrl(this.callbackPort)); } saveCodeVerifier(codeVerifier) { this.storedCodeVerifier = codeVerifier; } getCallbackUrl(port) { return `http://localhost:${port}/callback`; } /** * Listen on Port Promise * @param server * @param port * @private port that is being listened on. */ listenOnPort(server, port) { return new Promise((resolve, reject) => { const onError = (err) => { server.off("listening", onListening); reject(err); }; const onListening = () => { server.off("error", onError); const address = server.address(); resolve(address.port); }; server.once("error", onError); server.once("listening", onListening); server.listen(port, "localhost"); }); } /** * Starts a local HTTP server to handle the OAuth callback with port fallback support * @returns The actual port number if different from the configured port, undefined otherwise */ async startCallbackServer() { this.authorizationCodePromise = new Promise((resolveCode, rejectCode) => { this.authorizationCodeResolve = resolveCode; this.authorizationCodeReject = rejectCode; }); this.callbackServer = http.createServer((req, res) => { try { if (!req.url) { res.writeHead(400); res.end("Bad Request"); return; } const parsedUrl = url.parse(req.url, true); if (parsedUrl.pathname === "/callback") { const code = parsedUrl.query.code; const error = parsedUrl.query.error; if (error) { res.writeHead(200, { "Content-Type": "text/html" }); res.end(this.errorHtml.replace("{{error}}", escapeHtml(error))); this.authorizationCodeReject?.(new Error(`OAuth error: ${error}`)); } else if (code) { res.writeHead(200, { "Content-Type": "text/html" }); res.end(this.successHtml); if (this.transport) { this.transport.finishAuth(code).then(() => this.authorizationCodeResolve?.(code)).catch((error2) => { console.error("Error in finishAuth:", error2); this.authorizationCodeReject?.(error2); }); } else { this.authorizationCodeReject?.(new Error("No transport registered")); } } else { res.writeHead(400); res.end("Missing authorization code"); } } else { res.writeHead(404); res.end("Not Found"); } } finally { this.cleanup(); } }); let actualPort; try { actualPort = await this.listenOnPort(this.callbackServer, this.callbackPort); } catch (err) { if (err.code === "EADDRINUSE" && this.enablePortFallback) { console.warn(`Port ${this.callbackPort} in use. Trying a random port...`); actualPort = await this.listenOnPort(this.callbackServer, 0); } else { throw err; } } this.serverTimeout = setTimeout(() => { console.warn(`OAuth callback server timeout reached after ${this.authTimeoutMs / 1e3}s. Closing server.`); this.cleanup(); }, this.authTimeoutMs); return actualPort !== this.callbackPort ? actualPort : void 0; } /** * Resets the instance to its post-initialization state * Stops any active server, clears timeouts */ cleanup() { if (this.callbackServer) { this.callbackServer.close(); this.callbackServer = void 0; } if (this.serverTimeout) { clearTimeout(this.serverTimeout); this.serverTimeout = void 0; } } /** * Waits for the authorization code from the callback */ async waitForAuthorizationCode() { if (!this.authorizationCodePromise) { throw new Error("Authorization flow not started"); } return this.authorizationCodePromise; } async openInBrowser(url2) { const execFileAsync = promisify(execFile); try { switch (process.platform) { case "darwin": await execFileAsync("open", [url2]); break; case "win32": await execFileAsync("cmd", ["/c", "start", url2]); break; default: await execFileAsync("xdg-open", [url2]); } } catch (error) { console.error("Failed to open browser:", error); console.log("Please open this URL manually:", url2); } } }; // src/client/providers/TokenAuthProvider.ts var TokenAuthProvider = class extends CivicAuthProvider { /** * Create a new TokenAuthProvider * @param tokenOrOptions - Either a token string or full options object */ constructor(tokenOrOptions) { const options = typeof tokenOrOptions === "string" ? { tokens: { access_token: tokenOrOptions, token_type: "Bearer" } } : tokenOrOptions; super(options); this.tokenPersistence.saveTokens(options.tokens); } get redirectUrl() { return ""; } get clientMetadata() { return { redirect_uris: [] }; } clientInformation() { return { client_id: "token-client" }; } redirectToAuthorization(_authorizationUrl) { } saveCodeVerifier(_codeVerifier) { } codeVerifier() { return ""; } }; // src/client/transport/RestartableStreamableHTTPClientTransport.ts import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; var RestartableStreamableHTTPClientTransport = class extends StreamableHTTPClientTransport { constructor(url2, opts) { super(url2, opts); this._cliAuthProvider = opts.authProvider; this._cliAuthProvider.registerTransport(this); } get authProvider() { return this._cliAuthProvider; } /** * Extends the start method to properly handle reconnection. * If the transport has already been started, it will disconnect first, * then start again to establish a fresh connection. */ async start() { try { await super.start(); } catch (_error) { } } async close() { } }; // src/client/CLIClient.ts import { Client } from "@modelcontextprotocol/sdk/client/index.js"; var CLIClient = class extends Client { /** * Connect to MCP server with automatic authentication handling * If the first connection fails due to auth, it will wait for the OAuth flow * to complete and then retry the connection */ async connect(transport) { try { await super.connect(transport); } catch (error) { if (error instanceof Error) { if (error.message === "Unauthorized") { console.log("Authorization required, waiting for user to complete OAuth flow..."); const authProvider = transport.authProvider; await authProvider.waitForAuthorizationCode(); console.log("Authorization completed."); return await super.connect(transport); } } throw error; } } }; export { CLIAuthProvider, CLIClient, CivicAuthProvider, DEFAULT_CALLBACK_PORT, DEFAULT_MCP_ROUTE, DEFAULT_SCOPES, DEFAULT_WELLKNOWN_URL, InMemoryTokenPersistence, PUBLIC_CIVIC_CLIENT_ID, RestartableStreamableHTTPClientTransport, TokenAuthProvider }; //# sourceMappingURL=index.js.map