diff --git a/.gitignore b/.gitignore index 2d2b47d..1877e5d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,10 @@ .idea -node_modules \ No newline at end of file +node_modules +dist +.build +.serverless +.env +.env.local +.env.development.local +.env.test.local +.env.production.local \ No newline at end of file diff --git a/README.md b/README.md index 0d89387..c340694 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,10 @@ npm install npm install -g osls ``` -3. Run Locally with serverless-offline +3. Run Locally +This command compiles the TypeScript code and starts the local server using `serverless-offline`. ```bash -npm sls offline +npm run start ``` Local endpoint will be available at: @@ -141,10 +142,10 @@ export const handler = middy() ## 📡 Deploy to AWS -Just run: +This command compiles the TypeScript code and deploys the service to your configured AWS account using Serverless Framework. ```bash -sls deploy +npm run deploy ``` After deployment, the MCP server will be live at the URL output by the command. diff --git a/__tests__/add-tool/add-tool-edge-cases.test.mjs b/__tests__/add-tool/add-tool-edge-cases.test.mjs deleted file mode 100644 index 5493bef..0000000 --- a/__tests__/add-tool/add-tool-edge-cases.test.mjs +++ /dev/null @@ -1,63 +0,0 @@ -// __tests__/mcpAddToolEdgeCases.test.mjs -import { handler } from '../../src/index.mjs'; - -describe('MCP Server - tools/call "add" method (edge cases)', () => { - const baseEvent = { - httpMethod: 'POST', - headers: { - 'content-type': 'application/json', - accept: 'application/json', - jsonrpc: '2.0', - }, - }; - - const callAdd = (params, id = 999) => ({ - ...baseEvent, - body: JSON.stringify({ - jsonrpc: '2.0', - id, - method: 'tools/call', - params: { - name: 'add', - arguments: params, - }, - }), - }); - - it('should return a validation error if "a" is missing', async () => { - const response = await handler(callAdd({ b: 2 }, 101)); - const body = JSON.parse(response.body); - expect(body).toHaveProperty('error'); - expect(body.error.message).toMatch(/a/i); - }); - - it('should return a validation error if "b" is missing', async () => { - const response = await handler(callAdd({ a: 2 }, 102)); - const body = JSON.parse(response.body); - expect(body.error.message).toMatch(/b/i); - }); - - it('should return a validation error if "a" is a string', async () => { - const response = await handler(callAdd({ a: '5', b: 2 }, 103)); - const body = JSON.parse(response.body); - expect(body.error.message).toMatch(/a/i); - }); - - it('should return a validation error if both are strings', async () => { - const response = await handler(callAdd({ a: 'foo', b: 'bar' }, 104)); - const body = JSON.parse(response.body); - expect(body.error.message).toMatch(/number/i); - }); - - it('should return 0 when both a and b are 0', async () => { - const response = await handler(callAdd({ a: 0, b: 0 }, 105)); - const body = JSON.parse(response.body); - expect(body.result.content[0].text).toBe('0'); - }); - - it('should handle negative numbers correctly', async () => { - const response = await handler(callAdd({ a: -3, b: -7 }, 106)); - const body = JSON.parse(response.body); - expect(body.result.content[0].text).toBe('-10'); - }); -}); diff --git a/__tests__/add-tool/add-tool-edge-cases.test.ts b/__tests__/add-tool/add-tool-edge-cases.test.ts new file mode 100644 index 0000000..3d5be19 --- /dev/null +++ b/__tests__/add-tool/add-tool-edge-cases.test.ts @@ -0,0 +1,74 @@ +// __tests__/mcpAddToolEdgeCases.test.ts +import { handler } from '../../src/index'; +import { describe, it, expect } from '@jest/globals'; +import { createMockEvent, createMockContext } from '../test-utils'; + +describe('MCP Server - tools/call "add" method (edge cases)', () => { + + const createAddBody = (params: any, id = 999): string => { + return JSON.stringify({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { + name: 'add', + arguments: params, + }, + }); + }; + + it('should return a validation error if "a" is missing', async () => { + const body = createAddBody({ b: 2 }, 101); + const event = createMockEvent({ body, rawPath: '/add-edge-case' }); + const context = createMockContext(); + const response = await handler(event, context); + const responseBody = JSON.parse(response.body); + expect(responseBody).toHaveProperty('error'); + expect(responseBody.error.message).toMatch(/a/i); + }); + + it('should return a validation error if "b" is missing', async () => { + const body = createAddBody({ a: 2 }, 102); + const event = createMockEvent({ body, rawPath: '/add-edge-case' }); + const context = createMockContext(); + const response = await handler(event, context); + const responseBody = JSON.parse(response.body); + expect(responseBody.error.message).toMatch(/b/i); + }); + + it('should return a validation error if "a" is a string', async () => { + const body = createAddBody({ a: '5', b: 2 }, 103); + const event = createMockEvent({ body, rawPath: '/add-edge-case' }); + const context = createMockContext(); + const response = await handler(event, context); + const responseBody = JSON.parse(response.body); + expect(responseBody.error.message).toMatch(/a/i); + }); + + it('should return a validation error if both are strings', async () => { + const body = createAddBody({ a: 'foo', b: 'bar' }, 104); + const event = createMockEvent({ body, rawPath: '/add-edge-case' }); + const context = createMockContext(); + const response = await handler(event, context); + const responseBody = JSON.parse(response.body); + expect(responseBody.error.message).toMatch(/number/i); + }); + + it('should return 0 when both a and b are 0', async () => { + const body = createAddBody({ a: 0, b: 0 }, 105); + const event = createMockEvent({ body, rawPath: '/add-edge-case' }); + const context = createMockContext(); + const response = await handler(event, context); + const responseBody = JSON.parse(response.body); + expect(responseBody.result.content[0].text).toBe('0'); + }); + + it('should handle negative numbers correctly', async () => { + const body = createAddBody({ a: -3, b: -7 }, 106); + const event = createMockEvent({ body, rawPath: '/add-edge-case' }); + const context = createMockContext(); + const response = await handler(event, context); + const responseBody = JSON.parse(response.body); + expect(responseBody.result.content[0].text).toBe('-10'); + }); +}); diff --git a/__tests__/add-tool/add-tool.test.mjs b/__tests__/add-tool/add-tool.test.ts similarity index 74% rename from __tests__/add-tool/add-tool.test.mjs rename to __tests__/add-tool/add-tool.test.ts index cc1a1c1..7af7c29 100644 --- a/__tests__/add-tool/add-tool.test.mjs +++ b/__tests__/add-tool/add-tool.test.ts @@ -1,15 +1,14 @@ -// __tests__/mcpAddTool.test.mjs -import { handler } from '../../src/index.mjs'; +// __tests__/add-tool/add-tool.test.ts +import { handler } from '../../src/index'; +import { describe, it, expect } from '@jest/globals'; +import { createMockEvent, createMockContext } from '../test-utils'; + describe('MCP Server - tools/call "add" method', () => { it('Should return the sum of a and b', async () => { - const event = { - httpMethod: 'POST', - headers: { - 'content-type': 'application/json', - accept: 'application/json', - jsonrpc: '2.0', - }, + + const event = createMockEvent({ + rawPath: '/add-tool', body: JSON.stringify({ jsonrpc: '2.0', id: 2, @@ -22,12 +21,9 @@ describe('MCP Server - tools/call "add" method', () => { }, }, }), - }; - - const context = {}; - + }); + const context = createMockContext(); const response = await handler(event, context); - expect(response.statusCode).toBe(200); const body = JSON.parse(response.body); @@ -43,4 +39,4 @@ describe('MCP Server - tools/call "add" method', () => { text: '8', }); }); -}); +}); \ No newline at end of file diff --git a/__tests__/test-utils.ts b/__tests__/test-utils.ts new file mode 100644 index 0000000..b8ae801 --- /dev/null +++ b/__tests__/test-utils.ts @@ -0,0 +1,70 @@ +import { APIGatewayProxyEventV2, Context } from 'aws-lambda'; +import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; + +type HandlerContext = Context & { jsonRPCMessages: JSONRPCMessage[] }; + +/** + * Creates a mock APIGatewayProxyEventV2 object for testing. + * Allows overriding specific properties. + */ +export function createMockEvent(overrides: Partial = {}): APIGatewayProxyEventV2 { + const defaultEvent: APIGatewayProxyEventV2 = { + version: '2.0', + routeKey: '$default', + rawPath: '/test', + rawQueryString: '', + headers: { + 'content-type': 'application/json', + accept: 'application/json', + jsonrpc: '2.0', + ...overrides.headers, + }, + requestContext: { + accountId: '123456789012', + apiId: 'api-id', + domainName: 'id.execute-api.us-east-1.amazonaws.com', + domainPrefix: 'id', + http: { + method: 'POST', + path: '/test', + protocol: 'HTTP/1.1', + sourceIp: '127.0.0.1', + userAgent: 'Test Agent', + }, + requestId: 'request-id', + routeKey: '$default', + stage: '$default', + time: '01/Mar/2020:00:00:00 +0000', + timeEpoch: 1583011200000, + ...(overrides.requestContext as any), + }, + body: '{}', + isBase64Encoded: false, + ...overrides, + }; + return defaultEvent; +} + +/** + * Creates a mock Lambda Context object for testing. + * Includes the jsonRPCMessages property expected by the middleware. + */ +export function createMockContext(overrides: Partial = {}): HandlerContext { + const defaultContext: HandlerContext = { + callbackWaitsForEmptyEventLoop: true, + functionName: 'test-function', + functionVersion: '$LATEST', + invokedFunctionArn: 'arn:aws:lambda:us-east-1:123456789012:function:test-function', + memoryLimitInMB: '128', + awsRequestId: 'test-request-id', + logGroupName: '/aws/lambda/test-function', + logStreamName: '2023/01/01/[$LATEST]abcdef1234567890', + getRemainingTimeInMillis: () => 5 * 60 * 1000, + done: () => {}, + fail: () => {}, + succeed: () => {}, + jsonRPCMessages: [], + ...overrides, + }; + return defaultContext; +} \ No newline at end of file diff --git a/__tests__/tool-list/tool-list.test.mjs b/__tests__/tool-list/tool-list.test.ts similarity index 66% rename from __tests__/tool-list/tool-list.test.mjs rename to __tests__/tool-list/tool-list.test.ts index 264bb24..60e699e 100644 --- a/__tests__/tool-list/tool-list.test.mjs +++ b/__tests__/tool-list/tool-list.test.ts @@ -1,22 +1,20 @@ -import { handler } from '../../src/index.mjs'; +// __tests__/tool-list/tool-list.test.ts +import { handler } from '../../src/index'; +import { describe, it, expect } from '@jest/globals'; +import { createMockEvent, createMockContext } from '../test-utils'; describe('MCP Server - tools/list method', () => { it('Should return a list of tools', async () => { - const event = { - httpMethod: 'POST', - headers: { - 'content-type': 'application/json', - accept: 'application/json', - jsonrpc: '2.0', - }, + const event = createMockEvent({ + rawPath: '/list-tools', body: JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', id: 1, }), - }; + }); - const context = {}; // Lambda context (empty for unit tests) + const context = createMockContext(); const response = await handler(event, context); diff --git a/jest.config.mjs b/jest.config.mjs deleted file mode 100644 index 1d3a94a..0000000 --- a/jest.config.mjs +++ /dev/null @@ -1,8 +0,0 @@ -export default { - moduleFileExtensions: [ - "mjs", - // must include "js" to pass validation https://github.com/facebook/jest/issues/12116 - "js", - ], - testRegex: `test\.mjs$`, -}; \ No newline at end of file diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 0000000..e373be8 --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,26 @@ +import type { JestConfigWithTsJest } from 'ts-jest'; + +const config: JestConfigWithTsJest = { + preset: 'ts-jest/presets/default-esm', + testEnvironment: 'node', + extensionsToTreatAsEsm: ['.ts'], + moduleNameMapper: { + '^(\.{1,2}/.*)\.js$': '$1', + }, + transform: { + '^.+\.tsx?$': [ + 'ts-jest', + { + useESM: true, + }, + ], + }, + transformIgnorePatterns: [ + '/node_modules/(?!(@middy)/)', + ], + testPathIgnorePatterns: [ + '/__tests__/test-utils.ts', + ], +}; + +export default config; \ No newline at end of file diff --git a/package.json b/package.json index d719e89..a5c9644 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,11 @@ { "name": "serverless-mcp-server", - "version": "1.0.0", + "version": "1.0.1", "type": "module", "scripts": { - "test": "node --experimental-vm-modules ./node_modules/.bin/jest" + "start": "tsc && serverless offline start", + "deploy": "tsc && serverless deploy --force", + "test": "NODE_OPTIONS=--experimental-vm-modules npx jest" }, "author": "", "license": "ISC", @@ -11,9 +13,14 @@ "devDependencies": { "@types/aws-lambda": "^8.10.148", "@types/http-errors": "^2.0.4", + "@types/jest": "^29.5.14", "@types/node": "^22.14.0", "jest": "^29.7.0", - "serverless-offline": "^14.4.0" + "serverless-offline": "^13.3.4", + "serverless-plugin-typescript": "^2.1.5", + "ts-jest": "^29.3.2", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" }, "dependencies": { "@middy/core": "^6.1.6", diff --git a/serverless.yml b/serverless.yml index 471b1c6..ede1ad6 100644 --- a/serverless.yml +++ b/serverless.yml @@ -8,6 +8,7 @@ provider: plugins: - serverless-offline + - serverless-plugin-typescript functions: mcpServer: diff --git a/src/index.mjs b/src/index.ts similarity index 100% rename from src/index.mjs rename to src/index.ts index 67c9dfd..21222e0 100644 --- a/src/index.mjs +++ b/src/index.ts @@ -2,9 +2,9 @@ import middy from "@middy/core"; import httpErrorHandler from "@middy/http-error-handler"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; - import mcpMiddleware from "middy-mcp"; + // Create an MCP server const server = new McpServer({ name: "Lambda hosted MCP Server", diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..44d580f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "outDir": "./dist", + "strict": true, + "noImplicitAny": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "moduleResolution": "node" + }, + "include": ["./src/**/*"], + "exclude": ["node_modules"] +}