@civic/auth-mcp
Version:
Civic Auth integration for MCP servers
346 lines (339 loc) • 10.8 kB
JavaScript
// 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