|
import { Logger } from '@aws-lambda-powertools/logger'; |
|
import { LambdaFunctionUrlSchema } from '@aws-lambda-powertools/parser/schemas/lambda'; |
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; |
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; |
|
import { |
|
type JSONRPCMessage, |
|
JSONRPCMessageSchema, |
|
} from '@modelcontextprotocol/sdk/types.js'; |
|
import type { Context } from 'aws-lambda'; |
|
import { z } from 'zod'; |
|
|
|
const logger = new Logger({ |
|
serviceName: 'mcp-lambda', |
|
logLevel: 'INFO', |
|
}); |
|
const server = new McpServer({ |
|
name: 'MCP Server on AWS Lambda', |
|
version: '1.0.0', |
|
}); |
|
|
|
// Add an addition tool |
|
server.tool('add', { a: z.number(), b: z.number() }, async ({ a, b }) => ({ |
|
content: [{ type: 'text', text: String(a + b) }], |
|
})); |
|
|
|
const isMessageWithId = ( |
|
message: JSONRPCMessage |
|
): message is JSONRPCMessage & { id: number | string } => |
|
'id' in message && |
|
(typeof message.id === 'number' || typeof message.id === 'string'); |
|
|
|
class HttpServerTransport implements Transport { |
|
#pendingRequests = new Map< |
|
number | string, |
|
{ |
|
resolve: (message: JSONRPCMessage) => void; |
|
reject: (error: Error) => void; |
|
} |
|
>(); |
|
|
|
public onmessage?: (message: JSONRPCMessage) => void; |
|
public start = async () => {}; |
|
public close = async () => {}; |
|
|
|
public send = async (message: JSONRPCMessage) => { |
|
if (isMessageWithId(message)) { |
|
const pendingRequest = this.#pendingRequests.get(message.id); |
|
if (pendingRequest !== undefined) { |
|
pendingRequest.resolve(message); |
|
this.#pendingRequests.delete(message.id); |
|
} |
|
} |
|
}; |
|
|
|
#startFreshSession = () => { |
|
this.#pendingRequests.clear(); |
|
}; |
|
|
|
public resolve = async ( |
|
jsonRPCMessages: z.infer<typeof JSONRPCMessageSchema>[] |
|
): Promise<JSONRPCMessage[] | JSONRPCMessage | undefined> => { |
|
this.#startFreshSession(); |
|
|
|
jsonRPCMessages.map((message) => { |
|
this.onmessage?.(message); |
|
}); |
|
|
|
const messagesWithId = jsonRPCMessages.filter(isMessageWithId); |
|
|
|
if (messagesWithId.length > 0) { |
|
return await Promise.all( |
|
messagesWithId.map( |
|
(message) => |
|
new Promise<JSONRPCMessage>((resolve, reject) => { |
|
this.#pendingRequests.set(message.id, { resolve, reject }); |
|
}) |
|
) |
|
); |
|
} |
|
|
|
return new Promise<JSONRPCMessage>((resolve, reject) => { |
|
this.#pendingRequests.set(messagesWithId[0].id, { resolve, reject }); |
|
}); |
|
}; |
|
} |
|
|
|
const mcpEvent = LambdaFunctionUrlSchema.extend({ |
|
headers: z.object({ |
|
'content-type': z.literal('application/json'), |
|
accept: z.literal('application/json'), |
|
}), |
|
body: z.unknown(), |
|
}) |
|
.transform((data, ctx) => { |
|
const { body, isBase64Encoded } = data; |
|
if (typeof body !== 'string') { |
|
ctx.addIssue({ |
|
code: z.ZodIssueCode.custom, |
|
message: 'Body must be a string', |
|
}); |
|
return z.NEVER; |
|
} |
|
try { |
|
const decodedBody = isBase64Encoded |
|
? Buffer.from(body, 'base64').toString() |
|
: body; |
|
const parsedJSONBody = JSON.parse(decodedBody); |
|
data.body = Array.isArray(parsedJSONBody) |
|
? parsedJSONBody |
|
: [parsedJSONBody]; |
|
data.isBase64Encoded = false; |
|
return data; |
|
} catch (error) { |
|
ctx.addIssue({ |
|
code: z.ZodIssueCode.custom, |
|
message: 'Failed to parse or decode JSON body', |
|
}); |
|
return z.NEVER; |
|
} |
|
}) |
|
.refine( |
|
(data) => { |
|
const { body } = data; |
|
if (Array.isArray(body)) { |
|
return body.every( |
|
(message) => JSONRPCMessageSchema.safeParse(message).success |
|
); |
|
} |
|
return JSONRPCMessageSchema.safeParse(body).success; |
|
}, |
|
{ |
|
message: 'Invalid JSON-RPC message format', |
|
} |
|
); |
|
|
|
const transport = new HttpServerTransport(); |
|
await server.connect(transport); |
|
|
|
export const handler = async (event: unknown, context: Context) => { |
|
const parseResult = mcpEvent.safeParse(event); |
|
if (!parseResult.success) { |
|
logger.error('Invalid event format', parseResult.error); |
|
return { |
|
statusCode: 400, |
|
body: JSON.stringify({ |
|
jsonrpc: '2.0', |
|
error: { |
|
code: -32000, |
|
message: 'Bad Request: Invalid event format', |
|
}, |
|
id: null, |
|
}), |
|
}; |
|
} |
|
const { body } = parseResult.data; |
|
const responseMessages = await transport.resolve( |
|
body as z.infer<typeof JSONRPCMessageSchema>[] |
|
); |
|
if (responseMessages === undefined) { |
|
return { statusCode: 202, body: '' }; |
|
} |
|
return { statusCode: 200, body: JSON.stringify(responseMessages) }; |
|
}; |