Skip to main content
Glama
server-remote.ts17.4 kB
import * as fs from 'fs'; import * as path from 'path'; import * as https from 'https'; import { randomUUID } from 'node:crypto'; import fetch from 'node-fetch'; import express from 'express'; import rateLimit from 'express-rate-limit'; import cors from 'cors'; import jwt, { JwtHeader, SigningKeyCallback } from 'jsonwebtoken'; import jwksClient from 'jwks-rsa'; import * as oidc_client from 'openid-client'; import { mcpAuthMetadataRouter, getOAuthProtectedResourceMetadataUrl } from '@modelcontextprotocol/sdk/server/auth/router.js'; import { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import { loadTools } from './toolLoader.js'; // Load configuration const configPath = path.resolve( path.dirname(new URL(import.meta.url).pathname), '../config/config.json' ); const configData = JSON.parse(fs.readFileSync(configPath, 'utf-8')); // Always read the latest config from the file system function getConfigData() { return JSON.parse(fs.readFileSync(configPath, 'utf-8')); } export const USER_AGENT = 'OFBiz-MCP-server'; export const BACKEND_API_BASE = configData.BACKEND_API_BASE; const BACKEND_API_AUDIENCE = configData.BACKEND_API_AUDIENCE; const BACKEND_API_RESOURCE = configData.BACKEND_API_RESOURCE; const BACKEND_ACCESS_TOKEN = () => getConfigData().BACKEND_ACCESS_TOKEN; const MCP_SERVER_BASE_URL = configData.MCP_SERVER_BASE_URL; const MCP_SERVER_DNS_REBINDING_PROTECTION_ALLOWED_HOSTS = configData.MCP_SERVER_DNS_REBINDING_PROTECTION_ALLOWED_HOSTS || []; const MCP_SERVER_DNS_REBINDING_PROTECTION_ALLOWED_ORIGINS = configData.MCP_SERVER_DNS_REBINDING_PROTECTION_ALLOWED_ORIGINS || []; const AUTHZ_SERVER_BASE_URL = configData.AUTHZ_SERVER_BASE_URL; const SCOPES_SUPPORTED = configData.SCOPES_SUPPORTED; const MCP_SERVER_CLIENT_ID = configData.MCP_SERVER_CLIENT_ID; const MCP_SERVER_CLIENT_SECRET = configData.MCP_SERVER_CLIENT_SECRET; const TOKEN_EXCHANGE_SCOPE = configData.TOKEN_EXCHANGE_SCOPE || []; // Server configuration const SERVER_PORT = configData.SERVER_PORT; const RATE_LIMIT_WINDOW_MS = configData.RATE_LIMIT_WINDOW_MS || 60000; // default 1 minute const RATE_LIMIT_MAX_REQUESTS = configData.RATE_LIMIT_MAX_REQUESTS || 100; // default 100 requests // TLS support configuration (optional) const TLS_KEY_PATH = configData.TLS_KEY_PATH || ''; const TLS_CERT_PATH = configData.TLS_CERT_PATH || ''; const TLS_KEY_PASSPHRASE = configData.TLS_KEY_PASSPHRASE; const enableAuth = MCP_SERVER_BASE_URL && AUTHZ_SERVER_BASE_URL; const enableHttps = TLS_KEY_PATH && TLS_CERT_PATH; // Function to fetch JWKS URI from OpenID Connect metadata async function getJwksUri(issuer: string): Promise<string> { const res = await fetch(`${issuer}/.well-known/openid-configuration`); if (!res.ok) throw new Error(`Failed to fetch metadata: ${res.statusText}`); const metadata = (await res.json()) as Record<string, unknown>; const jwks = metadata['jwks_uri']; if (typeof jwks !== 'string') { throw new Error("Invalid OpenID metadata: 'jwks_uri' missing or not a string"); } return jwks; } // Create a JWKS client to retrieve the public key const client = !enableAuth ? null : jwksClient({ jwksUri: await getJwksUri(AUTHZ_SERVER_BASE_URL), cache: true, // enable local caching cacheMaxEntries: 5, // maximum number of keys stored cacheMaxAge: 10 * 60 * 1000 // 10 minutes }); // Function to get the public key from the JWT's kid function getKey(header: JwtHeader, callback: SigningKeyCallback) { if (!client) { return callback(new Error('JWKS client not initialized')); } if (!header.kid) { return callback(new Error("Missing 'kid' in token header")); } client.getSigningKey(header.kid, (err, key) => { if (err) return callback(err, undefined); const signingKey = key?.getPublicKey(); callback(null, signingKey); }); } export interface ValidationResult { valid: boolean; clientId?: string; scopes?: string[]; userId?: string; audience?: string | string[]; subjectToken?: string; downstreamToken?: string | null; } export interface AuthenticatedRequest extends express.Request { auth?: ValidationResult; } async function validateAccessToken(token: string): Promise<ValidationResult> { try { // Using JWT tokens, validate locally const result = await new Promise<jwt.JwtPayload>((resolve, reject) => { // Decode the token header to obtain the algorithm const decodedToken = jwt.decode(token, { complete: true }) as { header?: JwtHeader } | null; const alg = decodedToken?.header?.alg; if (!alg) return reject(new Error("Missing 'alg' in token header")); jwt.verify( token, getKey, { algorithms: [alg as jwt.Algorithm], audience: MCP_SERVER_CLIENT_ID, issuer: AUTHZ_SERVER_BASE_URL }, (err, decoded) => { if (err) return reject(err); resolve(decoded as jwt.JwtPayload); } ); }); return { valid: true, clientId: result.client_id, scopes: result.scope?.split(' '), userId: result.sub, audience: result.aud, subjectToken: token, downstreamToken: null }; } catch (error) { console.error('Token validation error:', error); return { valid: false }; } } // Middleware to check for valid access token const authenticateRequest = async ( req: express.Request, res: express.Response, next: express.NextFunction ) => { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { // Return 401 with WWW-Authenticate header pointing to metadata res .status(401) .set('WWW-Authenticate', `Bearer realm="mcp", as_uri="${resourceMetadataUrl}"`) .json({ jsonrpc: '2.0', error: { code: -32001, message: 'Authorization required' }, id: null }); return; } const token = authHeader.substring(7); try { // Validate the access token const validationResult = await validateAccessToken(token); if (!validationResult.valid) { res .status(401) .set( 'WWW-Authenticate', `Bearer realm="mcp", error="invalid_token", as_uri="${resourceMetadataUrl}"` ) .json({ jsonrpc: '2.0', error: { code: -32001, message: 'Invalid or expired token' }, id: null }); return; } // Attach user/token info to request for use in handlers (req as AuthenticatedRequest).auth = validationResult; next(); } catch (error) { console.error('Authentication error:', error); res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error during authentication' }, id: null }); } }; // // MCP server acting as a OAuth client in order to perform token exchanges // /** * Get or create a cached OpenID Configuration instance. */ let cachedAuthServerConfig: oidc_client.Configuration | null = null; async function getOAuthServerConfiguration(): Promise<oidc_client.Configuration> { if (cachedAuthServerConfig) return cachedAuthServerConfig; try { cachedAuthServerConfig = await oidc_client.discovery( new URL(AUTHZ_SERVER_BASE_URL), MCP_SERVER_CLIENT_ID, MCP_SERVER_CLIENT_SECRET, undefined, { execute: [oidc_client.allowInsecureRequests] } ); return cachedAuthServerConfig; } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); console.error('Failed to initialize OpenID client:', errorMessage); throw new Error('Failed to initialize OAuth client for token exchange'); } } /** * Performs an OAuth 2.0 Token Exchange (RFC 8693). * * @param subjectToken - The access token received from the client or another API * @returns The new access token to use for calling a downstream API */ async function performTokenExchange(subjectToken: string): Promise<string | null> { try { // Discover the Authorization Server's configuration const authServerConfig = await getOAuthServerConfiguration(); // Execute the token exchange request const response = await oidc_client.genericGrantRequest( authServerConfig, 'urn:ietf:params:oauth:grant-type:token-exchange', { subject_token: subjectToken, subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', requested_token_type: 'urn:ietf:params:oauth:token-type:access_token', scope: TOKEN_EXCHANGE_SCOPE.join(' '), resource: BACKEND_API_RESOURCE, audience: BACKEND_API_AUDIENCE } ); // Verify the response contains the expected access token if (!response?.access_token) { console.error('Token exchange succeeded but no access_token was returned:', response); return null; } return response.access_token; } catch (err: unknown) { // Handle specific openid-client errors /* if (err instanceof oidc_client.ClientError) { console.error("OAuth/OIDC error:", err.error, err.error_description); if (err.response) { console.error("Response details:", await err.response.text()); } } else if (err instanceof TypeError) { console.error("Network or configuration error:", err.message); } else { console.error("Unexpected error:", err); } */ console.error('Error during token exchange:', err); return null; } } const handleMcpRequest = async (req: express.Request, res: express.Response) => { // Check for existing session ID const sessionId = req.headers['mcp-session-id'] as string | undefined; let transport: StreamableHTTPServerTransport; if (sessionId && getTransport(sessionId)) { // Reuse existing transport transport = getTransport(sessionId)!; } else if (!sessionId && isInitializeRequest(req.body)) { const enableDnsRebindingProtection = MCP_SERVER_DNS_REBINDING_PROTECTION_ALLOWED_HOSTS.length > 0; // New initialization request transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (sessionId) => { // Store the transport by session ID addSession(sessionId, transport); }, enableDnsRebindingProtection: enableDnsRebindingProtection, allowedHosts: MCP_SERVER_DNS_REBINDING_PROTECTION_ALLOWED_HOSTS, allowedOrigins: MCP_SERVER_DNS_REBINDING_PROTECTION_ALLOWED_ORIGINS }); // Clean up transport when closed transport.onclose = () => { if (transport.sessionId) { deleteSession(transport.sessionId); } }; const server = new McpServer({ name: 'Apache OFBiz MCP Server (Streamable HTTP)', version: '0.1.0' }); // Load and register tools from external files async function registerTools() { try { const tools = await loadTools(); for (const tool of tools) { server.registerTool(tool.name, tool.metadata, tool.handler); console.error(`Registered tool: ${tool.name}`); } } catch (error) { console.error('Error loading tools:', error); throw error; } } // Set up server resources, tools, and prompts await registerTools(); // Connect to the MCP server await server.connect(transport); } else { // Invalid request res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: No valid session ID provided' }, id: null }); return; } // Prepare downstream token // Get or perform token exchange if authentication is enabled const authReq = req as AuthenticatedRequest; if (!authReq.auth) { authReq.auth = { valid: !enableAuth, downstreamToken: null }; } if (sessionId && authReq.auth.valid) { let downstreamToken = getDownstreamToken(sessionId); if (!downstreamToken) { if (enableAuth && authReq.auth.valid && MCP_SERVER_CLIENT_ID && MCP_SERVER_CLIENT_SECRET) { downstreamToken = await performTokenExchange(authReq.auth.subjectToken!); } if (downstreamToken) { setDownstreamToken(sessionId, downstreamToken); } else { // No downstream token obtained from token exchange, fallback to static token downstreamToken = BACKEND_ACCESS_TOKEN(); } } if (downstreamToken) { authReq.auth.downstreamToken = downstreamToken; } } // Handle the request console.log(`Processing request for session ${sessionId}`); await transport.handleRequest(req, res, req.body); console.log(`Completed request for session ${sessionId}`); }; // Reusable handler for GET and DELETE requests const handleSessionRequest = async (req: express.Request, res: express.Response) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; if (!sessionId || !getTransport(sessionId)) { res.status(400).send('Invalid or missing session ID'); return; } const transport = getTransport(sessionId); await transport?.handleRequest(req, res); }; // Map to store transports by session ID const sessions = new Map< string, { transport: StreamableHTTPServerTransport; downstreamToken: string | null } >(); // Helper functions to access the transports map function getTransport(sessionId: string): StreamableHTTPServerTransport | undefined { const entry = sessions.get(sessionId); return entry?.transport; } function getDownstreamToken(sessionId: string): string | null { const entry = sessions.get(sessionId); if (!entry) return null; return entry.downstreamToken; } function addSession(sessionId: string, transport: StreamableHTTPServerTransport): void { sessions.set(sessionId, { transport, downstreamToken: null }); } function deleteSession(sessionId?: string): void { if (!sessionId) return; sessions.delete(sessionId); } function setDownstreamToken(sessionId: string, downstreamToken: string): void { const entry = sessions.get(sessionId); if (!entry) return; entry.downstreamToken = downstreamToken; } // Precompute resource metadata URL const resourceMetadataUrl = enableAuth ? getOAuthProtectedResourceMetadataUrl(new URL(MCP_SERVER_BASE_URL)) : ''; // ====================================================================== // Initialization of Express app // ====================================================================== const app = express(); app.use(express.json()); // CORS configuration app.use( cors({ origin: configData.MCP_SERVER_CORS_ORIGINS, exposedHeaders: ['Mcp-Session-Id'] }) ); // Rate limiting to prevent abuse const limiter = rateLimit({ windowMs: RATE_LIMIT_WINDOW_MS, max: RATE_LIMIT_MAX_REQUESTS, standardHeaders: true, legacyHeaders: false }); app.use(limiter); if (enableAuth) { // Handle OAuth Protected Resource Metadata endpoint (RFC9728) const oauthMetadata: OAuthMetadata = { issuer: new URL(AUTHZ_SERVER_BASE_URL).toString(), introspection_endpoint: '', authorization_endpoint: '', token_endpoint: '', registration_endpoint: '', // optional response_types_supported: ['code'] }; app.use( mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: new URL(MCP_SERVER_BASE_URL), scopesSupported: SCOPES_SUPPORTED, resourceName: 'Apache OFBiz MCP Server' // optional }) ); // Handle POST, GET and DELETE requests for authenticated client-to-server communication app.post('/mcp', authenticateRequest, handleMcpRequest); app.get('/mcp', authenticateRequest, handleSessionRequest); app.delete('/mcp', authenticateRequest, handleSessionRequest); } else { // Handle POST, GET and DELETE requests for unauthenticated client-to-server communication app.post('/mcp', handleMcpRequest); app.get('/mcp', handleSessionRequest); app.delete('/mcp', handleSessionRequest); } if (enableHttps) { try { // Resolve key/cert relative to the project if paths provided in config are relative const base = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'); const keyPath = path.isAbsolute(TLS_KEY_PATH) ? TLS_KEY_PATH : path.join(base, TLS_KEY_PATH); const certPath = path.isAbsolute(TLS_CERT_PATH) ? TLS_CERT_PATH : path.join(base, TLS_CERT_PATH); const key = fs.readFileSync(keyPath); const cert = fs.readFileSync(certPath); const serverOptions: https.ServerOptions = { key, cert, passphrase: TLS_KEY_PASSPHRASE }; const httpsServer = https.createServer(serverOptions, app); httpsServer.listen(SERVER_PORT, () => { console.log( `MCP stateful Streamable HTTPS Server listening on port ${SERVER_PORT} with ${enableAuth ? 'authentication' : 'no authentication'}.` ); }); } catch (err) { console.error('Failed to start HTTPS server:', err); process.exit(1); } } else { app.listen(SERVER_PORT, () => { console.log( `MCP stateful Streamable HTTP Server listening on port ${SERVER_PORT} with ${enableAuth ? 'authentication' : 'no authentication'}.` ); }); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/jacopoc/mcp-for-apache-ofbiz'

If you have feedback or need assistance with the MCP directory API, please join our Discord server