Skip to main content
Glama

SAP OData to MCP Server

by Raistlin82
auth-server.ts70.2 kB
import express, { Request, Response, NextFunction } from 'express'; import path from 'path'; import { fileURLToPath } from 'url'; import cors from 'cors'; import helmet from 'helmet'; import { Logger } from '../utils/logger.js'; import { IASAuthService, TokenData } from './ias-auth-service.js'; import { TokenStore, StoredTokenData } from './token-store.js'; import { authMiddleware, AuthenticatedRequest, requireAdmin } from '../middleware/auth.js'; import xssec from '@sap/xssec'; import xsenv from '@sap/xsenv'; import { SESSION_LIFETIMES } from '../constants/timeouts.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); export interface AuthServerOptions { port?: number; corsOrigins?: string[]; enableHelmet?: boolean; } export class AuthServer { private app: express.Application; private logger: Logger; private iasAuthService: IASAuthService; private tokenStore: TokenStore; private server: any; constructor(options: AuthServerOptions = {}) { this.app = express(); this.logger = new Logger('AuthServer'); try { this.logger.debug('Initializing IASAuthService...'); this.iasAuthService = new IASAuthService(this.logger); this.logger.debug('IASAuthService initialized successfully'); this.logger.debug('Initializing TokenStore...'); this.tokenStore = new TokenStore(this.logger); this.logger.debug('TokenStore initialized successfully'); this.logger.debug('Setting up middleware...'); this.setupMiddleware(options); this.logger.debug('Middleware setup completed'); this.logger.debug('Setting up routes...'); this.setupRoutes(); this.logger.debug('Routes setup completed'); this.logger.info('✅ AuthServer constructor completed successfully'); } catch (error) { this.logger.error('❌ AuthServer constructor failed:', error); throw error; } } private setupMiddleware(options: AuthServerOptions): void { // Security middleware if (options.enableHelmet !== false) { this.app.use( helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-hashes'"], // Allow inline scripts and event handlers scriptSrcAttr: ["'unsafe-inline'"], // Allow inline event handlers like onclick styleSrc: ["'self'", "'unsafe-inline'"], // Allow inline styles imgSrc: ["'self'", 'data:', 'https:'], connectSrc: ["'self'", 'https:'], // Allow HTTPS connections fontSrc: ["'self'", 'https:', 'data:'], objectSrc: ["'none'"], mediaSrc: ["'self'"], frameSrc: ["'none'"], }, }, crossOriginResourcePolicy: false, }) ); } // CORS configuration this.app.use( cors({ origin: options.corsOrigins || ['http://localhost:3000', 'http://127.0.0.1:3000'], credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization', 'x-mcp-session-id'], }) ); // Body parsing middleware this.app.use(express.json({ limit: '10mb' })); this.app.use(express.urlencoded({ extended: true })); // Static files for the login page this.app.use('/static', express.static(path.join(__dirname, '../public'))); // Request logging this.app.use((req, res, next) => { this.logger.debug(`${req.method} ${req.path}`, { ip: req.ip, userAgent: req.get('User-Agent'), sessionId: req.headers['x-mcp-session-id'], }); next(); }); } private setupRoutes(): void { // Health check endpoint this.app.get('/health', (req: Request, res: Response) => { res.json({ status: 'healthy', timestamp: new Date().toISOString(), uptime: process.uptime(), memory: process.memoryUsage(), }); }); // Serve login page (note: this will be mounted at /login by main app) this.app.get('/', (req: Request, res: Response) => { res.sendFile(path.join(__dirname, '../public/login.html')); }); // Also handle explicit /login route for cases where it's mounted at root this.app.get('/login', (req: Request, res: Response) => { res.sendFile(path.join(__dirname, '../public/login.html')); }); // Global authentication endpoint - no client configuration required this.app.post('/login', async (req: Request, res: Response) => { try { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ success: false, error: 'Missing credentials', message: 'Username and password are required', }); } // Authenticate with SAP IAS using password grant const tokenData = await this.iasAuthService.authenticateUser(username, password); // Store as global authentication (not session-specific) const globalAuthKey = 'global_user_auth'; const clientInfo = { userAgent: req.get('User-Agent'), ipAddress: req.ip, clientId: 'global-auth', authenticatedAt: Date.now(), }; await this.tokenStore.set(tokenData, clientInfo, globalAuthKey); this.logger.info(`Global authentication successful: ${tokenData.user}`); res.json({ success: true, message: 'Authentication successful - server will remember your credentials', user: tokenData.user, authenticatedAt: new Date().toISOString(), note: 'No client configuration required - MCP tools will work automatically', }); } catch (error) { this.logger.error('Global authentication failed:', error); res.status(401).json({ success: false, error: 'Authentication failed', message: error instanceof Error ? error.message : 'Invalid username or password', }); } }); // OAuth 2.0 Authorization endpoint this.app.get('/authorize', (req: Request, res: Response) => { try { const { response_type, client_id, redirect_uri, scope, state } = req.query; // Validate required parameters if (!response_type || !client_id || !redirect_uri) { return res.status(400).json({ error: 'invalid_request', error_description: 'Missing required parameters: response_type, client_id, redirect_uri', }); } if (response_type !== 'code') { return res.status(400).json({ error: 'unsupported_response_type', error_description: 'Only authorization code flow is supported', }); } // Generate authorization URL and redirect to IAS const iasAuthUrl = this.iasAuthService.generateAuthorizationUrl( redirect_uri as string, state as string ); this.logger.info(`Redirecting to IAS authorization: ${iasAuthUrl}`); res.redirect(iasAuthUrl); } catch (error) { this.logger.error('Authorization endpoint error:', error); res.status(500).json({ error: 'server_error', error_description: 'Internal server error during authorization', }); } }); // OAuth 2.0 callback endpoint this.app.get('/callback', async (req: Request, res: Response) => { try { const { code, state, error, error_description } = req.query; if (error) { this.logger.error(`OAuth callback error: ${error} - ${error_description}`); return res.status(400).json({ error: error as string, error_description: (error_description as string) || 'Authorization failed', }); } if (!code) { return res.status(400).json({ error: 'invalid_request', error_description: 'Missing authorization code', }); } // Exchange code for tokens - force HTTPS for Cloud Foundry deployments const protocol = req.get('host')?.includes('.cfapps.') || req.get('host')?.includes('.ondemand.com') ? 'https' : req.protocol; const redirectUri = `${protocol}://${req.get('host')}/auth/callback`; this.logger.debug(`Constructed redirect URI for token exchange: ${redirectUri}`); this.logger.debug( `Request protocol: ${req.protocol}, forced protocol: ${protocol}, host: ${req.get('host')}` ); const tokenData = await this.iasAuthService.exchangeCodeForTokens( code as string, redirectUri ); // Store token with client info const clientInfo = { userAgent: req.get('User-Agent'), ipAddress: req.ip, clientId: `oauth2-${Date.now()}`, }; // Clean up any existing sessions for this user to prevent duplicates await this.tokenStore.removeUserSessions(tokenData.user); const sessionId = await this.tokenStore.set(tokenData, clientInfo); // Store as global authentication for MCP client compatibility (replacing any existing) const globalAuthKey = 'global_user_auth'; const globalClientInfo = { userAgent: req.get('User-Agent'), ipAddress: req.ip, clientId: 'browser-oauth-global', authenticatedAt: Date.now(), }; await this.tokenStore.set(tokenData, globalClientInfo, globalAuthKey); this.logger.info( `OAuth user authenticated successfully: ${tokenData.user}, session: ${sessionId}, global: ${globalAuthKey}` ); // Redirect to success page with session ID res.redirect(`/login?session=${sessionId}&success=true`); } catch (error) { this.logger.error('OAuth callback failed:', error); res.redirect(`/login?error=${encodeURIComponent('Authentication failed')}`); } }); // Authentication status endpoint this.app.get('/status', async (req: Request, res: Response) => { try { const sessionId = req.headers['x-mcp-session-id'] as string; if (!sessionId) { return res.json({ authenticated: false, message: 'No session ID provided' }); } const tokenData = await this.tokenStore.get(sessionId); if (!tokenData) { return res.json({ authenticated: false, message: 'Session not found or expired' }); } res.json({ authenticated: true, sessionId: tokenData.sessionId, user: tokenData.user, expiresAt: tokenData.expiresAt, scopes: tokenData.scopes, }); } catch (error) { this.logger.error('Error checking auth status:', error); res.status(500).json({ authenticated: false, error: 'Internal server error', }); } }); // Direct token authentication endpoint (for CLI/curl users) this.app.post('/cli-auth', async (req: Request, res: Response) => { try { const { access_token, method = 'cli' } = req.body; if (!access_token) { return res.status(400).json({ success: false, error: 'Missing access_token', message: 'Please provide the access_token obtained from the curl command', }); } // Validate the token with IAS - try user info first, fallback to validation for client credentials let tokenData; let user = 'unknown'; let scopes = ['read', 'write']; try { // Try to get user info (works for user tokens) const userInfo = await this.iasAuthService.getUserInfo(access_token); user = userInfo.preferred_username || userInfo.email || userInfo.sub; scopes = userInfo.scope || ['read', 'write']; } catch (error) { this.logger.debug( 'User info failed, trying token validation (likely client credentials token)' ); // For client credentials tokens, we can't get user info, but we can validate the token const isValid = await this.iasAuthService.validateToken(access_token); if (!isValid) { throw new Error('Invalid or expired access token'); } // For client credentials, use the client ID as user and assign appropriate scopes user = 'client-credentials-user'; scopes = ['read', 'write', 'delete', 'admin']; // Client credentials get broader access } tokenData = { token: access_token.startsWith('Bearer ') ? access_token : `Bearer ${access_token}`, user: user, scopes: scopes, expiresAt: Date.now() + SESSION_LIFETIMES.AUTH_TOKEN_EXPIRY, // 8 hours for better MCP client experience refreshToken: undefined, }; // Store token with client info const clientInfo = { userAgent: req.get('User-Agent'), ipAddress: req.ip, clientId: `${method}-${Date.now()}`, }; const sessionId = await this.tokenStore.set(tokenData, clientInfo); this.logger.info(`CLI authentication successful: ${tokenData.user}, session: ${sessionId}`); res.json({ success: true, sessionId: sessionId, user: tokenData.user, expiresAt: tokenData.expiresAt, scopes: tokenData.scopes, message: 'Authentication successful', instructions: { mcp_header: `Add this header to your MCP requests: x-mcp-session-id: ${sessionId}`, environment: `export SAP_MCP_SESSION_ID="${sessionId}"`, }, }); } catch (error) { this.logger.error('CLI authentication failed:', error); res.status(401).json({ success: false, error: 'CLI authentication failed', message: error instanceof Error ? error.message : 'Invalid or expired token', }); } }); // Token generation endpoint (supports multiple methods) this.app.post('/token', async (req: Request, res: Response) => { try { const { grant_type, username, password, code, redirect_uri, refresh_token } = req.body; let tokenData: TokenData; let sessionId: string; if (grant_type === 'authorization_code') { // OAuth 2.0 Authorization Code flow if (!code || !redirect_uri) { return res.status(400).json({ error: 'invalid_request', error_description: 'Missing required parameters: code, redirect_uri', }); } tokenData = await this.iasAuthService.exchangeCodeForTokens(code, redirect_uri); } else if (grant_type === 'refresh_token') { // Token refresh if (!refresh_token) { return res.status(400).json({ error: 'invalid_request', error_description: 'Missing refresh_token parameter', }); } tokenData = await this.iasAuthService.refreshToken(refresh_token); } else if (grant_type === 'client_credentials') { // Client credentials flow tokenData = await this.iasAuthService.getClientCredentialsToken(); } else { // Fallback to password flow (deprecated) if (!username || !password) { return res.status(400).json({ success: false, error: 'Missing credentials', message: 'Username and password are required', }); } tokenData = await this.iasAuthService.authenticateUser(username, password); } // Store token with client info const clientInfo = { userAgent: req.get('User-Agent'), ipAddress: req.ip, clientId: req.get('x-client-id') || `${grant_type || 'password'}-${Date.now()}`, }; sessionId = await this.tokenStore.set(tokenData, clientInfo); this.logger.info( `User authenticated successfully: ${tokenData.user}, session: ${sessionId}` ); if ( grant_type === 'authorization_code' || grant_type === 'refresh_token' || grant_type === 'client_credentials' ) { // OAuth 2.0 standard response res.json({ access_token: tokenData.token.replace('Bearer ', ''), token_type: 'Bearer', expires_in: Math.floor((tokenData.expiresAt - Date.now()) / 1000), scope: tokenData.scopes.join(' '), refresh_token: tokenData.refreshToken, session_id: sessionId, }); } else { // Legacy response format res.json({ success: true, sessionId: sessionId, user: tokenData.user, expiresAt: tokenData.expiresAt, scopes: tokenData.scopes, message: 'Authentication successful', }); } } catch (error) { this.logger.error('Authentication failed:', error); let statusCode = 401; let errorCode = 'invalid_client'; let message = 'Authentication failed'; if (error instanceof Error) { if (error.message.includes('credentials')) { message = 'Invalid username or password'; errorCode = 'invalid_grant'; } else if (error.message.includes('configuration')) { statusCode = 500; message = 'Server configuration error'; errorCode = 'server_error'; } else if (error.message.includes('code')) { errorCode = 'invalid_grant'; message = 'Invalid authorization code'; } } res.status(statusCode).json({ error: errorCode, error_description: message, // Legacy format for backward compatibility success: false, message: message, }); } }); // Token refresh endpoint this.app.post('/refresh', async (req: Request, res: Response) => { try { const sessionId = req.headers['x-mcp-session-id'] as string; if (!sessionId) { return res.status(400).json({ success: false, error: 'Missing session ID', message: 'Session ID is required in headers', }); } const existingToken = await this.tokenStore.get(sessionId); if (!existingToken || !existingToken.refreshToken) { return res.status(404).json({ success: false, error: 'Invalid session', message: 'Session not found or no refresh token available', }); } // Refresh token with IAS const newTokenData = await this.iasAuthService.refreshToken(existingToken.refreshToken); // Update stored token await this.tokenStore.update(sessionId, newTokenData); res.json({ success: true, sessionId: sessionId, user: newTokenData.user, expiresAt: newTokenData.expiresAt, scopes: newTokenData.scopes, message: 'Token refreshed successfully', }); } catch (error) { this.logger.error('Token refresh failed:', error); res.status(401).json({ success: false, error: 'Token refresh failed', message: error instanceof Error ? error.message : 'Unknown error', }); } }); // OAuth 2.0 Authorization URL generation endpoint this.app.get('/auth-url', (req: Request, res: Response) => { try { const { redirect_uri, state } = req.query; if (!redirect_uri) { return res.status(400).json({ error: 'invalid_request', error_description: 'Missing redirect_uri parameter', }); } const authUrl = this.iasAuthService.generateAuthorizationUrl( redirect_uri as string, state as string ); res.json({ authorization_url: authUrl, state: state || null, }); } catch (error) { this.logger.error('Auth URL generation failed:', error); res.status(500).json({ error: 'server_error', error_description: 'Failed to generate authorization URL', }); } }); // Configuration download endpoint this.app.get('/config/:sessionId', async (req: Request, res: Response) => { try { const { sessionId } = req.params; const tokenData = await this.tokenStore.get(sessionId); if (!tokenData) { return res.status(404).json({ error: 'Session not found', message: 'The requested session does not exist or has expired', }); } // Generate configuration for download const config = { sap_mcp_session_id: sessionId, mcp_server_url: req.get('host') || 'localhost:3000', user: tokenData.user, expires_at: tokenData.expiresAt, created_at: tokenData.createdAt, scopes: tokenData.scopes, oauth2_config: this.iasAuthService.getConfiguration(), instructions: { desktop: 'Place this file at ~/.sap/mcp-config.json', environment: `export SAP_MCP_SESSION_ID="${sessionId}"`, oauth2: 'Use the authorization_url to authenticate via browser', }, }; res.setHeader('Content-Disposition', 'attachment; filename="mcp-sap-config.json"'); res.setHeader('Content-Type', 'application/json'); res.json(config); } catch (error) { this.logger.error('Config download failed:', error); res.status(500).json({ error: 'Internal server error', message: 'Failed to generate configuration', }); } }); // Logout endpoint this.app.post('/logout', async (req: Request, res: Response) => { try { const sessionId = req.headers['x-mcp-session-id'] as string; if (sessionId) { const removed = await this.tokenStore.remove(sessionId); if (removed) { this.logger.info(`User logged out, session removed: ${sessionId}`); } } res.json({ success: true, message: 'Logged out successfully', }); } catch (error) { this.logger.error('Logout failed:', error); res.status(500).json({ success: false, error: 'Logout failed', message: 'Internal server error', }); } }); // Admin endpoint to view sessions (protected) this.app.get( '/admin/sessions', authMiddleware, async (req: AuthenticatedRequest, res: Response) => { try { // Check admin scope if (!req.authInfo?.scopes.some(scope => scope.includes('admin'))) { return res.status(403).json({ error: 'Insufficient permissions', message: 'Admin scope required', }); } const stats = await this.tokenStore.getStats(); const sessions = await this.tokenStore.getAllSessions(); res.json({ stats, sessions: sessions.map(session => ({ sessionId: session.sessionId, user: session.user, createdAt: session.createdAt, lastUsedAt: session.lastUsedAt, expiresAt: session.expiresAt, scopes: session.scopes, clientInfo: session.clientInfo, })), }); } catch (error) { this.logger.error('Admin sessions endpoint failed:', error); res.status(500).json({ error: 'Internal server error', message: 'Failed to retrieve sessions', }); } } ); // Get token by session ID (for MCP server internal use) this.app.get('/internal/token/:sessionId', async (req: Request, res: Response) => { try { // This endpoint should be protected in production (internal network only) const { sessionId } = req.params; const tokenData = await this.tokenStore.get(sessionId); if (!tokenData) { return res.status(404).json({ error: 'Session not found', authenticated: false, }); } res.json({ authenticated: true, token: tokenData.token, user: tokenData.user, scopes: tokenData.scopes, expiresAt: tokenData.expiresAt, }); } catch (error) { this.logger.error('Internal token lookup failed:', error); res.status(500).json({ error: 'Internal server error', authenticated: false, }); } }); // Error handling middleware this.app.use((error: Error, req: Request, res: Response, next: NextFunction) => { this.logger.error('Unhandled error:', error); res.status(500).json({ error: 'Internal server error', message: 'An unexpected error occurred', }); }); // CLI authentication instructions endpoint this.app.get('/cli-instructions', (req: Request, res: Response) => { try { const serverUrl = req.get('host') || 'localhost:3000'; const config = this.iasAuthService.getConfiguration(); const curlCommand = `curl -X POST "${config.tokenEndpoint}" \\ -H "Content-Type: application/x-www-form-urlencoded" \\ -d "grant_type=client_credentials&client_id=${config.clientId}&client_secret=${process.env.SAP_IAS_CLIENT_SECRET || 'YOUR_CLIENT_SECRET'}"`; const instructions = { title: 'CLI Authentication Instructions', steps: [ { step: 1, description: 'Get an access token using curl', command: curlCommand, note: "This will return a JSON response with an 'access_token' field", }, { step: 2, description: 'Extract the access token from the response', example: `# The response will look like: # {"access_token":"eyJ...","token_type":"Bearer","expires_in":3600} # Copy the access_token value (without quotes)`, }, { step: 3, description: 'Authenticate with the MCP server', command: `curl -X POST "https://${serverUrl}/auth/cli-auth" \\ -H "Content-Type: application/json" \\ -d '{"access_token":"YOUR_ACCESS_TOKEN_HERE"}'`, note: 'Replace YOUR_ACCESS_TOKEN_HERE with the token from step 1', }, { step: 4, description: 'Use the session ID in MCP requests', example: `# The response will contain a sessionId # Add this header to your MCP requests: # x-mcp-session-id: YOUR_SESSION_ID`, }, ], alternatives: { web_login: `https://${serverUrl}/login`, oauth_direct: `${config.authorizationEndpoint}?client_id=${config.clientId}&response_type=code&redirect_uri=https://${serverUrl}/auth/callback&scope=openid profile email`, }, }; res.json(instructions); } catch (error) { this.logger.error('Failed to generate CLI instructions:', error); res.status(500).json({ error: 'Failed to generate instructions', details: error instanceof Error ? error.message : 'Unknown error', }); } }); // Configuration endpoint for debugging this.app.get('/debug/config', (req: Request, res: Response) => { try { const config = this.iasAuthService.getConfiguration(); // Show more configuration details for debugging const debugConfig = { ...config, clientId: config.clientId, clientSecret: config.clientId ? config.clientId.includes('dummy') ? 'DUMMY/NOT_CONFIGURED' : 'PROVIDED' : 'MISSING', isConfigured: this.iasAuthService.isProperlyConfigured(), supportedGrantTypes: config.supportedGrantTypes, supportedScopes: config.supportedScopes, endpoints: { authorization: config.authorizationEndpoint, token: config.tokenEndpoint, userInfo: config.userInfoEndpoint, introspection: config.introspectionEndpoint, }, }; res.json(debugConfig); } catch (error) { this.logger.error('Failed to get configuration:', error); res.status(500).json({ error: 'Configuration not available', details: error instanceof Error ? error.message : 'Unknown error', }); } }); // Alternative XSUAA direct authentication endpoint this.app.post('/xsuaa-auth', async (req: Request, res: Response) => { try { const { access_token } = req.body; if (!access_token) { return res.status(400).json({ error: 'Missing access_token', message: 'Please provide an XSUAA access token in the request body', }); } // Try to create XSUAA security context directly const services = xsenv.getServices({ xsuaa: { label: 'xsuaa' } }); const xsuaaCredentials = services.xsuaa; if (!xsuaaCredentials) { return res.status(500).json({ error: 'XSUAA service not available', message: 'XSUAA service binding not found', }); } // Create XSUAA security context directly with the provided token const securityContext = await new Promise<any>((resolve, reject) => { xssec.createSecurityContext( `Bearer ${access_token}`, xsuaaCredentials, (err: any, ctx: any) => { if (err) { reject(err); } else { resolve(ctx); } } ); }); if (securityContext) { const userInfo = securityContext.getTokenInfo(); const grantedScopes = securityContext.getGrantedScopes(); this.logger.info(`✅ XSUAA direct auth successful for: ${userInfo.getLogonName()}`); this.logger.info(`🎫 XSUAA scopes: ${grantedScopes.join(', ')}`); // Store the token with XSUAA scopes const tokenData: TokenData = { token: `Bearer ${access_token}`, user: userInfo.getLogonName(), scopes: grantedScopes, expiresAt: Date.now() + SESSION_LIFETIMES.AUTH_TOKEN_EXPIRY, // 8 hours for better MCP experience }; await this.tokenStore.set(tokenData, undefined, 'global_user_auth'); res.json({ success: true, message: 'XSUAA authentication successful', user: userInfo.getLogonName(), scopes: grantedScopes, hasAdminScope: grantedScopes.some((scope: string) => scope.includes('.admin')), }); } else { res.status(401).json({ error: 'Authentication failed', message: 'Could not create XSUAA security context', }); } } catch (error) { this.logger.error('XSUAA direct authentication failed:', error); res.status(401).json({ error: 'Authentication failed', message: error instanceof Error ? error.message : 'Unknown error', details: 'The provided XSUAA token is invalid or expired', }); } }); // Debug endpoint to check current user scopes and XSUAA configuration this.app.get('/debug/user-scopes', async (req: Request, res: Response) => { try { let userAuth = null; const scopeInfo: { hasGlobalAuth: boolean; hasSessionAuth: boolean; xsuaaConfig: any } = { hasGlobalAuth: false, hasSessionAuth: false, xsuaaConfig: null, }; // Try to get authentication info from session or global auth const sessionId = (req.query.session as string) || (req.headers['x-mcp-session-id'] as string); if (sessionId) { const tokenData = await this.tokenStore.get(sessionId); if (tokenData && Date.now() < tokenData.expiresAt) { userAuth = tokenData; scopeInfo.hasSessionAuth = true; } } // Try global session if no specific session found if (!userAuth) { const globalAuth = await this.tokenStore.get('global_user_auth'); if (globalAuth && Date.now() < globalAuth.expiresAt) { userAuth = globalAuth; scopeInfo.hasGlobalAuth = true; } } // Get XSUAA configuration for scope prefix analysis try { const services = xsenv.getServices({ xsuaa: { label: 'xsuaa' } }); scopeInfo.xsuaaConfig = services.xsuaa ? { xsappname: (services.xsuaa as any).xsappname, clientid: (services.xsuaa as any).clientid, url: (services.xsuaa as any).url, } : null; } catch (error) { this.logger.debug('XSUAA service not available:', error); } if (!userAuth) { return res.json({ success: false, message: 'No active authentication session found', scopeInfo, instructions: { sessionAuth: 'Add ?session=YOUR_SESSION_ID to check specific session', globalAuth: 'Login via /auth/login to create global auth', adminAccess: 'Admin access requires scope with format: {xsappname}.admin', }, }); } // Analyze scopes for admin access const xsappname = (scopeInfo.xsuaaConfig as any)?.xsappname || process.env.XSUAA_XSAPPNAME || 'btp-sap-odata-to-mcp-server'; const expectedAdminScope = `${xsappname}.admin`; const hasAdminScope = userAuth.scopes?.includes(expectedAdminScope) || userAuth.scopes?.some(scope => scope.includes('admin')); // Check for other scope variations const scopeAnalysis = { totalScopes: userAuth.scopes?.length || 0, scopes: userAuth.scopes || [], expectedAdminScope, hasAdminScope, adminScopeVariants: (userAuth.scopes || []).filter( scope => scope.includes('admin') || scope.includes('Admin') || scope.includes('ADMIN') ), allScopePatterns: (userAuth.scopes || []).map(scope => ({ scope, hasAppPrefix: scope.includes('.'), appName: scope.includes('.') ? scope.split('.')[0] : null, })), }; res.json({ success: true, user: userAuth.user, sessionType: scopeInfo.hasGlobalAuth ? 'Global' : 'Session', sessionId: userAuth.sessionId || sessionId, expiresAt: new Date(userAuth.expiresAt).toISOString(), scopeInfo, scopeAnalysis, adminAccessStatus: { hasAccess: hasAdminScope, reason: hasAdminScope ? 'Has required admin scope' : scopeAnalysis.adminScopeVariants.length > 0 ? 'Has admin-like scopes but not the expected format' : 'No admin scopes found', troubleshooting: hasAdminScope ? null : { expectedScope: expectedAdminScope, xsSecurityConfiguration: { definedRoleCollections: ( process.env.ROLE_COLLECTIONS || 'MCPAdministrator,MCPUser,MCPManager,MCPViewer' ).split(','), definedRoleTemplates: ( process.env.ROLE_TEMPLATES || 'MCPAdmin,MCPEditor,MCPManager,MCPViewer' ).split(','), adminRoleCollection: process.env.ADMIN_ROLE_COLLECTION || 'MCPAdministrator', adminRoleTemplate: 'MCPAdmin', }, xsappnameCheck: `Current xsappname: ${xsappname}`, suggestedActions: [ `Verify user is assigned to "${process.env.ADMIN_ROLE_COLLECTION || 'MCPAdministrator'}" role collection (not "mcpadmin")`, 'Check if XSUAA service was updated with latest xs-security.json', 'Confirm role collection exists and maps to MCPAdmin role template', 'Verify XSUAA service binding is properly configured', ], }, }, }); } catch (error) { this.logger.error('Failed to analyze user scopes:', error); res.status(500).json({ error: 'Internal server error', message: 'Failed to analyze user scopes', details: error instanceof Error ? error.message : 'Unknown error', }); } }); // Admin dashboard page - supports both browser sessions and API tokens this.app.get('/admin', async (req: Request, res: Response) => { try { let isAuthenticated = false; let hasAdminScope = false; let userInfo = null; // Try session-based authentication first (for browsers) const sessionId = (req.query.session as string) || (req.headers['x-mcp-session-id'] as string); if (sessionId) { try { const tokenData = await this.tokenStore.get(sessionId); if (tokenData && Date.now() < tokenData.expiresAt) { isAuthenticated = true; hasAdminScope = tokenData.scopes?.some(scope => scope.includes('admin')) || false; userInfo = { user: tokenData.user, scopes: tokenData.scopes }; this.logger.debug( `Session auth for admin page: ${tokenData.user}, admin: ${hasAdminScope}` ); } } catch (error) { this.logger.debug('Session auth failed:', error); } } // Try global session if no specific session found if (!isAuthenticated) { try { const globalAuth = await this.tokenStore.get('global_user_auth'); if (globalAuth && Date.now() < globalAuth.expiresAt) { isAuthenticated = true; hasAdminScope = globalAuth.scopes?.some(scope => scope.includes('admin')) || false; userInfo = { user: globalAuth.user, scopes: globalAuth.scopes }; this.logger.debug( `Global auth for admin page: ${globalAuth.user}, admin: ${hasAdminScope}` ); } } catch (error) { this.logger.debug('Global auth failed:', error); } } // Try JWT token authentication (for API access) if (!isAuthenticated) { const authHeader = req.headers.authorization; if (authHeader && authHeader.startsWith('Bearer ')) { try { const token = authHeader.substring(7); const services = xsenv.getServices({ xsuaa: { label: 'xsuaa' } }); const xsuaaCredentials = services.xsuaa as { xsappname: string }; const securityContext = await new Promise((resolve, reject) => { xssec.createSecurityContext(token, xsuaaCredentials, (err: any, ctx: any) => { if (err) reject(err); else resolve(ctx); }); }); const authInfo = this.extractAuthInfo(securityContext as any); // Use xsappname from XSUAA credentials for correct scope check const requiredScope = `${xsuaaCredentials.xsappname}.admin`; isAuthenticated = true; hasAdminScope = authInfo.scopes.includes(requiredScope); userInfo = { user: authInfo.user, scopes: authInfo.scopes }; this.logger.debug( `JWT auth for admin page: ${authInfo.user}, admin: ${hasAdminScope}` ); } catch (authError) { this.logger.debug('JWT auth failed:', authError); } } } // Check authentication and authorization if (!isAuthenticated) { return res.redirect(`/auth/?redirect=${encodeURIComponent('/auth/admin')}`); } if (!hasAdminScope) { return res.status(403).send(` <html> <head><title>Access Denied</title></head> <body> <h1>Access Denied</h1> <p>Admin privileges required. You need the ${process.env.ADMIN_ROLE_COLLECTION || 'MCPAdministrator'} role collection.</p> <p>Current user: ${userInfo?.user}</p> <p>Current scopes: ${userInfo?.scopes?.join(', ') || 'none'}</p> <p><a href="/auth/">Back to Login</a></p> </body> </html> `); } // User is authenticated and has admin scope - serve the admin dashboard res.sendFile(path.join(__dirname, '../public/admin.html')); } catch (error) { this.logger.error('Admin dashboard access failed:', error); res.status(500).send(` <html> <head><title>Error</title></head> <body> <h1>Internal Server Error</h1> <p>Failed to load admin dashboard</p> <p><a href="/auth/">Back to Login</a></p> </body> </html> `); } }); // Admin endpoint to view all authenticated users and their roles/scopes this.app.get('/admin/users', async (req: Request, res: Response) => { try { // Session-based authentication check let isAuthenticated = false; let hasAdminScope = false; let authenticatedUser = 'Unknown'; // Try session-based authentication first const sessionId = (req.query.session as string) || (req.headers['x-mcp-session-id'] as string); this.logger.debug(`Admin /admin/users endpoint - sessionId: ${sessionId}`); if (sessionId) { const tokenData = await this.tokenStore.get(sessionId); this.logger.debug(`Admin /admin/users endpoint - tokenData found: ${!!tokenData}`); if (tokenData) { this.logger.debug( `Admin /admin/users endpoint - tokenData.expiresAt: ${tokenData.expiresAt}, now: ${Date.now()}, valid: ${Date.now() < tokenData.expiresAt}` ); this.logger.debug( `Admin /admin/users endpoint - tokenData.scopes: ${JSON.stringify(tokenData.scopes)}` ); } if (tokenData && Date.now() < tokenData.expiresAt) { isAuthenticated = true; hasAdminScope = tokenData.scopes?.some(scope => scope.includes('admin')) || false; authenticatedUser = tokenData.user || 'Unknown'; this.logger.debug( `Admin /admin/users endpoint - authenticated: ${isAuthenticated}, hasAdminScope: ${hasAdminScope}` ); } } // Try global session if no specific session found if (!isAuthenticated) { this.logger.debug(`Admin /admin/users endpoint - trying global session`); const globalAuth = await this.tokenStore.get('global_user_auth'); this.logger.debug(`Admin /admin/users endpoint - globalAuth found: ${!!globalAuth}`); if (globalAuth) { this.logger.debug( `Admin /admin/users endpoint - globalAuth.expiresAt: ${globalAuth.expiresAt}, now: ${Date.now()}, valid: ${Date.now() < globalAuth.expiresAt}` ); this.logger.debug( `Admin /admin/users endpoint - globalAuth.scopes: ${JSON.stringify(globalAuth.scopes)}` ); } if (globalAuth && Date.now() < globalAuth.expiresAt) { isAuthenticated = true; hasAdminScope = globalAuth.scopes?.some(scope => scope.includes('admin')) || false; authenticatedUser = globalAuth.user || 'Unknown'; this.logger.debug( `Admin /admin/users endpoint - global auth: authenticated: ${isAuthenticated}, hasAdminScope: ${hasAdminScope}` ); } } if (!isAuthenticated) { this.logger.debug(`Admin /admin/users endpoint - authentication failed, returning 401`); return res.status(401).json({ error: 'Authentication required' }); } if (!hasAdminScope) { this.logger.debug( `Admin /admin/users endpoint - admin scope check failed, returning 403` ); return res.status(403).json({ error: 'Admin access required' }); } this.logger.debug(`Admin /admin/users endpoint - authentication successful, proceeding`); // Get all active sessions const allSessions = await this.tokenStore.getAllSessions(); // Group sessions by user to merge session types const userSessionsMap = new Map<string, any>(); allSessions.forEach(tokenData => { const userId = tokenData.user; // Determine user role based on scopes let role = 'user'; if (tokenData.scopes?.some(scope => scope.includes('admin'))) { role = 'admin'; } else if (tokenData.scopes?.some(scope => scope.includes('write'))) { role = 'editor'; } else if (tokenData.scopes?.some(scope => scope.includes('read'))) { role = 'viewer'; } const sessionTypes = []; if (tokenData.sessionId === 'global_user_auth') { sessionTypes.push('Global'); } else { sessionTypes.push('Session'); } if (userSessionsMap.has(userId)) { // Merge with existing user entry const existingUser = userSessionsMap.get(userId); existingUser.sessionTypes = [ ...new Set([...existingUser.sessionTypes, ...sessionTypes]), ]; // Use the most recent session data if (tokenData.lastUsedAt > new Date(existingUser.lastActivity).getTime()) { existingUser.sessionId = tokenData.sessionId; existingUser.lastActivity = new Date(tokenData.lastUsedAt).toISOString(); existingUser.clientInfo = { userAgent: tokenData.clientInfo?.userAgent || 'Unknown', ipAddress: tokenData.clientInfo?.ipAddress || 'Unknown', clientId: tokenData.clientInfo?.clientId || 'Unknown', }; } } else { // Create new user entry userSessionsMap.set(userId, { sessionId: tokenData.sessionId, user: userId, role: role, scopes: tokenData.scopes || [], authenticatedAt: new Date(tokenData.createdAt).toISOString(), lastActivity: new Date(tokenData.lastUsedAt).toISOString(), expiresAt: new Date(tokenData.expiresAt).toISOString(), clientInfo: { userAgent: tokenData.clientInfo?.userAgent || 'Unknown', ipAddress: tokenData.clientInfo?.ipAddress || 'Unknown', clientId: tokenData.clientInfo?.clientId || 'Unknown', }, isActive: tokenData.expiresAt > Date.now(), sessionTypes: sessionTypes, }); } }); // Convert map to array and format session types const users = Array.from(userSessionsMap.values()).map(user => ({ ...user, sessionType: user.sessionTypes.join(' + '), })); // Sort by authentication time (most recent first) users.sort( (a, b) => new Date(b.authenticatedAt).getTime() - new Date(a.authenticatedAt).getTime() ); // Get statistics const stats = await this.tokenStore.getStats(); res.json({ success: true, requestedBy: authenticatedUser, requestedAt: new Date().toISOString(), summary: { totalUsers: users.length, activeUsers: users.filter(u => u.isActive).length, expiredUsers: users.filter(u => !u.isActive).length, adminUsers: users.filter(u => u.role === 'admin').length, editorUsers: users.filter(u => u.role === 'editor').length, viewerUsers: users.filter(u => u.role === 'viewer').length, globalSessions: users.filter(u => u.sessionType === 'Global Authentication').length, }, users: users, systemStats: stats, }); } catch (error) { this.logger.error('Admin users endpoint failed:', error); res.status(500).json({ error: 'Internal server error', message: 'Failed to retrieve user information', }); } }); // Admin endpoint to delete user sessions - requires admin role from XSUAA this.app.delete('/admin/users/:sessionId', async (req: Request, res: Response) => { try { // Session-based authentication check let isAuthenticated = false; let hasAdminScope = false; let authenticatedUser = 'Unknown'; // Try session-based authentication first const authSessionId = (req.query.session as string) || (req.headers['x-mcp-session-id'] as string); if (authSessionId) { const tokenData = await this.tokenStore.get(authSessionId); if (tokenData && Date.now() < tokenData.expiresAt) { isAuthenticated = true; hasAdminScope = tokenData.scopes?.some(scope => scope.includes('admin')) || false; authenticatedUser = tokenData.user || 'Unknown'; } } // Try global session if no specific session found if (!isAuthenticated) { const globalAuth = await this.tokenStore.get('global_user_auth'); if (globalAuth && Date.now() < globalAuth.expiresAt) { isAuthenticated = true; hasAdminScope = globalAuth.scopes?.some(scope => scope.includes('admin')) || false; authenticatedUser = globalAuth.user || 'Unknown'; } } if (!isAuthenticated) { return res.status(401).json({ error: 'Authentication required' }); } if (!hasAdminScope) { return res.status(403).json({ error: 'Admin access required' }); } const { sessionId } = req.params; const tokenData = await this.tokenStore.get(sessionId); if (!tokenData) { return res.status(404).json({ error: 'Session not found', message: 'The specified session does not exist or has expired', }); } const success = await this.tokenStore.remove(sessionId); if (success) { this.logger.info(`Admin ${authenticatedUser} deleted session for user ${tokenData.user}`); res.json({ success: true, message: 'User session deleted successfully', user: tokenData.user, sessionId: sessionId, deletedBy: authenticatedUser, deletedAt: new Date().toISOString(), }); } else { res.status(500).json({ error: 'Deletion failed', message: 'Failed to delete user session', }); } } catch (error) { this.logger.error('Admin session deletion failed:', error); res.status(500).json({ error: 'Internal server error', message: 'Failed to delete user session', }); } }); // Admin endpoint to update user role this.app.put('/admin/users/:sessionId/role', async (req: Request, res: Response) => { try { // Session-based authentication check let isAuthenticated = false; let hasAdminScope = false; let authenticatedUser = 'Unknown'; // Try session-based authentication first const authSessionId = (req.query.session as string) || (req.headers['x-mcp-session-id'] as string); if (authSessionId) { const tokenData = await this.tokenStore.get(authSessionId); if (tokenData && Date.now() < tokenData.expiresAt) { isAuthenticated = true; hasAdminScope = tokenData.scopes?.some(scope => scope.includes('admin')) || false; authenticatedUser = tokenData.user || 'Unknown'; } } // Try global session if no specific session found if (!isAuthenticated) { const globalAuth = await this.tokenStore.get('global_user_auth'); if (globalAuth && Date.now() < globalAuth.expiresAt) { isAuthenticated = true; hasAdminScope = globalAuth.scopes?.some(scope => scope.includes('admin')) || false; authenticatedUser = globalAuth.user || 'Unknown'; } } if (!isAuthenticated) { return res.status(401).json({ error: 'Authentication required' }); } if (!hasAdminScope) { return res.status(403).json({ error: 'Admin access required' }); } const { sessionId } = req.params; const { role } = req.body; // Validate role const validRoles = ['admin', 'editor', 'viewer']; if (!role || !validRoles.includes(role)) { return res.status(400).json({ error: 'Invalid role', message: 'Role must be one of: admin, editor, viewer', }); } // Get the target session const tokenData = await this.tokenStore.get(sessionId); if (!tokenData) { return res.status(404).json({ error: 'Session not found', message: 'The specified session does not exist or has expired', }); } // Note: In a real implementation, you would update the user role in your identity provider // For now, we'll return a success message as role changes require identity provider updates this.logger.info( `Admin ${authenticatedUser} requested role change for ${tokenData.user} to ${role}` ); res.json({ success: true, message: `Role update request processed. Note: Role changes require identity provider configuration updates.`, user: tokenData.user, requestedRole: role, currentScopes: tokenData.scopes, updatedBy: authenticatedUser, updatedAt: new Date().toISOString(), }); } catch (error) { this.logger.error('Admin role update failed:', error); res.status(500).json({ error: 'Internal server error', message: 'Failed to update user role', }); } }); // Admin endpoint for POST delete (to match admin.html expectations) this.app.post('/admin/users/delete', async (req: Request, res: Response) => { try { // Session-based authentication check let isAuthenticated = false; let hasAdminScope = false; let authenticatedUser = 'Unknown'; // Try session-based authentication first const authSessionId = (req.query.session as string) || (req.headers['x-mcp-session-id'] as string); if (authSessionId) { const tokenData = await this.tokenStore.get(authSessionId); if (tokenData && Date.now() < tokenData.expiresAt) { isAuthenticated = true; hasAdminScope = tokenData.scopes?.some(scope => scope.includes('admin')) || false; authenticatedUser = tokenData.user || 'Unknown'; } } // Try global session if no specific session found if (!isAuthenticated) { const globalAuth = await this.tokenStore.get('global_user_auth'); if (globalAuth && Date.now() < globalAuth.expiresAt) { isAuthenticated = true; hasAdminScope = globalAuth.scopes?.some(scope => scope.includes('admin')) || false; authenticatedUser = globalAuth.user || 'Unknown'; } } if (!isAuthenticated) { return res.status(401).json({ error: 'Authentication required' }); } if (!hasAdminScope) { return res.status(403).json({ error: 'Admin access required' }); } const { sessionId } = req.body; if (!sessionId) { return res.status(400).json({ error: 'Missing sessionId', message: 'Session ID is required', }); } const tokenData = await this.tokenStore.get(sessionId); if (!tokenData) { return res.status(404).json({ error: 'Session not found', message: 'The specified session does not exist or has expired', }); } const success = await this.tokenStore.remove(sessionId); if (success) { this.logger.info(`Admin ${authenticatedUser} deleted session for user ${tokenData.user}`); res.json({ success: true, message: 'User session deleted successfully', user: tokenData.user, sessionId: sessionId, deletedBy: authenticatedUser, deletedAt: new Date().toISOString(), }); } else { res.status(500).json({ error: 'Deletion failed', message: 'Failed to delete user session', }); } } catch (error) { this.logger.error('Admin session deletion failed:', error); res.status(500).json({ error: 'Internal server error', message: 'Failed to delete user session', }); } }); // Admin endpoint to reload OData configuration and trigger rediscovery this.app.post('/admin/odata/reload', async (req: Request, res: Response) => { try { // Session-based authentication check let isAuthenticated = false; let hasAdminScope = false; let authenticatedUser = 'Unknown'; // Try session-based authentication first const authSessionId = (req.query.session as string) || (req.headers['x-mcp-session-id'] as string); if (authSessionId) { const tokenData = await this.tokenStore.get(authSessionId); if (tokenData && Date.now() < tokenData.expiresAt) { isAuthenticated = true; hasAdminScope = tokenData.scopes?.some(scope => scope.includes('admin')) || false; authenticatedUser = tokenData.user || 'Unknown'; } } // Try global session if no specific session found if (!isAuthenticated) { const globalAuth = await this.tokenStore.get('global_user_auth'); if (globalAuth && Date.now() < globalAuth.expiresAt) { isAuthenticated = true; hasAdminScope = globalAuth.scopes?.some(scope => scope.includes('admin')) || false; authenticatedUser = globalAuth.user || 'Unknown'; } } if (!isAuthenticated) { return res.status(401).json({ error: 'Authentication required' }); } if (!hasAdminScope) { return res.status(403).json({ error: 'Admin access required' }); } // Trigger OData configuration reload const reloadResult = await this.reloadODataConfig(); this.logger.info(`Admin ${authenticatedUser} triggered OData config reload`); // The actual rediscovery will be triggered by the main application // This endpoint serves as a trigger mechanism res.json({ success: reloadResult.success, message: 'OData configuration reload initiated', details: reloadResult.message, triggeredBy: authenticatedUser, triggeredAt: new Date().toISOString(), note: 'Service rediscovery will begin shortly. Check logs for progress.', }); } catch (error) { this.logger.error('OData config reload failed:', error); res.status(500).json({ error: 'Internal server error', message: 'Failed to reload OData configuration', }); } }); // Admin endpoint to get current OData configuration status this.app.get('/admin/odata/status', async (req: Request, res: Response) => { try { // Session-based authentication check (same as reload endpoint) let isAuthenticated = false; let hasAdminScope = false; const authSessionId = (req.query.session as string) || (req.headers['x-mcp-session-id'] as string); if (authSessionId) { const tokenData = await this.tokenStore.get(authSessionId); if (tokenData && Date.now() < tokenData.expiresAt) { isAuthenticated = true; hasAdminScope = tokenData.scopes?.some(scope => scope.includes('admin')) || false; } } if (!isAuthenticated) { return res.status(401).json({ error: 'Authentication required', message: 'Valid session required to access admin endpoints', }); } // Get current configuration from config and discovered services count const configStatus = await this.getODataConfigStatus(); res.json({ success: true, timestamp: new Date().toISOString(), configuration: configStatus, }); } catch (error) { this.logger.error('Failed to get OData config status:', error); res.status(500).json({ error: 'Internal server error', message: 'Failed to retrieve OData configuration status', }); } }); // Admin endpoint to get destination status this.app.get('/admin/destinations/status', async (req: Request, res: Response) => { try { // Session-based authentication check (same as other admin endpoints) let isAuthenticated = false; let hasAdminScope = false; const authSessionId = (req.query.session as string) || (req.headers['x-mcp-session-id'] as string); if (authSessionId) { const tokenData = await this.tokenStore.get(authSessionId); if (tokenData && Date.now() < tokenData.expiresAt) { isAuthenticated = true; hasAdminScope = tokenData.scopes?.some(scope => scope.includes('admin')) || false; } } if (!isAuthenticated) { return res.status(401).json({ error: 'Authentication required', message: 'Valid session required to access admin endpoints', }); } // Get destination status from the callback if available // Pass the authenticated user's JWT token for Principal Propagation testing let userJWT = undefined; // Try to get JWT from session first if (authSessionId) { const tokenData = await this.tokenStore.get(authSessionId); if (tokenData && tokenData.token) { userJWT = tokenData.token; this.logger.debug(`Using JWT from session ${authSessionId} for destination testing`); } else { this.logger.debug(`No JWT found in session ${authSessionId}, trying global session`); } } // If no JWT from session, try global session if (!userJWT) { const globalAuth = await this.tokenStore.get('global_user_auth'); if (globalAuth && globalAuth.token) { userJWT = globalAuth.token; this.logger.debug('Using JWT from global session for destination testing'); } else { this.logger.warn( 'No JWT found in any session - Principal Propagation testing may fail' ); } } const destinationStatus = await this.getDestinationStatus(userJWT); res.json({ success: true, timestamp: new Date().toISOString(), destinations: destinationStatus, }); } catch (error) { this.logger.error('Failed to get destination status:', error); res.status(500).json({ error: 'Internal server error', message: 'Failed to retrieve destination status', }); } }); // Note: 404 handler removed - let the main application handle unknown routes } async start(port: number = 3000): Promise<void> { return new Promise((resolve, reject) => { try { this.server = this.app.listen(port, '0.0.0.0', () => { this.logger.info(`🔐 Auth server started on port ${port}`); this.logger.info(`📱 Login page: http://localhost:${port}/login`); resolve(); }); this.server.on('error', (error: Error) => { this.logger.error('Server error:', error); reject(error); }); } catch (error) { this.logger.error('Failed to start auth server:', error); reject(error); } }); } async stop(): Promise<void> { return new Promise(resolve => { if (this.server) { this.server.close(() => { this.logger.info('Auth server stopped'); this.tokenStore.shutdown(); resolve(); }); } else { resolve(); } }); } private extractAuthInfo(securityContext: any) { const userInfo = securityContext.getTokenInfo(); const scopes = securityContext.getGrantedScopes(); return { user: userInfo.getGivenName() + ' ' + userInfo.getFamilyName() || userInfo.getLogonName(), email: userInfo.getEmail(), scopes: scopes, tenant: userInfo.getIdentityZone(), userId: userInfo.getLogonName(), isAuthenticated: true, }; } /** * Trigger OData service configuration reload and rediscovery * This method will be set by the main application during initialization */ private reloadODataCallback?: () => Promise<{ success: boolean; servicesCount: number; message: string; }>; private getODataStatusCallback?: () => Promise<{ config: Record<string, unknown>; servicesCount: number; discoveredServices: Array<{ id: string; name: string; url: string; entities: number }>; }>; private getDestinationStatusCallback?: (userJWT?: string) => Promise<{ designTime: { name: string; available: boolean; error?: string; authType?: string }; runtime: { name: string; available: boolean; error?: string; authType?: string; hybrid?: boolean; }; config: { useSingleDestination: boolean }; }>; setReloadCallback( callback: () => Promise<{ success: boolean; servicesCount: number; message: string }> ) { this.reloadODataCallback = callback; } setStatusCallback( callback: () => Promise<{ config: Record<string, unknown>; servicesCount: number; discoveredServices: Array<{ id: string; name: string; url: string; entities: number }>; }> ) { this.getODataStatusCallback = callback; } setDestinationStatusCallback( callback: (userJWT?: string) => Promise<{ designTime: { name: string; available: boolean; error?: string; authType?: string }; runtime: { name: string; available: boolean; error?: string; authType?: string; hybrid?: boolean; }; config: { useSingleDestination: boolean }; }> ) { this.getDestinationStatusCallback = callback; } async getODataConfigStatus(): Promise<{ config: Record<string, unknown>; servicesCount: number; discoveredServices: Array<{ id: string; name: string; url: string; entities: number }>; }> { if (this.getODataStatusCallback) { return await this.getODataStatusCallback(); } else { // Fallback if callback not set return { config: { error: 'Status callback not configured' }, servicesCount: 0, discoveredServices: [], }; } } async getDestinationStatus(userJWT?: string): Promise<{ designTime: { name: string; available: boolean; error?: string; authType?: string }; runtime: { name: string; available: boolean; error?: string; authType?: string; hybrid?: boolean; }; config: { useSingleDestination: boolean }; }> { if (this.getDestinationStatusCallback) { return await this.getDestinationStatusCallback(userJWT); } else { // Fallback if callback not set return { designTime: { name: 'Not configured', available: false, error: 'Status callback not configured', }, runtime: { name: 'Not configured', available: false, error: 'Status callback not configured', }, config: { useSingleDestination: false }, }; } } async reloadODataConfig(): Promise<{ success: boolean; message: string; servicesCount?: number; }> { try { if (this.reloadODataCallback) { const result = await this.reloadODataCallback(); return { success: result.success, message: result.message, servicesCount: result.servicesCount, }; } else { return { success: false, message: 'OData reload callback not set - rediscovery not available', }; } } catch (error) { return { success: false, message: `Failed to reload OData config: ${error}`, }; } } getTokenStore(): TokenStore { return this.tokenStore; } getIASAuthService(): IASAuthService { return this.iasAuthService; } getApp(): express.Application { return this.app; } }

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/Raistlin82/btp-sap-odata-to-mcp-server-optimized'

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