feat(app): reimplement and streamline MCP server creation
- uses reference Typescript MCP servers as basis for redesign - centralises tool registration for different transports, reducing code duplication - implements MCP notifications
This commit is contained in:
parent
9a7a9d7fea
commit
95cfd6c2cc
|
|
@ -0,0 +1,340 @@
|
||||||
|
import { InMemoryEventStore } from '@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js';
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
|
import { isInitializeRequest, SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { Client, GatewayIntentBits } from 'discord.js';
|
||||||
|
import { config as dotenvConfig } from 'dotenv';
|
||||||
|
import express, { Request, Response } from 'express';
|
||||||
|
import { info, Level, setLevel, warning } from './notifications.js';
|
||||||
|
import * as schemas from './schemas.js';
|
||||||
|
import * as handlers from './tools/tools.js';
|
||||||
|
import { createToolContext } from './tools/tools.js';
|
||||||
|
|
||||||
|
// Load environment variables from .env file if exists
|
||||||
|
dotenvConfig();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and return a configured MCP server wired with the Discord client.
|
||||||
|
* The server registers tools programmatically using the handlers and schemas
|
||||||
|
* exported from the local modules to avoid a large switch statement.
|
||||||
|
*/
|
||||||
|
function createMcpServer(client: Client) {
|
||||||
|
const server = new McpServer(
|
||||||
|
{ name: 'discord-mcp-server', version: '1.0.0' },
|
||||||
|
{ capabilities: { tools: {}, logging: {} } }
|
||||||
|
);
|
||||||
|
// attach the Discord client to the tool context for use in handlers
|
||||||
|
const toolContext = createToolContext(client);
|
||||||
|
// attach the Discord client to the server for logging purposes
|
||||||
|
// The underlying Server type from the SDK doesn't declare a `client` property,
|
||||||
|
// so assert to `any` to attach a runtime-only property used by handlers.
|
||||||
|
(client as any).server = server.server;
|
||||||
|
(server as any).discord = client;
|
||||||
|
|
||||||
|
// Map of tool name to handler + schema. Keep this small and explicit so it's
|
||||||
|
// easy to see which handler is wired to which tool. Use the exported
|
||||||
|
// Zod schemas from `schemas.ts` where available.
|
||||||
|
const toolMap: Array<{ name: string; schema?: any; handler: any }> = [
|
||||||
|
{ name: 'discord_login', schema: schemas.DiscordLoginSchema, handler: handlers.loginHandler },
|
||||||
|
{ name: 'discord_send', schema: schemas.SendMessageSchema, handler: handlers.sendMessageHandler },
|
||||||
|
{ name: 'discord_get_forum_channels', schema: schemas.GetForumChannelsSchema, handler: handlers.getForumChannelsHandler },
|
||||||
|
{ name: 'discord_create_forum_post', schema: schemas.CreateForumPostSchema, handler: handlers.createForumPostHandler },
|
||||||
|
{ name: 'discord_get_forum_post', schema: schemas.GetForumPostSchema, handler: handlers.getForumPostHandler },
|
||||||
|
{ name: 'discord_reply_to_forum', schema: schemas.ReplyToForumSchema, handler: handlers.replyToForumHandler },
|
||||||
|
{ name: 'discord_delete_forum_post', schema: schemas.DeleteForumPostSchema, handler: handlers.deleteForumPostHandler },
|
||||||
|
{ name: 'discord_create_text_channel', schema: schemas.CreateTextChannelSchema, handler: handlers.createTextChannelHandler },
|
||||||
|
{ name: 'discord_delete_channel', schema: schemas.DeleteChannelSchema, handler: handlers.deleteChannelHandler },
|
||||||
|
{ name: 'discord_read_messages', schema: schemas.ReadMessagesSchema, handler: handlers.readMessagesHandler },
|
||||||
|
{ name: 'discord_get_server_info', schema: schemas.GetServerInfoSchema, handler: handlers.getServerInfoHandler },
|
||||||
|
{ name: 'discord_add_reaction', schema: schemas.AddReactionSchema, handler: handlers.addReactionHandler },
|
||||||
|
{ name: 'discord_add_multiple_reactions', schema: schemas.AddMultipleReactionsSchema, handler: handlers.addMultipleReactionsHandler },
|
||||||
|
{ name: 'discord_remove_reaction', schema: schemas.RemoveReactionSchema, handler: handlers.removeReactionHandler },
|
||||||
|
{ name: 'discord_delete_message', schema: schemas.DeleteMessageSchema, handler: handlers.deleteMessageHandler },
|
||||||
|
{ name: 'discord_create_webhook', schema: schemas.CreateWebhookSchema, handler: handlers.createWebhookHandler },
|
||||||
|
{ name: 'discord_send_webhook_message', schema: schemas.SendWebhookMessageSchema, handler: handlers.sendWebhookMessageHandler },
|
||||||
|
{ name: 'discord_edit_webhook', schema: schemas.EditWebhookSchema, handler: handlers.editWebhookHandler },
|
||||||
|
{ name: 'discord_delete_webhook', schema: schemas.DeleteWebhookSchema, handler: handlers.deleteWebhookHandler },
|
||||||
|
{ name: 'discord_list_servers', schema: schemas.ListServersSchema, handler: handlers.listServersHandler },
|
||||||
|
{ name: 'discord_create_category', schema: schemas.CreateCategorySchema, handler: handlers.createCategoryHandler },
|
||||||
|
{ name: 'discord_edit_category', schema: schemas.EditCategorySchema, handler: handlers.editCategoryHandler },
|
||||||
|
{ name: 'discord_delete_category', schema: schemas.DeleteCategorySchema, handler: handlers.deleteCategoryHandler },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Register each tool on the MCP server
|
||||||
|
for (const t of toolMap) {
|
||||||
|
try {
|
||||||
|
server.tool(t.name, t.schema ? t.schema.shape ?? t.schema : undefined, async (args: any) => {
|
||||||
|
// Handlers follow the signature: (args, context) => Promise<ToolResponse>
|
||||||
|
return await t.handler(args, toolContext);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// If a particular tool registration fails, log and continue (keeps boot resilient)
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
warning(server.server, `Failed to register tool ${t.name}: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle logging method separately
|
||||||
|
server.server.setRequestHandler(SetLevelRequestSchema, async (request) => {
|
||||||
|
const levelParam = request.params.level as Level | undefined;
|
||||||
|
const ok = setLevel(levelParam || 'info');
|
||||||
|
// Empty result on success, error on failure
|
||||||
|
if (!ok) {
|
||||||
|
throw { code: -32602, message: 'Invalid log level' };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create a Discord client with sensible defaults used by the CLI
|
||||||
|
function createDiscordClient(token?: string) {
|
||||||
|
const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent] });
|
||||||
|
if (token) {
|
||||||
|
client.token = token;
|
||||||
|
// Auto-login on startup if token is available
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await client.login(token);
|
||||||
|
info((client as any).server, 'Successfully logged in to Discord');
|
||||||
|
} catch (err: any) {
|
||||||
|
if (typeof err.message === 'string' && err.message.includes('Privileged intent provided is not enabled or whitelisted')) {
|
||||||
|
warning((client as any).server, 'Discord login failed due to missing privileged intents. Please enable the required intents in the Discord Developer Portal.');
|
||||||
|
} else {
|
||||||
|
warning((client as any).server, 'Discord login failed: ' + String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
let clientStatusInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
function logClientState(context: string) {
|
||||||
|
// only log if server is attached and connected
|
||||||
|
if (!(client as any).server || !(client as any).server.transport) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
info((client as any).server, `Discord client state [${context}]: ${JSON.stringify({
|
||||||
|
isReady: client.isReady(),
|
||||||
|
hasToken: !!client.token,
|
||||||
|
user: client.user ? {
|
||||||
|
id: client.user.id,
|
||||||
|
tag: client.user.tag,
|
||||||
|
} : null
|
||||||
|
})}`);
|
||||||
|
}
|
||||||
|
clientStatusInterval = setInterval(() => {
|
||||||
|
logClientState("periodic check");
|
||||||
|
}, 10000);
|
||||||
|
client.on('ready', () => {
|
||||||
|
logClientState("on ready event");
|
||||||
|
});
|
||||||
|
client.on('disconnect', () => {
|
||||||
|
logClientState("on disconnect event");
|
||||||
|
if (clientStatusInterval) {
|
||||||
|
clearInterval(clientStatusInterval);
|
||||||
|
clientStatusInterval = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration from command-line arguments or environment variables
|
||||||
|
const config = {
|
||||||
|
DISCORD_TOKEN: (() => {
|
||||||
|
try {
|
||||||
|
// First check for command-line argument
|
||||||
|
const tokenIndex = process.argv.indexOf('--config');
|
||||||
|
if (tokenIndex !== -1 && tokenIndex + 1 < process.argv.length) {
|
||||||
|
const configArg = process.argv[tokenIndex + 1];
|
||||||
|
if (typeof configArg === 'string') {
|
||||||
|
try {
|
||||||
|
const parsedConfig = JSON.parse(configArg);
|
||||||
|
return parsedConfig.DISCORD_TOKEN;
|
||||||
|
} catch (err) {
|
||||||
|
// If not valid JSON, try using the string directly
|
||||||
|
return configArg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Then try environment variable
|
||||||
|
return process.env.DISCORD_TOKEN;
|
||||||
|
} catch (err) {
|
||||||
|
process.stderr.write('Error parsing configuration: ' + String(err) + '\n');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
TRANSPORT: (() => {
|
||||||
|
// Check for transport type argument
|
||||||
|
const transportIndex = process.argv.indexOf('--transport');
|
||||||
|
if (transportIndex !== -1 && transportIndex + 1 < process.argv.length) {
|
||||||
|
return process.argv[transportIndex + 1];
|
||||||
|
}
|
||||||
|
// Default to stdio
|
||||||
|
return 'stdio';
|
||||||
|
})(),
|
||||||
|
HTTP_PORT: (() => {
|
||||||
|
// Check for port argument
|
||||||
|
const portIndex = process.argv.indexOf('--port');
|
||||||
|
if (portIndex !== -1 && portIndex + 1 < process.argv.length) {
|
||||||
|
return parseInt(process.argv[portIndex + 1]);
|
||||||
|
}
|
||||||
|
// Default port for MCP
|
||||||
|
return 8080;
|
||||||
|
})()
|
||||||
|
};
|
||||||
|
|
||||||
|
const discord = createDiscordClient(config.DISCORD_TOKEN);
|
||||||
|
const server = createMcpServer(discord);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// region MCP Streamable HTTP Transport Handlers
|
||||||
|
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
|
||||||
|
const mcpPostHandler = async (req: Request, res: Response) => {
|
||||||
|
const sessionId = req.headers['mcp-session-id'] as string;
|
||||||
|
if (sessionId) {
|
||||||
|
process.stderr.write(`Received MCP request for session: ${sessionId}\n`);
|
||||||
|
} else {
|
||||||
|
process.stderr.write('Request body:' + JSON.stringify(req.body) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let transport: StreamableHTTPServerTransport;
|
||||||
|
if (sessionId && transports[sessionId]) {
|
||||||
|
transport = transports[sessionId];
|
||||||
|
} else if (!sessionId && isInitializeRequest(req.body)) {
|
||||||
|
// New session initialization
|
||||||
|
const eventStore = new InMemoryEventStore();
|
||||||
|
transport = new StreamableHTTPServerTransport({
|
||||||
|
sessionIdGenerator: () => randomUUID(),
|
||||||
|
eventStore,
|
||||||
|
enableJsonResponse: true, // Enable JSON response mode
|
||||||
|
onsessioninitialized: sessionId => {
|
||||||
|
// Store the transport by session ID for future requests
|
||||||
|
transports[sessionId] = transport;
|
||||||
|
process.stderr.write(`Initialized new MCP session: ${sessionId}\n`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up onclose handler to clean up transport
|
||||||
|
transport.onclose = () => {
|
||||||
|
const sid = transport.sessionId;
|
||||||
|
if (sid && transports[sid]) {
|
||||||
|
delete transports[sid];
|
||||||
|
process.stderr.write(`Closed and removed MCP session: ${sid}\n`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Connect the transport to the MCP server before handling the request
|
||||||
|
await server.connect(transport);
|
||||||
|
|
||||||
|
await transport.handleRequest(req, res, req.body);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// Invalid request without session ID
|
||||||
|
res.status(400).json({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
error: { code: -32000, message: "Invalid or missing MCP session ID." },
|
||||||
|
id: req.body.id || null
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the request with the existing transport - no need to reconnect
|
||||||
|
await transport.handleRequest(req, res, req.body);
|
||||||
|
} catch (err) {
|
||||||
|
process.stderr.write('Error handling MCP request: ' + String(err) + '\n');
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
error: { code: -32000, message: "Internal server error." },
|
||||||
|
id: req.body.id || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Handle GET requests for SSE streams (using built-in support from StreamableHTTP)
|
||||||
|
const mcpGetHandler = async (req: Request, res: Response) => {
|
||||||
|
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||||
|
if (!sessionId || !transports[sessionId]) {
|
||||||
|
res.status(400).send('Invalid or missing session ID');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Last-Event-ID header for resumability
|
||||||
|
const lastEventId = req.headers['last-event-id'] as string | undefined;
|
||||||
|
if (lastEventId) {
|
||||||
|
process.stderr.write(`Client reconnecting with Last-Event-ID: ${lastEventId}\n`);
|
||||||
|
} else {
|
||||||
|
process.stderr.write(`Establishing new SSE stream for session ${sessionId}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transport = transports[sessionId];
|
||||||
|
await transport.handleRequest(req, res);
|
||||||
|
};
|
||||||
|
// Handle DELETE requests for session termination (according to MCP spec)
|
||||||
|
const mcpDeleteHandler = async (req: Request, res: Response) => {
|
||||||
|
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||||
|
if (!sessionId || !transports[sessionId]) {
|
||||||
|
res.status(400).send('Invalid or missing session ID');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stderr.write(`Received session termination request for session ${sessionId}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const transport = transports[sessionId];
|
||||||
|
await transport.handleRequest(req, res);
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write('Error handling session termination:' + String(error) + '\n');
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).send('Error processing session termination');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// Handle both Stdio and HTTP transports based on configuration
|
||||||
|
if (config.TRANSPORT.toLowerCase() === 'http') {
|
||||||
|
// HTTP transport endpoints
|
||||||
|
app.post('/mcp', mcpPostHandler);
|
||||||
|
app.get('/mcp', mcpGetHandler);
|
||||||
|
app.delete('/mcp', mcpDeleteHandler);
|
||||||
|
|
||||||
|
const port = config.HTTP_PORT || 8080;
|
||||||
|
app.listen(port, error => {
|
||||||
|
if (error) {
|
||||||
|
process.stderr.write('Error starting MCP HTTP server: ' + String(error) + '\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
process.stderr.write(`MCP Streamable HTTP Server listening on port ${port}\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle server shutdown
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
process.stderr.write('Shutting down server...\n');
|
||||||
|
|
||||||
|
// Close all active transports to properly clean up resources
|
||||||
|
for (const sessionId in transports) {
|
||||||
|
try {
|
||||||
|
process.stderr.write(`Closing transport for session ${sessionId}\n`);
|
||||||
|
await transports[sessionId].close();
|
||||||
|
delete transports[sessionId];
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(`Error closing transport for session ${sessionId}:` + String(error) + '\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.stderr.write('Server shutdown complete\n');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Stdio transport
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
await server.connect(transport);
|
||||||
|
process.stderr.write('MCP Stdio Server started. Awaiting messages...\n');
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { Server } from '@modelcontextprotocol/sdk/server'
|
||||||
|
|
||||||
|
export type Level = 'debug' | 'info' | 'notice' | 'warning' | 'error' | 'critical' | 'alert' | 'emergency';
|
||||||
|
|
||||||
|
const levelPriority: Record<Level, number> = {
|
||||||
|
debug: 0,
|
||||||
|
info: 1,
|
||||||
|
notice: 2,
|
||||||
|
warning: 3,
|
||||||
|
error: 4,
|
||||||
|
critical: 5,
|
||||||
|
alert: 6,
|
||||||
|
emergency: 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
export let currentLevel: Level = 'info';
|
||||||
|
|
||||||
|
export function setLevel(level: Level) {
|
||||||
|
if (!levelPriority.hasOwnProperty(level)) return false;
|
||||||
|
currentLevel = level;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function log(server: Server, message: string, level: Level = 'info') {
|
||||||
|
if (levelPriority[level] < levelPriority[currentLevel]) return;
|
||||||
|
server.sendLoggingMessage(
|
||||||
|
{
|
||||||
|
level: level,
|
||||||
|
logger: 'mcp-discord',
|
||||||
|
data: { text: message },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function info(server: Server, message: string) {
|
||||||
|
log(server, message, 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function warning(server: Server, message: string) {
|
||||||
|
log(server, message, 'warning');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function error(server: Server, message: string) {
|
||||||
|
log(server, message, 'error');
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue