UNPKG

@access-mcp/shared

Version:

Shared utilities for ACCESS-CI MCP servers

447 lines (446 loc) 16.1 kB
import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import axios from "axios"; import express from "express"; import { createLogger } from "./logger.js"; export class BaseAccessServer { serverName; version; baseURL; server; transport; logger; _httpClient; _httpServer; _httpPort; _sseTransports = new Map(); constructor(serverName, version, baseURL = "https://support.access-ci.org/api") { this.serverName = serverName; this.version = version; this.baseURL = baseURL; this.logger = createLogger(serverName); this.server = new Server({ name: serverName, version: version, }, { capabilities: { resources: {}, tools: {}, prompts: {}, }, }); this.transport = new StdioServerTransport(); this.setupHandlers(); } get httpClient() { if (!this._httpClient) { const headers = { "User-Agent": `${this.serverName}/${this.version}`, }; // Add authentication if API key is provided const apiKey = process.env.ACCESS_CI_API_KEY; if (apiKey) { headers["Authorization"] = `Bearer ${apiKey}`; } this._httpClient = axios.create({ baseURL: this.baseURL, timeout: 5000, headers, validateStatus: () => true, // Don't throw on HTTP errors }); } return this._httpClient; } setupHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => { try { return { tools: this.getTools() }; } catch (error) { // Silent error handling for MCP compatibility return { tools: [] }; } }); this.server.setRequestHandler(ListResourcesRequestSchema, async () => { try { return { resources: this.getResources() }; } catch (error) { // Silent error handling for MCP compatibility return { resources: [] }; } }); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { try { return await this.handleToolCall(request); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.error("Error handling tool call", { error: errorMessage }); return { content: [ { type: "text", text: JSON.stringify({ error: errorMessage, }), }, ], isError: true, }; } }); this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { try { return await this.handleResourceRead(request); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.error("Error reading resource", { error: errorMessage }); return { contents: [ { uri: request.params.uri, mimeType: "text/plain", text: `Error: ${errorMessage}`, }, ], }; } }); this.server.setRequestHandler(ListPromptsRequestSchema, async () => { try { return { prompts: this.getPrompts() }; } catch (error) { // Silent error handling for MCP compatibility return { prompts: [] }; } }); this.server.setRequestHandler(GetPromptRequestSchema, async (request) => { try { return await this.handleGetPrompt(request); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.error("Error getting prompt", { error: errorMessage }); throw error; } }); } /** * Get available prompts - override in subclasses to provide prompts */ getPrompts() { return []; } /** * Handle resource read requests - override in subclasses */ async handleResourceRead(request) { throw new Error(`Resource reading not supported by this server: ${request.params.uri}`); } /** * Handle get prompt requests - override in subclasses */ async handleGetPrompt(request) { throw new Error(`Prompt not found: ${request.params.name}`); } /** * Helper method to create a standard error response (MCP 2025 compliant) * @param message The error message * @param hint Optional suggestion for how to fix the error */ errorResponse(message, hint) { return { content: [ { type: "text", text: JSON.stringify({ error: message, ...(hint && { hint }), }), }, ], isError: true, }; } /** * Helper method to create a JSON resource response * @param uri The resource URI * @param data The data to return as JSON */ createJsonResource(uri, data) { return { contents: [ { uri, mimeType: "application/json", text: JSON.stringify(data, null, 2), }, ], }; } /** * Helper method to create a Markdown resource response * @param uri The resource URI * @param markdown The markdown content */ createMarkdownResource(uri, markdown) { return { contents: [ { uri, mimeType: "text/markdown", text: markdown, }, ], }; } /** * Helper method to create a text resource response * @param uri The resource URI * @param text The plain text content */ createTextResource(uri, text) { return { contents: [ { uri, mimeType: "text/plain", text: text, }, ], }; } /** * Start the MCP server with optional HTTP service layer for inter-server communication */ async start(options) { // Start HTTP service layer if port is specified if (options?.httpPort) { this._httpPort = options.httpPort; await this.startHttpService(); this.logger.info("HTTP server running", { port: this._httpPort }); } else { // Only connect stdio transport when NOT in HTTP mode await this.server.connect(this.transport); // MCP servers should not output anything to stderr/stdout when running // as it interferes with JSON-RPC communication } } /** * Start HTTP service layer with SSE support for remote MCP connections */ async startHttpService() { if (!this._httpPort) return; this._httpServer = express(); this._httpServer.use(express.json()); // Health check endpoint this._httpServer.get("/health", (req, res) => { res.json({ server: this.serverName, version: this.version, status: "healthy", timestamp: new Date().toISOString(), }); }); // SSE endpoint for MCP remote connections this._httpServer.get("/sse", async (req, res) => { this.logger.debug("New SSE connection"); const transport = new SSEServerTransport("/messages", res); const sessionId = transport.sessionId; this._sseTransports.set(sessionId, transport); // Clean up on disconnect res.on("close", () => { this.logger.debug("SSE connection closed", { sessionId }); this._sseTransports.delete(sessionId); }); // Create a new server instance for this SSE connection const sseServer = new Server({ name: this.serverName, version: this.version, }, { capabilities: { resources: {}, tools: {}, prompts: {}, }, }); // Set up handlers for the SSE server (same as main server) this.setupServerHandlers(sseServer); await sseServer.connect(transport); }); // Messages endpoint for SSE POST messages this._httpServer.post("/messages", async (req, res) => { const sessionId = req.query.sessionId; const transport = this._sseTransports.get(sessionId); if (!transport) { res.status(404).json({ error: "Session not found" }); return; } await transport.handlePostMessage(req, res, req.body); }); // List available tools endpoint (for inter-server communication) this._httpServer.get("/tools", (req, res) => { try { const tools = this.getTools(); res.json({ tools }); } catch (error) { res.status(500).json({ error: "Failed to list tools" }); } }); // Tool execution endpoint (for inter-server communication) this._httpServer.post("/tools/:toolName", async (req, res) => { try { const { toolName } = req.params; const { arguments: args = {} } = req.body; // Validate that the tool exists const tools = this.getTools(); const tool = tools.find((t) => t.name === toolName); if (!tool) { res.status(404).json({ error: `Tool '${toolName}' not found` }); return; } // Execute the tool const request = { method: "tools/call", params: { name: toolName, arguments: args, }, }; const result = await this.handleToolCall(request); res.json(result); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); res.status(500).json({ error: errorMessage }); } }); // Start HTTP server return new Promise((resolve, reject) => { this._httpServer.listen(this._httpPort, "0.0.0.0", () => { resolve(); }).on("error", reject); }); } /** * Set up MCP handlers on a server instance */ setupServerHandlers(server) { server.setRequestHandler(ListToolsRequestSchema, async () => { try { return { tools: this.getTools() }; } catch (error) { return { tools: [] }; } }); server.setRequestHandler(ListResourcesRequestSchema, async () => { try { return { resources: this.getResources() }; } catch (error) { return { resources: [] }; } }); server.setRequestHandler(CallToolRequestSchema, async (request) => { try { return await this.handleToolCall(request); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.error("Error handling tool call", { error: errorMessage }); return { content: [ { type: "text", text: JSON.stringify({ error: errorMessage, }), }, ], isError: true, }; } }); server.setRequestHandler(ReadResourceRequestSchema, async (request) => { try { return await this.handleResourceRead(request); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.error("Error reading resource", { error: errorMessage }); return { contents: [ { uri: request.params.uri, mimeType: "text/plain", text: `Error: ${errorMessage}`, }, ], }; } }); server.setRequestHandler(ListPromptsRequestSchema, async () => { try { return { prompts: this.getPrompts() }; } catch (error) { return { prompts: [] }; } }); server.setRequestHandler(GetPromptRequestSchema, async (request) => { try { return await this.handleGetPrompt(request); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.error("Error getting prompt", { error: errorMessage }); throw error; } }); } /** * Call a tool on another ACCESS-CI MCP server via HTTP */ async callRemoteServer(serviceName, toolName, args = {}) { const serviceUrl = this.getServiceEndpoint(serviceName); if (!serviceUrl) { throw new Error(`Service '${serviceName}' not found. Check ACCESS_MCP_SERVICES environment variable.`); } const response = await axios.post(`${serviceUrl}/tools/${toolName}`, { arguments: args, }, { timeout: 30000, validateStatus: () => true, }); if (response.status !== 200) { throw new Error(`Remote server call failed: ${response.status} ${response.data?.error || response.statusText}`); } return response.data; } /** * Get service endpoint from environment configuration * Expected format: ACCESS_MCP_SERVICES=nsf-awards=http://localhost:3001,xdmod-metrics=http://localhost:3002 */ getServiceEndpoint(serviceName) { const services = process.env.ACCESS_MCP_SERVICES; if (!services) return null; const serviceMap = {}; services.split(",").forEach((service) => { const [name, url] = service.split("="); if (name && url) { serviceMap[name.trim()] = url.trim(); } }); return serviceMap[serviceName] || null; } }