index.ts•16.9 kB
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequest, CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequest, GetPromptRequestSchema, Tool, isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { randomUUID } from "node:crypto";
import queryString from "query-string";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { InMemoryEventStore } from '@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js';
import express from "express";
import { Request, Response } from "express";
import bodyParser from "body-parser";
import { getClientIp } from "./helpers.js";
/* Enable SSE or Streamable HTTP mode */
const SSE = ((process.env.EDUBASE_SSE_MODE || 'false') == 'true');
const STREAMABLE_HTTP = ((process.env.EDUBASE_STREAMABLE_HTTP_MODE || 'false') == 'true');
/* Check required EduBase environment variables */
const EDUBASE_API_URL = process.env.EDUBASE_API_URL || 'https://www.edubase.net/api';
if (!SSE && !STREAMABLE_HTTP && EDUBASE_API_URL.length == 0) {
	console.error('Error: EDUBASE_API_URL environment variable is required with this transport mode');
	process.exit(1);
}
const EDUBASE_API_APP = process.env.EDUBASE_API_APP || '';
if (!SSE && !STREAMABLE_HTTP && EDUBASE_API_APP.length == 0) {
	console.error('Error: EDUBASE_API_APP environment variable is required with this transport mode');
	process.exit(1);
}
const EDUBASE_API_KEY = process.env.EDUBASE_API_KEY || '';
if (!SSE && !STREAMABLE_HTTP && EDUBASE_API_KEY.length == 0) {
	console.error('Error: EDUBASE_API_KEY environment variable is required with this transport mode');
	process.exit(1);
}
/* Supported tools and prompts */
import { EDUBASE_API_TOOLS, EDUBASE_API_TOOLS_OUTPUT_SCHEMA } from "./tools.js";
import { EDUBASE_API_PROMPTS, EDUBASE_API_PROMPTS_HANDLERS } from "./prompts.js";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
/* Create MCP server */
const server = new Server(
	{
		name: '@edubase/mcp',
		version: '1.0.19',
	},
	{
		capabilities: {
			prompts: {},
			tools: {},
		},
	},
);
/* EduBase API rate limits (via environment variables or configured defaults) */
const EDUBASE_API_MAXRATE_DEFAULT = {
	second: 10,
	minute: 1000
};
const EDUBASE_API_MAXRATE_ENV = {
	second: parseInt(process.env.EDUBASE_API_MAXRATE || ''),
	minute: parseInt(process.env.EDUBASE_API_MAXRATE60 || '')
};
const EDUBASE_API_MAXRATE = {
	second: Number.isInteger(EDUBASE_API_MAXRATE_ENV.second) ? EDUBASE_API_MAXRATE_ENV.second : EDUBASE_API_MAXRATE_DEFAULT.second,
	minute: Number.isInteger(EDUBASE_API_MAXRATE_ENV.minute) ? EDUBASE_API_MAXRATE_ENV.minute : EDUBASE_API_MAXRATE_DEFAULT.minute,
};
let requestRate = {
	second: 0,
	minute: 0,
	since: {
		second: Date.now(),
		minute: Date.now()
	}
};
function checkRateLimit() {
	const now = Date.now();
	if (now - requestRate.since.second > 1000) {
		/* New second, reset rate */
		requestRate.second = 0;
		requestRate.since.second = now;
	}
	if (now - requestRate.since.minute > 60000) {
		/* New minute, reset rate */
		requestRate.minute = 0;
		requestRate.since.minute = now;
	}
	if (requestRate.second >= EDUBASE_API_MAXRATE.second || requestRate.minute >= EDUBASE_API_MAXRATE.minute) {
		throw new Error('Rate limit exceeded');
	}
	requestRate.second++;
	requestRate.minute++;
}
/* EduBase API requests */
type EduBaseAuthentication = { app: string; secret: string };
async function sendEduBaseApiRequest(method: string, endpoint: string, data: object, authentication: EduBaseAuthentication | null) {
	/* Check method and endpoint */
	method = method.toUpperCase()
	if (!['GET', 'POST', 'DELETE'].includes(method)) {
		throw new Error('Invalid method: "' + method + '"');
	}
	if (endpoint.length == 0) {
		throw new Error('Invalid endpoint');
	}
	/* Check rate limit */
	checkRateLimit();
	/* Prepare authentication (prefer EDUBASE_API_APP and EDUBASE_API_KEY environment variables) */
	if (!authentication) {
		authentication = { app: EDUBASE_API_APP, secret: EDUBASE_API_KEY };
	} else {
		if (!authentication.hasOwnProperty('app') || authentication.app.length == 0 || EDUBASE_API_APP.length > 0) {
			authentication.app = EDUBASE_API_APP;
		}
		if (!authentication.hasOwnProperty('secret') || authentication.secret.length == 0 || EDUBASE_API_KEY.length > 0) {
			authentication.secret = EDUBASE_API_KEY;
		}
	}
	/* Send request with input data */
	let headers = {
		'Content-Type': 'application/json',
		'Accept-Encoding': 'gzip',
		'EduBase-API-Client': 'MCP',
		'EduBase-API-Transport': (STREAMABLE_HTTP) ? 'Streamable HTTP' : ((SSE) ? 'SSE' : 'Stdio'),
		'EduBase-API-App': authentication.app,
		'EduBase-API-Secret': authentication.secret
	};
	const response = await fetch(endpoint + (method == 'GET' ? '?' + queryString.stringify(data) : ''), {
		method: method,
		body: (method != 'GET' ? JSON.stringify(data) : undefined),
		headers: headers
	});
	if (!response.ok) {
		throw new Error(`EduBase API error: ${response.status} ${response.statusText}` + (response.headers.has('EduBase-API-Error') ? ` (${response.headers.get('EduBase-API-Error')})` : ''));
	}
	/* Parse response and return as object */
	let clonedResponse = response.clone();
	try {
		/* First try to decode as JSON */
		return await response.json();
	} catch (error) {
		/* Response might be empty string with a 200 status code */
		return await clonedResponse.text();
	}
}
/* Configure request handlers */
type EduBaseOverrideConfiguration = { EDUBASE_API_URL: string | null; EDUBASE_API_APP: string | null; EDUBASE_API_KEY: string | null };
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
	prompts: Object.values(EDUBASE_API_PROMPTS),
}));
server.setRequestHandler(GetPromptRequestSchema, (request: GetPromptRequest) => {
	try {
		/* Decompose request and check arguments */
		const { name, arguments: args } = request.params;
		const promptHandler = EDUBASE_API_PROMPTS_HANDLERS[name as keyof typeof EDUBASE_API_PROMPTS_HANDLERS];
		if (!promptHandler) {
			throw new Error('Prompt not found');
		}
		/* Return prompt response */
		return promptHandler;
	} catch (error) {
		/* Request failed */
		return {};
	}
});
server.setRequestHandler(ListToolsRequestSchema, async () => ({
	tools: EDUBASE_API_TOOLS,
}));
server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => {
	try {
		/* Decompose request and check arguments */
		const { name, arguments: args } = request.params;
		if (!name.match(/^edubase_(get|post|delete)/)) {
			throw new Error('Invalid tool configuration');
		}
		if (!args) {
			throw new Error('No arguments provided');
		}
		const meta: any = request.params._meta || {};
		/* Prepare authentication */
		let authentication: EduBaseAuthentication | null = null;
		if (meta && meta.override && meta.override.EDUBASE_API_APP && meta.override.EDUBASE_API_KEY) {
			/* Use authentication from custom configuration */
			authentication = {
				app: meta.override.EDUBASE_API_APP,
				secret: meta.override.EDUBASE_API_KEY
			};
		} else if (meta && meta.headers && meta.headers['edubase-api-app'] && meta.headers['edubase-api-secret']) {
			/* Use authentication from request headers */
			authentication = {
				app: meta.headers['edubase-api-app'],
				secret: meta.headers['edubase-api-secret']
			};
		} else if (meta && meta.headers && meta.headers['authorization'] && meta.headers['authorization'].startsWith('Bearer ')) {
			/* Use authentication from Bearer token */
			try {
				/* Decode Bearer token */
				const [ app, secret ] = atob(meta.headers['authorization'].split(' ')[1]).split(':');
				if (app && app.length > 0 && secret && secret.length > 0) {
					authentication = { app, secret };
				}
			} catch(error) {
				/* Probably not encoded as base64 */
				const [ app, secret ] = meta.headers['authorization'].split(' ')[1].split(':');
				if (app && app.length > 0 && secret && secret.length > 0) {
					authentication = { app, secret };
				}
			}
		}
		/* Prepare and send API request */
		const [ , method, ...endpoint ] = name.split('_');
		const response = await sendEduBaseApiRequest(method, (meta?.override?.EDUBASE_API_URL || EDUBASE_API_URL) + '/' + endpoint.join(':'), args, authentication);
		/* Return response */
		const outputSchemaKey = name as keyof typeof EDUBASE_API_TOOLS_OUTPUT_SCHEMA;
		if (typeof EDUBASE_API_TOOLS_OUTPUT_SCHEMA[outputSchemaKey] == 'object' && Object.keys(EDUBASE_API_TOOLS_OUTPUT_SCHEMA[outputSchemaKey]).length == 0 && typeof response == 'string' && response.length == 0) {
			/* Endpoint without response */
			return {
				content: [{ type: 'text', text: 'Success.' }],
				isError: false,
			};
		}
		else if (typeof response != 'object') {
			/* Response should be an object at this point */
			throw new Error('Invalid response');
		}
		else
		{
			/* Return response with optional schema */
			return {
				content: [{ type: 'text', text: "Response: " + JSON.stringify(response) + (Object.keys(EDUBASE_API_TOOLS_OUTPUT_SCHEMA[outputSchemaKey]).length > 0 ? "\nResponse schema: " + JSON.stringify(EDUBASE_API_TOOLS_OUTPUT_SCHEMA[outputSchemaKey]) : '') }],
				isError: false,
			};
		}
	} catch (error) {
		/* Request failed */
		return {
			content: [{
				type: 'text',
				text: `${error instanceof Error ? error.message : String(error)}`,
			}],
			isError: true,
		};
	}
});
/* Start MCP server */
if (STREAMABLE_HTTP) {
	/* Using HTTP with Streamable HTTP transport */
	const app = express();
	app.disable('x-powered-by');
	app.use(bodyParser.json());
	const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
	app.post('/mcp', async (req: Request, res: Response) => {
		/* Handle POST requests */
		const sessionId = req.headers['mcp-session-id'] as string | undefined;
		let transport: StreamableHTTPServerTransport;
		if (sessionId && transports[sessionId]) {
			/* Use existing session */
			transport = transports[sessionId];
		} else if (!sessionId && isInitializeRequest(req.body)) {
			/* New session */
			const eventStore = new InMemoryEventStore();
			transport = new StreamableHTTPServerTransport({
				sessionIdGenerator: () => randomUUID(),
				eventStore,
				onsessioninitialized: (sessionId) => {
					transports[sessionId] = transport;
				}
			});
			transport.onclose = () => {
				if (transport.sessionId) {
					delete transports[transport.sessionId];
				}
			};
			await server.connect(transport);
		} else {
			console.error("No session found for request");
			res.status(400).send();
			return;
		}
		try {
			let override: EduBaseOverrideConfiguration = { EDUBASE_API_URL: null, EDUBASE_API_APP: null, EDUBASE_API_KEY: null };
			if (req.query?.config && typeof req.query.config == 'string') {
				/* Apply Smithery configuration */
				const smitheryConfig = JSON.parse(Buffer.from(req.query.config, 'base64').toString());
				if (smitheryConfig.edubaseApiUrl && typeof smitheryConfig.edubaseApiUrl == 'string' && smitheryConfig.edubaseApiUrl.length > 0) {
					override.EDUBASE_API_URL = smitheryConfig.edubaseApiUrl;
				}
				if (smitheryConfig.edubaseApiApp && typeof smitheryConfig.edubaseApiApp == 'string' && smitheryConfig.edubaseApiApp.length > 0) {
					override.EDUBASE_API_APP = smitheryConfig.edubaseApiApp;
				}
				if (smitheryConfig.edubaseApiKey && typeof smitheryConfig.edubaseApiKey == 'string' && smitheryConfig.edubaseApiKey.length > 0) {
					override.EDUBASE_API_KEY = smitheryConfig.edubaseApiKey;
				}
			}
			const params = req.body?.params || {};
			params._meta = {
				ip: getClientIp(req),
				headers: req.headers,
				override: override,
			};
			await transport!.handleRequest(req, res, {...req.body, params});
		} catch (error) {
			console.error("Error handling POST request for session (" + sessionId + "): " + error);
			res.status(500).send();
		}
	});
	app.get('/mcp', async (req: Request, res: Response) => {
		/* Handle GET requests */
		const sessionId = req.headers['mcp-session-id'] as string | undefined;
		if (!sessionId || !transports[sessionId]) {
			res.status(400).send('Invalid session ID');
			return;
		}
		try {
			const transport = transports[sessionId];
			await transport!.handleRequest(req, res);
		} catch (error) {
			console.error("Error handling GET request for session (" + sessionId + "): " + error);
			res.status(500).send();
		}
	});
	app.delete('/mcp', async (req: Request, res: Response) => {
		/* Handle DELETE requests */
		const sessionId = req.headers['mcp-session-id'] as string | undefined;
		if (!sessionId || !transports[sessionId]) {
			res.status(400).send('Invalid session ID');
			return;
		}
		try {
			const transport = transports[sessionId];
			await transport!.handleRequest(req, res);
		} catch (error) {
			console.error("Error handling DELETE request for session (" + sessionId + "): " + error);
			res.status(500).send();
		}
	});
	app.get('/health', async (_: Request, res: Response) => {
		/* Health check endpoint */
		res.status(200).send();
	});
	const EDUBASE_HTTP_PORT = parseInt(process.env.EDUBASE_HTTP_PORT || process.env.PORT || '3000');
	app.listen(EDUBASE_HTTP_PORT, () => {
		console.error("EduBase MCP server is now listening on HTTP port " + EDUBASE_HTTP_PORT + " with Streamable HTTP transport");
	});
	process.on('SIGTERM', () => {
		/* Graceful shutdown */
		console.error("Received SIGTERM, shutting down EduBase MCP server...");
		server.close();
	});
} else if (SSE) {
	/* Using HTTP with SSE transport */
	const app = express();
	app.use(bodyParser.json());
	app.disable('x-powered-by');
	const transports: { [sessionId: string]: SSEServerTransport } = {};
	app.get('/sse', async (_: Request, res: Response) => {
		/* Handle SSE sessions */
		const transport = new SSEServerTransport('/messages', res);
		transports[transport.sessionId] = transport;
		res.on('close', () => {
			delete transports[transport.sessionId];
		});
		try {
			await server.connect(transport);
		} catch (error) {
			console.error("Error connecting transport to MCP server for session (" + transport.sessionId + "): " + error);
		}
	});
	app.post('/messages', async (req: Request, res: Response) => {
		/* Handle MCP messages */
		const sessionId = req.query.sessionId as string;
		const transport = transports[sessionId] ?? Object.values(transports)[0];
		if (transport) {
			try {
				let override: EduBaseOverrideConfiguration = { EDUBASE_API_URL: null, EDUBASE_API_APP: null, EDUBASE_API_KEY: null };
				if (req.query?.config && typeof req.query.config == 'string') {
					/* Apply Smithery configuration */
					const smitheryConfig = JSON.parse(Buffer.from(req.query.config, 'base64').toString());
					if (smitheryConfig.edubaseApiUrl && typeof smitheryConfig.edubaseApiUrl == 'string' && smitheryConfig.edubaseApiUrl.length > 0) {
						override.EDUBASE_API_URL = smitheryConfig.edubaseApiUrl;
					}
					if (smitheryConfig.edubaseApiApp && typeof smitheryConfig.edubaseApiApp == 'string' && smitheryConfig.edubaseApiApp.length > 0) {
						override.EDUBASE_API_APP = smitheryConfig.edubaseApiApp;
					}
					if (smitheryConfig.edubaseApiKey && typeof smitheryConfig.edubaseApiKey == 'string' && smitheryConfig.edubaseApiKey.length > 0) {
						override.EDUBASE_API_KEY = smitheryConfig.edubaseApiKey;
					}
				}
				const params = req.body?.params || {};
				params._meta = {
					ip: getClientIp(req),
					headers: req.headers,
					override: override,
				};
				await transport!.handlePostMessage(req, res, {...req.body, params});
			} catch (error) {
				console.error("Error handling message for session (" + sessionId + "): " + error);
				res.status(500).send();
			}
		} else {
			console.error("No transport found for session (" + sessionId + ")");
			res.status(400).send();
		}
	});
	app.get('/health', async (_: Request, res: Response) => {
		/* Health check endpoint */
		res.status(200).send();
	});
	const EDUBASE_HTTP_PORT = parseInt(process.env.EDUBASE_HTTP_PORT || process.env.PORT || '3000');
	app.listen(EDUBASE_HTTP_PORT, () => {
		console.error("EduBase MCP server is now listening on HTTP port " + EDUBASE_HTTP_PORT + " with SSE transport");
	});
	process.on('SIGTERM', () => {
		/* Graceful shutdown */
		console.error("Received SIGTERM, shutting down EduBase MCP server...");
		server.close();
	});
} else {
	/* Using stdio transport */
	async function runMcpServer() {
		const transport = new StdioServerTransport();
		await server.connect(transport);
		console.error("EduBase MCP server is now listening on standard input/output");
	}
	runMcpServer().catch((error) => {
		console.error("Cannot start EduBase MCP server: ", error);
		process.exit(1);
	});
}