diff --git a/README.md b/README.md index cf7e75c..521e7e1 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,67 @@ # 📝 NestJS Todo Playground -A simple and extensible Todo backend playground built with [NestJS](https://nestjs.com/), MongoDB (Mongoose), and TypeScript. This project demonstrates modular architecture, DTO validation, custom error handling, and includes ready-to-use Docker and test setups. +A comprehensive Todo backend playground built with [NestJS](https://nestjs.com/), showcasing modern backend architecture with microservices patterns, event-driven design, and AI integration. This project demonstrates advanced NestJS features, distributed systems concepts, and production-ready patterns. --- ## 🚀 Features -- Modular structure with NestJS -- Authentication (JWT tokens) -- `MongoDB` integration using Mongoose -- User registration with unique nickname validation -- DTO validation with class-validator -- Custom error and exception handling -- Ready-to-use `integration tests` (Jest & Supertest) -- `Docker support` for easy development and deployment +### Core Backend Features + +- **Modular NestJS Architecture** with clean separation of concerns +- **JWT Authentication** with access & refresh tokens +- **MongoDB Integration** using Mongoose ODM +- **Redis Caching & Session Management** +- **DTO Validation** with class-validator +- **Custom Exception Handling** with structured error responses +- **Integration Testing Suite** (Jest & Supertest) + +### Advanced Features + +- **🔄 BullMQ Job Queue** - Delayed todo deletion with rollback support +- **🔍 Elasticsearch Integration** - Full-text search capabilities +- **🐰 RabbitMQ Event System** - Event-driven architecture for data synchronization +- **🔒 RedLock Distributed Locking** - Race condition protection +- **⚡ Real-time Search** - Instant todo search with Elasticsearch +- **🐳 Docker-Compose Setup** - Multi-container development environment + +### AI & Integration + +- **🤖 MCP (Model Context Protocol)** - Claude Desktop integration +- **🔧 Natural Language Interface** - Control your app via AI commands +- **📡 JSON-RPC Protocol** - Standardized AI-to-backend communication + +--- + +## 🛠️ Tech Stack + +### Backend Framework + +- **[NestJS](https://nestjs.com/)** - Progressive Node.js framework +- **TypeScript** - Type-safe JavaScript +- **Express.js** - Web framework (underlying NestJS) + +### Databases & Storage + +- **[MongoDB](https://www.mongodb.com/)** - Document database with Mongoose ODM +- **[Redis](https://redis.io/)** - In-memory caching & session storage +- **[Elasticsearch](https://www.elastic.co/)** - Full-text search engine + +### Message Queues & Events + +- **[BullMQ](https://bullmq.io/)** - Redis-based job queue for background tasks +- **[RabbitMQ](https://www.rabbitmq.com/)** - Message broker for event-driven architecture + +### DevOps & Infrastructure + +- **[Docker](https://www.docker.com/)** & **Docker Compose** - Containerization +- **[Jest](https://jestjs.io/)** - Testing framework with Supertest +- **[Swagger](https://swagger.io/)** - API documentation + +### AI Integration + +- **[Model Context Protocol (MCP)](https://spec.modelcontextprotocol.io/)** - AI integration standard +- **JSON-RPC** - Remote procedure call protocol --- @@ -29,7 +77,7 @@ npm install ```bash $ cd docker -$ docker compose up +$ docker compose up ``` --- @@ -38,7 +86,103 @@ $ docker compose up ```bash $ cd docker -$ docker compose down +$ docker compose down +``` + +--- + +## 🤖 MCP (Model Context Protocol) Integration + +This project includes MCP server integration, allowing **Claude Desktop** to interact with your Todo application using natural language commands. + +### 🏃‍♂️ Running MCP Server + +The MCP server runs automatically when you start the Docker containers: + +```bash +$ cd docker +$ docker compose up +``` + +This starts both: + +- **HTTP API Server** (port 3000) - Regular REST API +- **MCP Server** (stdio) - Claude Desktop integration + +### 🔧 Claude Desktop Setup + +1. **Install Claude Desktop** from Anthropic + +2. **Create/Edit Claude Desktop config file**: + + - **macOS**: `~/.config/claude-desktop/claude_desktop_config.json` + - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` + - **Linux**: `~/.config/claude-desktop/claude_desktop_config.json` + +3. **Add MCP server configuration**: + +```json +{ + "mcpServers": { + "nestjs-todo": { + "command": "docker", + "args": [ + "exec", + "-i", + "docker-mcp-server-1", + "node", + "dist/mcp-server.js" + ] + } + } +} +``` + +4. **Restart Claude Desktop** completely + +### 🎯 Available MCP Tools + +- `auth_register` - Register new user +- `auth_login` - User authentication and JWT token retrieval + +### 💬 Example Claude Desktop Commands + +Try these natural language commands in Claude Desktop: + +``` +Can you register a new user with: +- Full name: "John Doe" +- Nickname: "johndoe" +- Password: "secure123" +``` + +``` +Please login user "johndoe" with password "secure123" +``` + +``` +What MCP tools are available in this application? +``` + +### 🔍 Troubleshooting MCP + +**Check if MCP server is running:** + +```bash +docker ps | grep mcp-server +# Should show: docker-mcp-server-1 +``` + +**View MCP server logs:** + +```bash +docker logs docker-mcp-server-1 +``` + +**Restart only MCP server:** + +```bash +docker compose restart mcp-server ``` --- @@ -79,17 +223,57 @@ npm run test ## 📬 Example API Usage -Create a user: +### Authentication ```http +# Register User POST /user Content-Type: application/json { - "nickname": "your_nickname", - "fullname": "Your Name", - "password": "your_password" + "nickname": "johndoe", + "fullname": "John Doe", + "password": "secure123" +} + +# Login User +POST /auth/sign-in +Content-Type: application/json + +{ + "nickname": "johndoe", + "password": "secure123" +} +``` + +### Todo Management + +```http +# Create Todo +POST /todo +Authorization: Bearer +Content-Type: application/json + +{ + "title": "Learn NestJS", + "description": "Build a comprehensive todo app" } + +# Get Todos +GET /todo?page=1&limit=10&completed=false +Authorization: Bearer + +# Search Todos (Elasticsearch) +GET /todo/search?q=NestJS +Authorization: Bearer + +# Delete Todo (with 4-second delay) +DELETE /todo/:id +Authorization: Bearer + +# Cancel Deletion (within 4 seconds) +POST /todo/:id/cancel-deletion +Authorization: Bearer ``` --- @@ -110,12 +294,17 @@ Pull requests and suggestions are welcome! ## 📚 Resources -- [NestJS Documentation](https://docs.nestjs.com) -- [Mongoose Docs](https://mongoosejs.com/) -- [Jest Docs](https://jestjs.io/) +- [NestJS Documentation](https://docs.nestjs.com) - Framework fundamentals +- [MongoDB & Mongoose](https://mongoosejs.com/) - Database & ODM +- [BullMQ Documentation](https://bullmq.io/) - Job queue system +- [RabbitMQ Tutorials](https://www.rabbitmq.com/tutorials.html) - Message broker +- [Elasticsearch Guide](https://www.elastic.co/guide/) - Search engine +- [Model Context Protocol](https://spec.modelcontextprotocol.io/) - AI integration standard +- [Docker Compose](https://docs.docker.com/compose/) - Multi-container applications +- [Jest Testing](https://jestjs.io/) - Testing framework --- ## 🪪 License -This project is for educational purposes and is currently **unlicensed**. +This project is for educational purposes demonstrating advanced NestJS patterns and AI integration. Currently **unlicensed**. diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 07138fc..65d06eb 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -19,6 +19,22 @@ services: networks: - nestjs-todo-network + mcp-server: + build: + context: .. + dockerfile: ./docker/Dockerfile + command: npm run mcp:dev + volumes: + - ../src:/usr/src/app/src + depends_on: + - mongo + - redis-db + - todo-playground-server + env_file: + - ../.env + networks: + - nestjs-todo-network + mongo: image: mongo container_name: todo-playground-mongo diff --git a/package-lock.json b/package-lock.json index 5fc13ad..cfe2781 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "UNLICENSED", "dependencies": { "@elastic/elasticsearch": "9.0.3", + "@modelcontextprotocol/sdk": "1.18.1", "@nestjs-modules/ioredis": "2.0.2", "@nestjs/bullmq": "11.0.3", "@nestjs/common": "11.1.0", @@ -29,7 +30,7 @@ "dotenv": "16.5.0", "ioredis": "5.7.0", "mongoose": "8.14.1", - "redlock": "^5.0.0-beta.2", + "redlock": "5.0.0-beta.2", "reflect-metadata": "0.2.2", "rxjs": "7.8.2" }, @@ -2057,6 +2058,29 @@ "integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==", "license": "MIT" }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.18.1.tgz", + "integrity": "sha512-d//GE8/Yh7aC3e7p+kZG8JqqEAwwDUmAfvH1quogtbk+ksS6E0RR6toKKESPYYZVre0meqkJb27zb+dhqE9Sgw==", + "license": "MIT", + "dependencies": { + "ajv": "^6.12.6", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@mongodb-js/saslprep": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.2.2.tgz", @@ -5151,7 +5175,6 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -6721,7 +6744,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -7382,6 +7404,27 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -7481,6 +7524,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express/node_modules/content-disposition": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", @@ -7524,7 +7582,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-diff": { @@ -7575,7 +7632,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { @@ -8737,7 +8793,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -9584,7 +9639,6 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -10830,7 +10884,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10949,6 +11002,15 @@ "@napi-rs/nice": "^1.0.1" } }, + "node_modules/pkce-challenge": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", + "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -11754,7 +11816,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -11767,7 +11828,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -13098,7 +13158,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" @@ -13446,7 +13505,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -13710,6 +13768,24 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", + "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } } } } diff --git a/package.json b/package.json index 5f1e386..815a391 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "private": true, "license": "UNLICENSED", "scripts": { + "mcp:dev": "ts-node --transpile-only -r tsconfig-paths/register src/mcp-server.ts", "build": "nest build", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "start": "nest start", @@ -21,6 +22,7 @@ }, "dependencies": { "@elastic/elasticsearch": "9.0.3", + "@modelcontextprotocol/sdk": "1.18.1", "@nestjs-modules/ioredis": "2.0.2", "@nestjs/bullmq": "11.0.3", "@nestjs/common": "11.1.0", diff --git a/src/app.module.ts b/src/app.module.ts index 0994c34..5291fc2 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -11,6 +11,7 @@ import { Environment } from './core/interface'; import { TodoModule } from './modules/todo/todo.module'; import { RabbitmqModule } from './modules/utils/rabbitmq/rabbitmq.module'; import { LockModule } from './core/cache/lock/lock.module'; +import { McpModule } from './modules/mcp/mcp.module'; @Module({ imports: [ @@ -37,6 +38,7 @@ import { LockModule } from './core/cache/lock/lock.module'; TodoModule, RabbitmqModule, LockModule, + McpModule, ], controllers: [], diff --git a/src/core/error/error-code.ts b/src/core/error/error-code.ts index 2d5ab11..2d8d71d 100644 --- a/src/core/error/error-code.ts +++ b/src/core/error/error-code.ts @@ -6,6 +6,9 @@ export enum ErrorCode { BAD_INPUT = 400100, NICKNAME_ALREADY_TAKEN = 400101, INVALID_CREDENTIALS = 400103, + //MCP + MCP_TOOL_NOT_FOUND = 400200, + MCP_CATEGORY_NOT_SUPPORTED = 400201, // 401 UNAUTHORIZED = 40100, diff --git a/src/core/error/exception/index.ts b/src/core/error/exception/index.ts index 86f480f..afe4a9d 100644 --- a/src/core/error/exception/index.ts +++ b/src/core/error/exception/index.ts @@ -10,3 +10,5 @@ export * from './unauthorized.exception'; export * from './invalid-refresh-token.exception'; export * from './todo-not-found.exception'; export * from './todo-deletion-pending.exception'; +export * from './mcp-tool-not-found.exception'; +export * from './mcp-category-not-supported.exception'; diff --git a/src/core/error/exception/mcp-category-not-supported.exception.ts b/src/core/error/exception/mcp-category-not-supported.exception.ts new file mode 100644 index 0000000..1406b65 --- /dev/null +++ b/src/core/error/exception/mcp-category-not-supported.exception.ts @@ -0,0 +1,12 @@ +import { ErrorCode } from '../error-code'; +import { BadInputException } from './bad-input.exception'; + +export class McpCategoryNotSupportedException extends BadInputException { + constructor(category: string) { + super( + `MCP tool category '${category}' is not supported`, + ErrorCode.MCP_CATEGORY_NOT_SUPPORTED, + `Category '${category}' is not available`, + ); + } +} diff --git a/src/core/error/exception/mcp-tool-not-found.exception.ts b/src/core/error/exception/mcp-tool-not-found.exception.ts new file mode 100644 index 0000000..5ec5378 --- /dev/null +++ b/src/core/error/exception/mcp-tool-not-found.exception.ts @@ -0,0 +1,12 @@ +import { ErrorCode } from '../error-code'; +import { NotFoundException } from './not-found.exception'; + +export class McpToolNotFoundException extends NotFoundException { + constructor(toolName: string) { + super( + `MCP tool '${toolName}' not found`, + ErrorCode.MCP_TOOL_NOT_FOUND, + `Tool '${toolName}' is not available`, + ); + } +} diff --git a/src/core/helper/index.ts b/src/core/helper/index.ts index 5c57b21..f0e30ae 100644 --- a/src/core/helper/index.ts +++ b/src/core/helper/index.ts @@ -1,2 +1,3 @@ export * from './mongo.helper'; export * from './mask.helper'; +export * from './mcp.helper'; diff --git a/src/core/helper/mcp.helper.ts b/src/core/helper/mcp.helper.ts new file mode 100644 index 0000000..60cf706 --- /dev/null +++ b/src/core/helper/mcp.helper.ts @@ -0,0 +1,17 @@ +export const formatMcpToolResponse = ( + message: string, + data: Record, +) => { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message, + data, + }), + }, + ], + }; +}; diff --git a/src/core/interface/index.ts b/src/core/interface/index.ts index 8ac571c..6de9136 100644 --- a/src/core/interface/index.ts +++ b/src/core/interface/index.ts @@ -1,3 +1,4 @@ export * from './environment.interface'; export * from './mongo-model'; export * from './bullmq'; +export * from './mcp'; diff --git a/src/core/interface/mcp/index.ts b/src/core/interface/mcp/index.ts new file mode 100644 index 0000000..2318c5b --- /dev/null +++ b/src/core/interface/mcp/index.ts @@ -0,0 +1 @@ +export * from './mcp-tool.const'; diff --git a/src/core/interface/mcp/mcp-tool.const.ts b/src/core/interface/mcp/mcp-tool.const.ts new file mode 100644 index 0000000..d1e1578 --- /dev/null +++ b/src/core/interface/mcp/mcp-tool.const.ts @@ -0,0 +1,43 @@ +export const McpTool = { + AUTH_REGISTER: { + name: 'auth_register', + description: 'Register a new user. Start here to create your account.', + category: 'auth' as const, + }, + AUTH_LOGIN: { + name: 'auth_login', + description: + 'Login user and get access token. Use this after registration to get your userId and tokens.', + category: 'auth' as const, + }, + TODO_CREATE: { + name: 'todo_create', + description: + 'Create a new todo item. IMPORTANT: You must first login with auth_login to get your userId.', + category: 'todo' as const, + }, + TODO_GET: { + name: 'todo_get', + description: + 'Get user todos with pagination. IMPORTANT: You must provide the userId from auth_login response.', + category: 'todo' as const, + }, + TODO_UPDATE: { + name: 'todo_update', + description: + 'Update an existing todo. IMPORTANT: You need both todoId (from todo_create/todo_get) and userId (from auth_login).', + category: 'todo' as const, + }, + TODO_DELETE: { + name: 'todo_delete', + description: + 'Schedule todo for deletion (delayed 4 seconds). IMPORTANT: You need todoId and userId. Returns jobId for potential cancellation.', + category: 'todo' as const, + }, + TODO_CANCEL_DELETION: { + name: 'todo_cancel_deletion', + description: + 'Cancel pending todo deletion (only works within 4 seconds). IMPORTANT: Use the todoId and userId to identify which deletion to cancel.', + category: 'todo' as const, + }, +} as const; diff --git a/src/mcp-server.ts b/src/mcp-server.ts new file mode 100644 index 0000000..1e685f3 --- /dev/null +++ b/src/mcp-server.ts @@ -0,0 +1,13 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { McpServer } from './modules/mcp/server/mcp.server'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule, { logger: false }); + + const mcpServer = app.get(McpServer); + + await mcpServer.start(); +} + +bootstrap(); diff --git a/src/modules/mcp/mcp.module.ts b/src/modules/mcp/mcp.module.ts new file mode 100644 index 0000000..14b15e6 --- /dev/null +++ b/src/modules/mcp/mcp.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { McpServer } from './server/mcp.server'; +import { AuthModule } from '../auth/auth.module'; +import { UserModule } from '../user/user.module'; +import { TodoModule } from '../todo/todo.module'; + +@Module({ + imports: [AuthModule, UserModule, TodoModule], + providers: [McpServer], + exports: [McpServer], +}) +export class McpModule {} diff --git a/src/modules/mcp/schemas/auth.schemas.ts b/src/modules/mcp/schemas/auth.schemas.ts new file mode 100644 index 0000000..8df5113 --- /dev/null +++ b/src/modules/mcp/schemas/auth.schemas.ts @@ -0,0 +1,34 @@ +export const AuthSchemas = { + register: { + type: 'object', + properties: { + fullname: { + type: 'string', + description: "User's full name", + }, + nickname: { + type: 'string', + description: 'Unique username', + }, + password: { + type: 'string', + description: 'User password', + }, + }, + required: ['fullname', 'nickname', 'password'], + }, + login: { + type: 'object', + properties: { + nickname: { + type: 'string', + description: 'Username', + }, + password: { + type: 'string', + description: 'User password', + }, + }, + required: ['nickname', 'password'], + }, +}; diff --git a/src/modules/mcp/schemas/index.ts b/src/modules/mcp/schemas/index.ts new file mode 100644 index 0000000..42c68ea --- /dev/null +++ b/src/modules/mcp/schemas/index.ts @@ -0,0 +1,2 @@ +export * from './auth.schemas'; +export * from './todo.schemas'; diff --git a/src/modules/mcp/schemas/todo.schemas.ts b/src/modules/mcp/schemas/todo.schemas.ts new file mode 100644 index 0000000..162d6b8 --- /dev/null +++ b/src/modules/mcp/schemas/todo.schemas.ts @@ -0,0 +1,47 @@ +export const TodoSchemas = { + create: { + type: 'object', + properties: { + userId: { type: 'string', description: 'User ID from login' }, + title: { type: 'string', description: 'Todo title' }, + description: { type: 'string', description: 'Todo description' }, + }, + required: ['userId', 'title'], + }, + get: { + type: 'object', + properties: { + userId: { type: 'string', description: 'User ID' }, + page: { type: 'number', description: 'Page number' }, + limit: { type: 'number', description: 'Items per page' }, + }, + required: ['userId'], + }, + update: { + type: 'object', + properties: { + userId: { type: 'string' }, + todoId: { type: 'string' }, + title: { type: 'string' }, + description: { type: 'string' }, + completed: { type: 'boolean' }, + }, + required: ['userId', 'todoId'], + }, + delete: { + type: 'object', + properties: { + userId: { type: 'string' }, + todoId: { type: 'string' }, + }, + required: ['userId', 'todoId'], + }, + cancelDeletion: { + type: 'object', + properties: { + userId: { type: 'string' }, + todoId: { type: 'string' }, + }, + required: ['userId', 'todoId'], + }, +}; diff --git a/src/modules/mcp/server/mcp.server.ts b/src/modules/mcp/server/mcp.server.ts new file mode 100644 index 0000000..c3f2685 --- /dev/null +++ b/src/modules/mcp/server/mcp.server.ts @@ -0,0 +1,101 @@ +import { Injectable } from '@nestjs/common'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import { AuthService } from '../../auth/service/auth.service'; +import { AuthTools } from '../tools/auth.tools'; +import { UserService } from 'src/modules/user/service/user.service'; +import { McpTool } from 'src/core/interface/mcp/mcp-tool.const'; +import { McpToolNotFoundException } from 'src/core/error/exception/mcp-tool-not-found.exception'; +import { McpCategoryNotSupportedException } from 'src/core/error/exception/mcp-category-not-supported.exception'; +import { TodoTools } from '../tools/todo.tools'; +import { TodoService } from 'src/modules/todo/service/todo.service'; + +@Injectable() +export class McpServer { + private server: Server; + private authTools: AuthTools; + private todoTools: TodoTools; + + constructor( + private readonly authService: AuthService, + private readonly userService: UserService, + private readonly todoService: TodoService, + ) { + this.authTools = new AuthTools(this.authService, this.userService); + this.todoTools = new TodoTools(this.todoService); + this.initializeServer(); + } + + private initializeServer() { + this.server = new Server( + { + name: 'nestjs-todo-mcp', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + }, + }, + ); + + this.setupToolHandlers(); + } + + private setupToolHandlers() { + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: [ + ...this.authTools.getToolDefinitions(), + ...this.todoTools.getToolDefinitions(), + ], + }; + }); + + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + + try { + // Category-based routing + const allTools: Array<{ + name: string; + description: string; + category: string; + }> = Object.values(McpTool); + const tool = allTools.find((t) => t.name === name); + + if (!tool) { + throw new McpToolNotFoundException(name); + } + + switch (tool.category) { + case 'auth': + return await this.authTools.handleToolCall(name, args); + case 'todo': + return await this.todoTools.handleToolCall(name, args); + default: + throw new McpCategoryNotSupportedException(tool.category); + } + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Error: ${error.message}`, + }, + ], + isError: true, + }; + } + }); + } + + async start() { + const transport = new StdioServerTransport(); + await this.server.connect(transport); + } +} diff --git a/src/modules/mcp/tools/auth.tools.ts b/src/modules/mcp/tools/auth.tools.ts new file mode 100644 index 0000000..abb75f0 --- /dev/null +++ b/src/modules/mcp/tools/auth.tools.ts @@ -0,0 +1,63 @@ +import { AuthService } from '../../auth/service/auth.service'; +import { UserService } from '../../user/service/user.service'; +import { McpTool } from 'src/core/interface'; +import { McpToolNotFoundException } from 'src/core/error/exception/mcp-tool-not-found.exception'; +import { formatMcpToolResponse } from 'src/core/helper'; +import { AuthSchemas } from '../schemas'; + +export class AuthTools { + constructor( + private readonly authService: AuthService, + private readonly userService: UserService, + ) {} + + getToolDefinitions() { + return [ + { + name: McpTool.AUTH_REGISTER.name, + description: McpTool.AUTH_REGISTER.description, + inputSchema: AuthSchemas.register, + }, + { + name: McpTool.AUTH_LOGIN.name, + description: McpTool.AUTH_LOGIN.description, + inputSchema: AuthSchemas.login, + }, + ]; + } + + async handleToolCall(toolName: string, args: any) { + switch (toolName) { + case McpTool.AUTH_REGISTER.name: + return await this.register(args); + case McpTool.AUTH_LOGIN.name: + return await this.login(args); + default: + throw new McpToolNotFoundException(toolName); + } + } + + private async register(args: any) { + const result = await this.userService.create({ + fullname: args.fullname, + nickname: args.nickname, + password: args.password, + }); + + return formatMcpToolResponse('User registered successfully', { + userId: result.id, + }); + } + + private async login(args: any) { + const result = await this.authService.signIn({ + nickname: args.nickname, + password: args.password, + }); + + return formatMcpToolResponse('User logged in successfully', { + accessToken: result.accessToken, + refreshToken: result.refreshToken, + }); + } +} diff --git a/src/modules/mcp/tools/todo.tools.ts b/src/modules/mcp/tools/todo.tools.ts new file mode 100644 index 0000000..84bbaf0 --- /dev/null +++ b/src/modules/mcp/tools/todo.tools.ts @@ -0,0 +1,105 @@ +import { McpTool } from 'src/core/interface'; +import { McpToolNotFoundException } from 'src/core/error/exception/mcp-tool-not-found.exception'; +import { formatMcpToolResponse } from 'src/core/helper'; +import { TodoService } from 'src/modules/todo/service'; +import { TodoSchemas } from '../schemas'; + +export class TodoTools { + constructor(private readonly todoService: TodoService) {} + + getToolDefinitions() { + return [ + { + name: McpTool.TODO_CREATE.name, + description: McpTool.TODO_CREATE.description, + inputSchema: TodoSchemas.create, + }, + { + name: McpTool.TODO_GET.name, + description: McpTool.TODO_GET.description, + inputSchema: TodoSchemas.get, + }, + { + name: McpTool.TODO_UPDATE.name, + description: McpTool.TODO_UPDATE.description, + inputSchema: TodoSchemas.update, + }, + { + name: McpTool.TODO_DELETE.name, + description: McpTool.TODO_DELETE.description, + inputSchema: TodoSchemas.delete, + }, + { + name: McpTool.TODO_CANCEL_DELETION.name, + description: McpTool.TODO_CANCEL_DELETION.description, + inputSchema: TodoSchemas.cancelDeletion, + }, + ] as const; + } + + async handleToolCall(toolName: string, args: any) { + switch (toolName) { + case McpTool.TODO_CREATE.name: + return await this.create(args); + case McpTool.TODO_GET.name: + return await this.get(args); + case McpTool.TODO_UPDATE.name: + return await this.update(args); + case McpTool.TODO_DELETE.name: + return await this.delete(args); + case McpTool.TODO_CANCEL_DELETION.name: + return await this.cancelDeletion(args); + default: + throw new McpToolNotFoundException(toolName); + } + } + + private async create(args: any) { + const createTodoDto = { + title: args.title, + description: args.description, + }; + + const result = await this.todoService.create(createTodoDto, args.userId); + return formatMcpToolResponse('Todo created successfully', result); + } + + private async get(args: any) { + const getTodoDto = { + page: args.page, + limit: args.limit, + completed: args.completed, + }; + + const result = await this.todoService.findByUserId(args.userId, getTodoDto); + return formatMcpToolResponse('Todos retrieved successfully', result); + } + + private async update(args: any) { + const updateTodoDto = { + title: args.title, + description: args.description, + completed: args.completed, + }; + + const result = await this.todoService.update( + args.todoId, + args.userId, + updateTodoDto, + ); + return formatMcpToolResponse('Todo updated successfully', result); + } + + private async delete(args: any) { + const result = await this.todoService.delete(args.todoId, args.userId); + return formatMcpToolResponse('Todo deletion scheduled', result); + } + + private async cancelDeletion(args: any) { + const result = await this.todoService.cancelDeletion( + args.todoId, + args.userId, + ); + return formatMcpToolResponse('Todo deletion cancelled', result); + } +} diff --git a/src/modules/todo/dto/delete-todo.dto.ts b/src/modules/todo/dto/delete-todo.dto.ts index ce905d5..d88836b 100644 --- a/src/modules/todo/dto/delete-todo.dto.ts +++ b/src/modules/todo/dto/delete-todo.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; export class DeleteTodoAck { @ApiProperty({