Skip to main content
Glama
index.ts21.7 kB
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { CallToolRequestSchema, ListToolsRequestSchema, ErrorCode, McpError, } from '@modelcontextprotocol/sdk/types.js'; import { createServer, IncomingMessage, ServerResponse } from 'http'; import { URL } from 'url'; import { loadConfig, validateConfig } from './config.js'; import { getToolsForLevel, getTool, initializeTools } from './tools/index.js'; import { validateClientCredentials, validateClientId, validateAccessToken, issueToken, parseBasicAuth, parseRequestBody, parseFormUrlEncoded, createAuthorizationCode, validateAuthorizationCode, } from './oauth.js'; async function main() { console.error('[Server] Starting Homelab MCP Server...'); const config = loadConfig(); validateConfig(config); initializeTools(config); const availableTools = getToolsForLevel(config.capabilityLevel); console.error(`[Server] Loaded ${availableTools.length} tools for capability level ${config.capabilityLevel}`); // Factory function to create configured MCP server function createMcpServer() { const server = new Server( { name: 'homelab-mcp', version: '1.0.0', }, { capabilities: { tools: {}, }, } ); // Handle list_tools request server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: availableTools.map(tool => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema, })), }; }); // Handle call_tool request server.setRequestHandler(CallToolRequestSchema, async (request) => { const toolName = request.params.name; const args = request.params.arguments || {}; console.error(`[Tool] Calling: ${toolName}`); const tool = getTool(toolName); if (!tool) { throw new McpError(ErrorCode.MethodNotFound, `Tool not found: ${toolName}`); } if (tool.level > config.capabilityLevel) { throw new McpError( ErrorCode.InvalidRequest, `Tool ${toolName} requires capability level ${tool.level}, current level is ${config.capabilityLevel}` ); } try { const result = await tool.handler(args, config); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } catch (error) { console.error(`[Tool] Error executing ${toolName}:`, error); const errorMessage = error instanceof Error ? error.message : 'Unknown error'; return { content: [{ type: 'text', text: JSON.stringify({ error: true, code: 'TOOL_EXECUTION_ERROR', message: errorMessage }, null, 2), }], isError: true, }; } }); return server; } // Determine transport mode const useHttp = process.env.MCP_TRANSPORT === 'http' || !!config.port; if (useHttp) { // HTTP transport for remote access (Claude Chat) const port = config.port || 3000; // Check we have some form of authentication configured const hasApiKey = !!config.apiKey; const hasOAuth = !!(config.oauthClientId && config.oauthClientSecret); if (!hasApiKey && !hasOAuth) { console.error('[Server] ERROR: HTTP mode requires API_KEY or OAuth credentials'); process.exit(1); } console.error(`[Server] Auth modes: API_KEY=${hasApiKey}, OAuth=${hasOAuth}`); // Track active transports for cleanup const activeTransports = new Map<string, StreamableHTTPServerTransport>(); // Helper to get base URL const getBaseUrl = (req: IncomingMessage) => { const host = req.headers.host || config.serverDomain; return `https://${host}`; }; // Helper to send 401 with proper MCP auth headers (RFC 9728) const sendUnauthorized = (req: IncomingMessage, res: ServerResponse, requestId: string) => { const baseUrl = getBaseUrl(req); const resourceMetadataUrl = `${baseUrl}/.well-known/oauth-protected-resource`; console.error(`[Auth] Authentication required, returning 401 with resource_metadata (${requestId})`); res.writeHead(401, { 'Content-Type': 'application/json', 'WWW-Authenticate': `Bearer resource_metadata="${resourceMetadataUrl}"`, }); res.end(JSON.stringify({ error: 'unauthorized', message: 'Authentication required', })); }; const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => { const requestId = Math.random().toString(36).substring(7); console.error(`[HTTP] ${req.method} ${req.url} (${requestId})`); // CORS headers res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Session-Id'); res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id'); // Handle preflight if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } // Parse URL const parsedUrl = new URL(req.url || '/', `http://${req.headers.host}`); const pathname = parsedUrl.pathname; const baseUrl = getBaseUrl(req); // Health check endpoint (no auth required) if (pathname === '/health' && req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', version: '1.0.0', capabilityLevel: config.capabilityLevel, toolCount: availableTools.length, authModes: { apiKey: hasApiKey, oauth: hasOAuth, }, })); return; } // Favicon - return 204 No Content if (pathname === '/favicon.ico') { res.writeHead(204); res.end(); return; } // OAuth 2.0 Protected Resource Metadata (RFC 9728) - Required by MCP spec if (pathname === '/.well-known/oauth-protected-resource' && req.method === 'GET') { console.error(`[OAuth] Serving protected resource metadata`); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ resource: baseUrl, authorization_servers: [baseUrl], scopes_supported: ['mcp'], bearer_methods_supported: ['header'], })); return; } // OAuth 2.0 Authorization Server Metadata (RFC 8414) if (pathname === '/.well-known/oauth-authorization-server' && req.method === 'GET') { console.error(`[OAuth] Serving authorization server metadata`); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ issuer: baseUrl, authorization_endpoint: `${baseUrl}/authorize`, token_endpoint: `${baseUrl}/oauth/token`, registration_endpoint: `${baseUrl}/oauth/register`, response_types_supported: ['code'], grant_types_supported: ['authorization_code'], code_challenge_methods_supported: ['S256', 'plain'], token_endpoint_auth_methods_supported: ['none', 'client_secret_post'], scopes_supported: ['mcp'], })); return; } // OAuth 2.0 Dynamic Client Registration (RFC 7591) if (pathname === '/oauth/register' && req.method === 'POST') { if (!hasOAuth) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'invalid_request', error_description: 'OAuth not configured' })); return; } try { const body = await parseRequestBody(req); const registration = JSON.parse(body); console.error(`[OAuth] Client registration request:`, JSON.stringify(registration)); // Return the pre-configured client credentials res.writeHead(201, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ client_id: config.oauthClientId, client_secret: config.oauthClientSecret, client_name: registration.client_name || 'MCP Client', redirect_uris: registration.redirect_uris || [], grant_types: ['authorization_code'], response_types: ['code'], token_endpoint_auth_method: 'none', })); return; } catch (error) { console.error('[OAuth] Registration error:', error); res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'invalid_request' })); return; } } // OAuth 2.0 Authorization Endpoint if (pathname === '/authorize' && req.method === 'GET') { if (!hasOAuth) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'OAuth not configured' })); return; } const responseType = parsedUrl.searchParams.get('response_type'); const clientId = parsedUrl.searchParams.get('client_id'); const redirectUri = parsedUrl.searchParams.get('redirect_uri'); const codeChallenge = parsedUrl.searchParams.get('code_challenge'); const codeChallengeMethod = parsedUrl.searchParams.get('code_challenge_method') || 'plain'; const state = parsedUrl.searchParams.get('state'); const scope = parsedUrl.searchParams.get('scope'); console.error(`[OAuth] Authorization request: client_id=${clientId}, redirect_uri=${redirectUri}, scope=${scope}`); // Validate required parameters if (responseType !== 'code') { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'unsupported_response_type', error_description: 'Only code response type is supported', })); return; } if (!clientId) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'invalid_request', error_description: 'client_id is required', })); return; } if (!redirectUri) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'invalid_request', error_description: 'redirect_uri is required', })); return; } if (!codeChallenge) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'invalid_request', error_description: 'code_challenge is required (PKCE)', })); return; } // Auto-approve for personal homelab const authCode = createAuthorizationCode( clientId, redirectUri, codeChallenge, codeChallengeMethod ); // Build redirect URL with authorization code const redirectUrl = new URL(redirectUri); redirectUrl.searchParams.set('code', authCode); if (state) { redirectUrl.searchParams.set('state', state); } console.error(`[OAuth] Redirecting to: ${redirectUrl.toString()}`); res.writeHead(302, { 'Location': redirectUrl.toString() }); res.end(); return; } // OAuth 2.0 Token Endpoint if (pathname === '/oauth/token' && req.method === 'POST') { if (!hasOAuth) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'unsupported_grant_type', error_description: 'OAuth is not configured on this server', })); return; } try { const body = await parseRequestBody(req); const contentType = req.headers['content-type'] || ''; let grantType: string | undefined; let clientId: string | undefined; let clientSecret: string | undefined; let code: string | undefined; let codeVerifier: string | undefined; let redirectUri: string | undefined; // Parse based on content type if (contentType.includes('application/x-www-form-urlencoded')) { const params = parseFormUrlEncoded(body); grantType = params.grant_type; clientId = params.client_id; clientSecret = params.client_secret; code = params.code; codeVerifier = params.code_verifier; redirectUri = params.redirect_uri; } else if (contentType.includes('application/json')) { const json = JSON.parse(body); grantType = json.grant_type; clientId = json.client_id; clientSecret = json.client_secret; code = json.code; codeVerifier = json.code_verifier; redirectUri = json.redirect_uri; } // Also check Authorization header for Basic auth const authHeader = req.headers.authorization; if (authHeader && authHeader.startsWith('Basic ')) { const basicAuth = parseBasicAuth(authHeader); if (basicAuth) { clientId = clientId || basicAuth.clientId; clientSecret = clientSecret || basicAuth.clientSecret; } } console.error(`[OAuth] Token request: grant_type=${grantType}, client_id=${clientId}, code=${code ? 'present' : 'missing'}, code_verifier=${codeVerifier ? 'present' : 'missing'}`); // Handle Authorization Code grant if (grantType === 'authorization_code') { if (!code || !codeVerifier || !redirectUri || !clientId) { console.error(`[OAuth] Missing params: code=${!!code}, codeVerifier=${!!codeVerifier}, redirectUri=${!!redirectUri}, clientId=${!!clientId}`); res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'invalid_request', error_description: 'Missing code, code_verifier, redirect_uri, or client_id', })); return; } const validation = validateAuthorizationCode(code, clientId, redirectUri, codeVerifier); if (!validation.valid) { console.error(`[OAuth] Authorization code validation failed: ${validation.error}`); res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'invalid_grant', error_description: validation.error, })); return; } // Issue token const tokenResponse = issueToken(); console.error(`[OAuth] Token issued via authorization_code for client_id: ${clientId}`); res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'Pragma': 'no-cache', }); res.end(JSON.stringify(tokenResponse)); return; } // Handle Client Credentials grant (for backward compatibility / testing) if (grantType === 'client_credentials') { if (!clientId || !clientSecret) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'invalid_request', error_description: 'Missing client_id or client_secret', })); return; } if (!validateClientCredentials(clientId, clientSecret, config)) { console.error(`[OAuth] Invalid credentials for client_id: ${clientId}`); res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'invalid_client', error_description: 'Invalid client credentials', })); return; } // Issue token const tokenResponse = issueToken(); console.error(`[OAuth] Token issued via client_credentials for client_id: ${clientId}`); res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'Pragma': 'no-cache', }); res.end(JSON.stringify(tokenResponse)); return; } // Unsupported grant type res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'unsupported_grant_type', error_description: 'Supported grant types: authorization_code, client_credentials', })); return; } catch (error) { console.error('[OAuth] Token endpoint error:', error); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'server_error', error_description: 'Internal server error', })); return; } } // MCP endpoint - handle discovery (GET) and protocol (POST) if (pathname === '/mcp' || pathname === '/') { // Check authentication for all MCP requests const authHeader = req.headers.authorization; let isAuthenticated = false; if (authHeader && authHeader.startsWith('Bearer ')) { const token = authHeader.substring(7); isAuthenticated = validateAccessToken(token, config); } // If not authenticated, return 401 with resource_metadata (per MCP spec) if (!isAuthenticated) { sendUnauthorized(req, res, requestId); return; } // Authenticated - handle the request if (req.method === 'GET') { // GET requests return server info res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ name: 'homelab-mcp', version: '1.0.0', protocol_version: '2024-11-05', capabilities: { tools: {} }, })); return; } // POST requests - MCP protocol try { // Check for existing session const sessionId = req.headers['mcp-session-id'] as string | undefined; if (sessionId && activeTransports.has(sessionId)) { // Reuse existing transport for this session const transport = activeTransports.get(sessionId)!; await transport.handleRequest(req, res); return; } // Create new transport and server for new session const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => requestId, }); const server = createMcpServer(); // Handle transport close transport.onclose = () => { console.error(`[HTTP] Transport closed (${requestId})`); activeTransports.delete(requestId); }; // Store transport activeTransports.set(requestId, transport); // Connect and handle await server.connect(transport); await transport.handleRequest(req, res); } catch (error) { console.error(`[HTTP] Error handling MCP request (${requestId}):`, error); if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Internal server error' })); } } return; } // 404 for unknown routes res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not found' })); }); // Graceful shutdown process.on('SIGTERM', () => { console.error('[Server] Received SIGTERM, shutting down...'); httpServer.close(() => { console.error('[Server] HTTP server closed'); process.exit(0); }); }); httpServer.listen(port, '0.0.0.0', () => { console.error(`[Server] HTTP server listening on http://0.0.0.0:${port}`); console.error(`[Server] Endpoints:`); console.error(`[Server] - Health: http://0.0.0.0:${port}/health`); console.error(`[Server] - Resource: http://0.0.0.0:${port}/.well-known/oauth-protected-resource`); console.error(`[Server] - Auth Meta: http://0.0.0.0:${port}/.well-known/oauth-authorization-server`); console.error(`[Server] - Register: http://0.0.0.0:${port}/oauth/register`); console.error(`[Server] - Authorize: http://0.0.0.0:${port}/authorize`); console.error(`[Server] - Token: http://0.0.0.0:${port}/oauth/token`); console.error(`[Server] - MCP: http://0.0.0.0:${port}/mcp`); console.error(`[Server] Capability level: ${config.capabilityLevel}`); }); } else { // Stdio transport for local access (Claude Desktop/Code) const server = createMcpServer(); const transport = new StdioServerTransport(); await server.connect(transport); console.error('[Server] Homelab MCP Server running (stdio mode)'); } } main().catch((error) => { console.error('[Server] Fatal error:', error); process.exit(1); });

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/bshandley/homelab-mcp'

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