@hellocoop/admin-mcp
Version:
Model Context Protocol (MCP) for HellΕ Admin API.
237 lines (197 loc) β’ 7.16 kB
JavaScript
// MCP CLI with OAuth flow and stdio transport
// Launches OAuth flow to get tokens and provides stdio interface
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { HelloMCPServer } from './mcp-server.js';
import { WALLET_BASE_URL, MCP_STDIO_CLIENT_ID } from './oauth-endpoints.js';
import { pkce } from '@hellocoop/helper-server';
import getPort from 'get-port';
import http from 'http';
import url from 'url';
import open from 'open';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
class MCPCLIServer {
constructor() {
this.mcpServer = new HelloMCPServer('stdio');
this.accessToken = null;
this.localServer = null;
this.localPort = null; // Will be set dynamically
this.authInProgress = false;
this.authPromise = null;
}
async start() {
// Set up lazy authentication callback
this.mcpServer.setAuthenticationCallback(() => this.ensureAuthenticated());
// Start stdio transport
const transport = new StdioServerTransport();
await this.mcpServer.mcpServer.connect(transport);
}
async ensureAuthenticated() {
if (this.accessToken) {
return this.accessToken;
}
if (this.authInProgress) {
return this.authPromise;
}
this.authInProgress = true;
this.authPromise = this.performOAuthFlow();
try {
const token = await this.authPromise;
return token;
} finally {
this.authInProgress = false;
this.authPromise = null;
}
}
async performOAuthFlow() {
try {
// Generate PKCE parameters
const pkceMaterial = await pkce();
const state = crypto.randomUUID();
const nonce = crypto.randomUUID();
// Store OAuth parameters for later use
this.oauthParams = {
pkceMaterial,
state,
nonce
};
// Start local callback server (without auth URL yet)
const authCode = await this.startCallbackServer(state);
// Exchange code for token
const tokenResponse = await this.exchangeCodeForToken({
code: authCode,
code_verifier: pkceMaterial.code_verifier,
client_id: MCP_STDIO_CLIENT_ID,
redirect_uri: `http://localhost:${this.localPort}/callback`
});
this.accessToken = tokenResponse.access_token;
return this.accessToken;
} catch (error) {
throw error;
}
}
createAuthorizationUrl(params) {
const urlParams = new URLSearchParams({
client_id: params.client_id,
redirect_uri: params.redirect_uri,
scope: params.scope.join(' '),
response_type: 'code',
response_mode: 'query',
code_challenge: params.code_challenge,
code_challenge_method: params.code_challenge_method,
state: params.state,
nonce: params.nonce
});
return `${WALLET_BASE_URL}/authorize?${urlParams.toString()}`;
}
async startCallbackServer(expectedState) {
return new Promise(async (resolve, reject) => {
// Get any available port
this.localPort = await getPort();
const server = http.createServer((req, res) => {
const parsedUrl = url.parse(req.url, true);
if (parsedUrl.pathname === '/callback') {
const { code, state, error } = parsedUrl.query;
if (error) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end(`<h1>Authentication Error</h1><p>${error}</p>`);
reject(new Error(`OAuth error: ${error}`));
return;
}
if (state !== expectedState) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end('<h1>Authentication Error</h1><p>Invalid state parameter</p>');
reject(new Error('Invalid state parameter'));
return;
}
if (!code) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end('<h1>Authentication Error</h1><p>No authorization code received</p>');
reject(new Error('No authorization code received'));
return;
}
res.writeHead(200, { 'Content-Type': 'text/html' });
const successHtml = fs.readFileSync(path.join(__dirname, 'html', 'auth-success.html'), 'utf8');
res.end(successHtml);
resolve(code);
} else if (parsedUrl.pathname === '/auth/start') {
// Generate authorization URL when user clicks the button
const authUrl = this.createAuthorizationUrl({
client_id: MCP_STDIO_CLIENT_ID,
redirect_uri: `http://localhost:${this.localPort}/callback`,
scope: ['mcp'],
code_challenge: this.oauthParams.pkceMaterial.code_challenge,
code_challenge_method: 'S256',
state: this.oauthParams.state,
nonce: this.oauthParams.nonce
});
// Redirect to authorization URL
res.writeHead(302, { 'Location': authUrl });
res.end();
} else {
// Serve the login page for all other requests
res.writeHead(200, { 'Content-Type': 'text/html' });
const loginHtml = fs.readFileSync(path.join(__dirname, 'html', 'auth-login.html'), 'utf8');
res.end(loginHtml);
}
});
server.listen(this.localPort, () => {
const serverUrl = `http://localhost:${this.localPort}`;
console.error(`π MCP Authentication server started: ${serverUrl}`);
console.error(`π± Opening browser automatically...`);
open(serverUrl);
});
server.on('error', (err) => {
reject(new Error(`Failed to start callback server: ${err.message}`));
});
this.localServer = server;
});
}
async exchangeCodeForToken(params) {
const tokenEndpoint = `${WALLET_BASE_URL}/oauth/token`;
const body = new URLSearchParams({
grant_type: 'authorization_code',
code: params.code,
code_verifier: params.code_verifier,
client_id: params.client_id,
redirect_uri: params.redirect_uri
});
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: body.toString()
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token exchange failed: ${response.status} ${errorText}`);
}
const tokenData = await response.json();
if (!tokenData.access_token) {
throw new Error('No access_token in response');
}
return tokenData;
}
async stopCallbackServer() {
if (this.localServer) {
return new Promise((resolve) => {
this.localServer.close(() => {
this.localServer = null;
resolve();
});
});
}
}
}
// Start the CLI server
const cli = new MCPCLIServer();
cli.start().catch((error) => {
console.error('Failed to start MCP CLI:', error);
process.exit(1);
});