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:
Sooty 2025-10-21 20:03:40 +01:00
parent 9a7a9d7fea
commit 95cfd6c2cc
2 changed files with 385 additions and 0 deletions

340
src/app.ts Normal file
View File

@ -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');
}

45
src/notifications.ts Normal file
View File

@ -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');
}