Fix streamable HTTP transport to use SDK's handleRequest method

- Remove custom /mcp endpoint handling that was causing 405 errors
- Use StreamableHTTPServerTransport.handleRequest() to properly handle MCP protocol
- Fixes compatibility with MCP Inspector and Kiro clients
This commit is contained in:
robertlestak 2025-11-28 10:51:44 -08:00
parent 2616fc1a10
commit 4c2621717e
1 changed files with 14 additions and 560 deletions

View File

@ -65,571 +65,16 @@ export class StreamableHttpTransport implements MCPTransport {
constructor(private port: number = 8080) { constructor(private port: number = 8080) {
this.app = express(); this.app = express();
this.app.use(express.json()); this.app.use(express.json());
this.setupEndpoints();
this.sessionId = `session_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`; this.sessionId = `session_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`;
info(`Created HTTP transport with session ID: ${this.sessionId}`); info(`Created HTTP transport with session ID: ${this.sessionId}`);
} }
private setupEndpoints() {
// Handler for POST requests
this.app.post('/mcp', (req: Request, res: Response) => {
info('Received MCP request: ' + JSON.stringify(req.body));
this.handleMcpRequest(req, res).catch(error => {
error('Unhandled error in MCP request: ' + String(error));
});
});
// Handler for non-POST methods
this.app.all('/mcp', (req: Request, res: Response) => {
if (req.method !== 'POST') {
res.status(405).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Method not allowed. Use POST.',
},
id: null,
});
}
});
}
private async handleMcpRequest(req: Request, res: Response) {
try {
if (!this.server) {
return res.json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Server not initialized',
},
id: req.body?.id || null,
});
}
info(`Request body (session ${this.sessionId}): ${JSON.stringify(req.body)}`);
// Handle all tool requests in a generic way
if (!req.body.method) {
return res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32600,
message: 'Invalid Request: No method specified',
},
id: req.body?.id || null,
});
}
// Handle all tools directly with proper error handling
try {
const method = req.body.method;
const params = req.body.params || {};
// Make sure toolContext is available for tool methods
if (!this.toolContext && method !== 'list_tools' && method !== 'initialize') {
return res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Tool context not initialized. Service may need to be restarted.',
},
id: req.body?.id || null,
});
}
let result;
// Handle each tool method directly
switch (method) {
case 'initialize':
// Handle initialize method for MCP protocol compliance
result = {
protocolVersion: "2025-03-26",
capabilities: {
tools: {
listChanged: false
},
logging: {}
},
serverInfo: {
name: "MCP-Discord",
version: "1.2.0"
}
};
break;
case 'notifications/initialized':
// Client indicates it's ready to begin normal operation
info("Client initialized. Starting normal operations.");
// No result needed for notifications
return res.json({
jsonrpc: "2.0",
result: null,
id: req.body.id
});
case 'tools/list':
// New MCP method name format
result = { tools: toolList };
break;
case 'list_tools':
// Legacy method name for backward compatibility
result = { tools: toolList };
break;
case 'discord_login':
result = await loginHandler(params, this.toolContext!);
// Log client state after login
info(`Client state after login: ${JSON.stringify({
isReady: this.toolContext!.client.isReady(),
hasToken: !!this.toolContext!.client.token,
user: this.toolContext!.client.user ? {
id: this.toolContext!.client.user.id,
tag: this.toolContext!.client.user.tag,
} : null
})}`);
break;
// Make sure Discord client is logged in for other Discord API tools
// but return a proper JSON-RPC error rather than throwing an exception
case 'discord_send':
case 'discord_get_forum_channels':
case 'discord_create_forum_post':
case 'discord_get_forum_post':
case 'discord_reply_to_forum':
case 'discord_delete_forum_post':
case 'discord_create_text_channel':
case 'discord_delete_channel':
case 'discord_read_messages':
case 'discord_get_server_info':
case 'discord_add_reaction':
case 'discord_add_multiple_reactions':
case 'discord_remove_reaction':
case 'discord_delete_message':
case 'discord_create_webhook':
case 'discord_send_webhook_message':
case 'discord_edit_webhook':
case 'discord_delete_webhook':
case 'discord_create_category':
case 'discord_edit_category':
case 'discord_delete_category':
case 'discord_list_servers':
case 'discord_search_messages':
// Check if client is logged in
if (!this.toolContext!.client.isReady()) {
error(`Client not ready for method ${method}, client state: ${JSON.stringify({
isReady: this.toolContext!.client.isReady(),
hasToken: !!this.toolContext!.client.token,
user: this.toolContext!.client.user ? {
id: this.toolContext!.client.user.id,
tag: this.toolContext!.client.user.tag,
} : null
})}`);
// Check if we have a token but not ready - try to force reconnect
if (this.toolContext!.client.token) {
info("Has token but not ready - attempting to force reconnect");
try {
// Attempt to force login with existing token
await this.toolContext!.client.login(this.toolContext!.client.token);
info(`Force reconnect successful: ${this.toolContext!.client.isReady()}`);
// If still not ready after reconnect, return error
if (!this.toolContext!.client.isReady()) {
return res.json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Discord client reconnect failed. Please use discord_login tool first.',
},
id: req.body?.id || null,
});
}
// Continue with original request as now logged in
info("Reconnected successfully, continuing with original request");
} catch (reconnectError) {
error(`Reconnect failed: ${reconnectError instanceof Error ? reconnectError.message : String(reconnectError)}`);
return res.json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Discord client reconnect failed. Please use discord_login tool first.',
},
id: req.body?.id || null,
});
}
} else {
return res.json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Discord client not logged in. Please use discord_login tool first.',
},
id: req.body?.id || null,
});
}
}
// Call appropriate handler based on method
switch (method) {
case 'discord_send':
result = await sendMessageHandler(params, this.toolContext!);
break;
case 'discord_get_forum_channels':
result = await getForumChannelsHandler(params, this.toolContext!);
break;
case 'discord_create_forum_post':
result = await createForumPostHandler(params, this.toolContext!);
break;
case 'discord_get_forum_post':
result = await getForumPostHandler(params, this.toolContext!);
break;
case 'discord_reply_to_forum':
result = await replyToForumHandler(params, this.toolContext!);
break;
case 'discord_delete_forum_post':
result = await deleteForumPostHandler(params, this.toolContext!);
break;
case 'discord_create_text_channel':
result = await createTextChannelHandler(params, this.toolContext!);
break;
case 'discord_delete_channel':
result = await deleteChannelHandler(params, this.toolContext!);
break;
case 'discord_read_messages':
result = await readMessagesHandler(params, this.toolContext!);
break;
case 'discord_get_server_info':
result = await getServerInfoHandler(params, this.toolContext!);
break;
case 'discord_add_reaction':
result = await addReactionHandler(params, this.toolContext!);
break;
case 'discord_add_multiple_reactions':
result = await addMultipleReactionsHandler(params, this.toolContext!);
break;
case 'discord_remove_reaction':
result = await removeReactionHandler(params, this.toolContext!);
break;
case 'discord_delete_message':
result = await deleteMessageHandler(params, this.toolContext!);
break;
case 'discord_create_webhook':
result = await createWebhookHandler(params, this.toolContext!);
break;
case 'discord_send_webhook_message':
result = await sendWebhookMessageHandler(params, this.toolContext!);
break;
case 'discord_edit_webhook':
result = await editWebhookHandler(params, this.toolContext!);
break;
case 'discord_delete_webhook':
result = await deleteWebhookHandler(params, this.toolContext!);
break;
case 'discord_create_category':
result = await createCategoryHandler(params, this.toolContext!);
break;
case 'discord_edit_category':
result = await editCategoryHandler(params, this.toolContext!);
break;
case 'discord_delete_category':
result = await deleteCategoryHandler(params, this.toolContext!);
break;
case 'discord_list_servers':
result = await listServersHandler(params, this.toolContext!);
case 'discord_search_messages':
result = await searchMessagesHandler(params, this.toolContext!);
break;break;
}
break;
case 'tools/call':
// Handle new tools/call method format
const toolName = params.name;
const toolArgs = params.arguments || {};
// Check if Discord client is logged in for Discord API tools
if (toolName !== 'discord_login' &&
toolName.startsWith('discord_') &&
!this.toolContext!.client.isReady()) {
error(`Client not ready for tool ${toolName}, client state: ${JSON.stringify({
isReady: this.toolContext!.client.isReady(),
hasToken: !!this.toolContext!.client.token,
user: this.toolContext!.client.user ? {
id: this.toolContext!.client.user.id,
tag: this.toolContext!.client.user.tag,
} : null
})}`);
// Check if we have a token but not ready - try to force reconnect
if (this.toolContext!.client.token) {
info("Has token but not ready - attempting to force reconnect");
try {
// Attempt to force login with existing token
await this.toolContext!.client.login(this.toolContext!.client.token);
info(`Force reconnect successful: ${this.toolContext!.client.isReady()}`);
// If still not ready after reconnect, return error
if (!this.toolContext!.client.isReady()) {
return res.json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Discord client reconnect failed. Please use discord_login tool first.',
},
id: req.body?.id || null,
});
}
// Continue with original request as now logged in
info("Reconnected successfully, continuing with original request");
} catch (reconnectError) {
error(`Reconnect failed: ${reconnectError instanceof Error ? reconnectError.message : String(reconnectError)}`);
return res.json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Discord client reconnect failed. Please use discord_login tool first.',
},
id: req.body?.id || null,
});
}
} else {
return res.json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Discord client not logged in. Please use discord_login tool first.',
},
id: req.body?.id || null,
});
}
}
// Call the appropriate handler based on tool name
switch (toolName) {
case 'discord_login':
result = await loginHandler(toolArgs, this.toolContext!);
// Log client state after login
info(`Client state after login: ${JSON.stringify({
isReady: this.toolContext!.client.isReady(),
hasToken: !!this.toolContext!.client.token,
user: this.toolContext!.client.user ? {
id: this.toolContext!.client.user.id,
tag: this.toolContext!.client.user.tag,
} : null
})}`);
break;
case 'discord_send':
result = await sendMessageHandler(toolArgs, this.toolContext!);
break;
case 'discord_get_forum_channels':
result = await getForumChannelsHandler(toolArgs, this.toolContext!);
break;
case 'discord_create_forum_post':
result = await createForumPostHandler(toolArgs, this.toolContext!);
break;
case 'discord_get_forum_post':
result = await getForumPostHandler(toolArgs, this.toolContext!);
break;
case 'discord_reply_to_forum':
result = await replyToForumHandler(toolArgs, this.toolContext!);
break;
case 'discord_delete_forum_post':
result = await deleteForumPostHandler(toolArgs, this.toolContext!);
break;
case 'discord_create_text_channel':
result = await createTextChannelHandler(toolArgs, this.toolContext!);
break;
case 'discord_delete_channel':
result = await deleteChannelHandler(toolArgs, this.toolContext!);
break;
case 'discord_read_messages':
result = await readMessagesHandler(toolArgs, this.toolContext!);
break;
case 'discord_get_server_info':
result = await getServerInfoHandler(toolArgs, this.toolContext!);
break;
case 'discord_add_reaction':
result = await addReactionHandler(toolArgs, this.toolContext!);
break;
case 'discord_add_multiple_reactions':
result = await addMultipleReactionsHandler(toolArgs, this.toolContext!);
break;
case 'discord_remove_reaction':
result = await removeReactionHandler(toolArgs, this.toolContext!);
break;
case 'discord_delete_message':
result = await deleteMessageHandler(toolArgs, this.toolContext!);
break;
case 'discord_create_webhook':
result = await createWebhookHandler(toolArgs, this.toolContext!);
break;
case 'discord_send_webhook_message':
result = await sendWebhookMessageHandler(toolArgs, this.toolContext!);
break;
case 'discord_edit_webhook':
result = await editWebhookHandler(toolArgs, this.toolContext!);
break;
case 'discord_delete_webhook':
result = await deleteWebhookHandler(toolArgs, this.toolContext!);
break;
case 'discord_create_category':
result = await createCategoryHandler(toolArgs, this.toolContext!);
break;
case 'discord_edit_category':
result = await editCategoryHandler(toolArgs, this.toolContext!);
break;
case 'discord_delete_category':
result = await deleteCategoryHandler(toolArgs, this.toolContext!);
break;
case 'discord_list_servers':
result = await listServersHandler(toolArgs, this.toolContext!);
break;
case 'discord_search_messages':
result = await searchMessagesHandler(toolArgs, this.toolContext!);
break;
default:
return res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32601,
message: `Unknown tool: ${toolName}`,
},
id: req.body?.id || null,
});
}
break;
default:
// For method 'ping' and other non-critical methods, just return an empty result
// This ensures MCP compatibility for health checks and probes
if (method === 'ping') {
info(`Returning empty response for ping request`);
result = {};
} else {
// For other unknown methods, return method not found error
return res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32601,
message: `Method not found: ${method}`,
},
id: req.body?.id || null,
});
}
}
info(`Request for ${method} handled successfully`);
// Handle the case where tool handlers return { content, isError }
if (result && typeof result === 'object' && 'content' in result) {
// If it's an error from the tool handler
if ('isError' in result && result.isError) {
error(`Tool error response: ${JSON.stringify(result)}`);
return res.json({
jsonrpc: '2.0',
id: req.body.id,
error: {
code: -32603,
message: Array.isArray(result.content)
? result.content.map((item: any) => item.text).join(' ')
: 'Tool execution error'
}
});
}
// Return success result but maintain same format as other RPC methods
const finalResponse = {
jsonrpc: '2.0',
id: req.body.id,
result: result
};
info(`Sending response (session ${this.sessionId}): ${JSON.stringify(finalResponse)}`);
return res.json(finalResponse);
}
// Standard result format
const finalResponse = {
jsonrpc: '2.0',
id: req.body.id,
result: result
};
info(`Sending response (session ${this.sessionId}): ${JSON.stringify(finalResponse)}`);
return res.json(finalResponse);
} catch (err) {
error('Error processing tool request: ' + String(err));
// Handle validation errors
if (err && typeof err === 'object' && 'name' in err && err.name === 'ZodError') {
return res.json({
jsonrpc: '2.0',
error: {
code: -32602,
message: `Invalid parameters: ${err && typeof err === 'object' && 'message' in err ? String((err as any).message) : 'Unknown validation error'}`,
},
id: req.body?.id || null,
});
}
// Handle all other errors
return res.json({
jsonrpc: '2.0',
error: {
code: -32603,
message: err instanceof Error ? err.message : 'Unknown error',
},
id: req.body?.id || null,
});
}
} catch (err) {
error('Error handling MCP request: ' + String(err));
if (!res.headersSent) {
res.json({
jsonrpc: '2.0',
error: {
code: -32603,
message: err instanceof Error ? err.message : 'Internal server error',
},
id: req.body?.id || null,
});
}
}
}
async start(server: Server): Promise<void> { async start(server: Server): Promise<void> {
this.server = server; this.server = server;
info('Starting HTTP transport with server: ' + String(!!this.server)); info('Starting HTTP transport with server: ' + String(!!this.server));
// Try to get client from the DiscordMCPServer instance // Try to get client from the DiscordMCPServer instance
// First, check if the server is passed from DiscordMCPServer
if (server) { if (server) {
// Try to access client directly from server._context
const anyServer = server as any; const anyServer = server as any;
let client: Client | undefined; let client: Client | undefined;
@ -637,12 +82,10 @@ export class StreamableHttpTransport implements MCPTransport {
client = anyServer._context.client; client = anyServer._context.client;
info('Found client in server._context'); info('Found client in server._context');
} }
// Also check if the server object has client directly
else if (anyServer.client instanceof Client) { else if (anyServer.client instanceof Client) {
client = anyServer.client; client = anyServer.client;
info('Found client directly on server object'); info('Found client directly on server object');
} }
// Look in parent object if available
else if (anyServer._parent?.client instanceof Client) { else if (anyServer._parent?.client instanceof Client) {
client = anyServer._parent.client; client = anyServer._parent.client;
info('Found client in server._parent'); info('Found client in server._parent');
@ -652,7 +95,6 @@ export class StreamableHttpTransport implements MCPTransport {
this.toolContext = createToolContext(client); this.toolContext = createToolContext(client);
info('Tool context initialized with Discord client'); info('Tool context initialized with Discord client');
} else { } else {
// Create a real Discord client instead of a dummy
info('Creating new Discord client for transport'); info('Creating new Discord client for transport');
const newClient = new Client({ const newClient = new Client({
intents: [ intents: [
@ -667,15 +109,27 @@ export class StreamableHttpTransport implements MCPTransport {
} }
} }
// Create a stateless transport // Create transport
this.transport = new StreamableHTTPServerTransport({ this.transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined // set to undefined for stateless servers sessionIdGenerator: undefined // stateless
}); });
// Connect the transport // Connect the transport
await this.server.connect(this.transport); await this.server.connect(this.transport);
info('Transport connected'); info('Transport connected');
// Setup /mcp endpoint to use transport.handleRequest()
this.app.all('/mcp', async (req: Request, res: Response) => {
try {
await this.transport!.handleRequest(req, res, req.body);
} catch (err) {
error('Error handling MCP request: ' + String(err));
if (!res.headersSent) {
res.status(500).json({ error: 'Internal server error' });
}
}
});
return new Promise((resolve) => { return new Promise((resolve) => {
this.httpServer = this.app.listen(this.port, '0.0.0.0', () => { this.httpServer = this.app.listen(this.port, '0.0.0.0', () => {
info(`MCP Server listening on 0.0.0.0:${this.port}`); info(`MCP Server listening on 0.0.0.0:${this.port}`);