🐺 Initial commit - Lupul Augmentat MCP Server
- MCP server cu stdio transport pentru performanță maximă
- Tool-uri pentru file operations, HTTP requests, system commands
- Suport NATS pentru comunicare inter-module
- Configurare nginx cu API key auth și SSL
- Arhitectură modulară și extensibilă
🤖 Generated with Claude Code
This commit is contained in:
18
.env.example
Normal file
18
.env.example
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# MCP Server Configuration
|
||||||
|
MCP_HOST=127.0.0.1
|
||||||
|
MCP_PORT=19017
|
||||||
|
MCP_LOG_LEVEL=info
|
||||||
|
|
||||||
|
# NATS Configuration
|
||||||
|
NATS_URL=nats://localhost:4222
|
||||||
|
NATS_RECONNECT_TIME_WAIT=2000
|
||||||
|
NATS_MAX_RECONNECT_ATTEMPTS=10
|
||||||
|
|
||||||
|
# Security
|
||||||
|
# Generate a secure JWT secret with: openssl rand -base64 64 | tr -d '\n'
|
||||||
|
JWT_SECRET=your-secure-jwt-secret-here-minimum-32-characters-CHANGE-THIS
|
||||||
|
AUTH_ENABLED=true
|
||||||
|
|
||||||
|
# Module Configuration
|
||||||
|
MODULE_STARTUP_TIMEOUT=5000
|
||||||
|
MODULE_HEALTH_CHECK_INTERVAL=30000
|
||||||
26
.eslintrc.json
Normal file
26
.eslintrc.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended-requiring-type-checking",
|
||||||
|
"plugin:prettier/recommended"
|
||||||
|
],
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 2022,
|
||||||
|
"sourceType": "module",
|
||||||
|
"project": "./tsconfig.json"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/explicit-function-return-type": "warn",
|
||||||
|
"@typescript-eslint/no-explicit-any": "error",
|
||||||
|
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
|
||||||
|
"@typescript-eslint/no-floating-promises": "error",
|
||||||
|
"@typescript-eslint/no-misused-promises": "error",
|
||||||
|
"@typescript-eslint/await-thenable": "error",
|
||||||
|
"@typescript-eslint/no-unnecessary-type-assertion": "error",
|
||||||
|
"@typescript-eslint/prefer-as-const": "error",
|
||||||
|
"@typescript-eslint/no-non-null-assertion": "error"
|
||||||
|
},
|
||||||
|
"ignorePatterns": ["dist/", "node_modules/", "*.js"]
|
||||||
|
}
|
||||||
48
.gitignore
vendored
Normal file
48
.gitignore
vendored
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.js.map
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development
|
||||||
|
.env.test
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
mcp-server.log
|
||||||
|
mcp-http-server.log
|
||||||
|
http-server.log
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# Archives
|
||||||
|
*.tar.gz
|
||||||
|
*.zip
|
||||||
10
.prettierrc.json
Normal file
10
.prettierrc.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
94
CLAUDE.md
Normal file
94
CLAUDE.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# 🧠 MCP SERVER - MEMORIE ȘI CONTEXT PROIECT
|
||||||
|
|
||||||
|
## ⚡ REGULI DE AUR (NICIODATA NU ȘTERG!)
|
||||||
|
|
||||||
|
1. **NICI UN TASK NU SE CONSIDERĂ ÎNDEPLINIT** până nu se îndeplinesc criteriile de acceptanță definite
|
||||||
|
2. **NU ÎNCEPEM UN TASK** până nu definim criteriile de acceptanță
|
||||||
|
3. **UN SINGUR TASK ÎN LUCRU** - restul în standby
|
||||||
|
4. **DOCUMENTAȚIA RĂMÂNE ÎN ARBORE** - toate fișierele conectate
|
||||||
|
5. **NU LUCREZI NICIODATĂ LA ALTCEVA** decât ți s-a spus explicit
|
||||||
|
6. **NICIODATA NU HARDCODEZ VARIABILE!**
|
||||||
|
7. **NICIODATA NU ADAUGAM SETARI FAILOVERS** - Dacă ceva nu e bine, vrem să știm imediat
|
||||||
|
8. **Salvez date relevante taskului curent** în DEBUG_CURRENT_TASK.md
|
||||||
|
9. **Salvez întotdeauna ce am modificat** pentru rollback dacă e nevoie
|
||||||
|
10. **Creez criterii de acceptanță** înainte de a testa/finaliza
|
||||||
|
11. **Când task-uri depind de API changes** → salvez în TASK_IN_STANDBY.md
|
||||||
|
|
||||||
|
## 🎯 SCOPUL PROIECTULUI
|
||||||
|
|
||||||
|
**Obiectiv Principal:** Construirea unui server MCP (Model Context Protocol) care să augmenteze capabilitățile Claude pentru automatizări și integrări custom.
|
||||||
|
|
||||||
|
**Configurație Server:**
|
||||||
|
- **Port:** 19017 (atipic, pentru securitate)
|
||||||
|
- **Bind:** 127.0.0.1:19017 (doar local)
|
||||||
|
- **Access extern:** via nginx proxy la mcp.runningwolf.com
|
||||||
|
|
||||||
|
**Conceptul Core:** Un server extensibil care oferă tool-uri specializate pentru:
|
||||||
|
- Acces la sisteme locale și remote
|
||||||
|
- Procesare date complexe
|
||||||
|
- Integrări cu servicii externe
|
||||||
|
- Workflow-uri automatizate
|
||||||
|
- Persistență și context management
|
||||||
|
|
||||||
|
## 📁 STRUCTURA DOCUMENTAȚIEI
|
||||||
|
|
||||||
|
```
|
||||||
|
/Projects/mcp/
|
||||||
|
├── CLAUDE.md (acest fișier - entry point)
|
||||||
|
├── DEBUG_CURRENT_TASK.md → task în lucru
|
||||||
|
├── TASK_IN_STANDBY.md → task-uri în așteptare
|
||||||
|
├── docs/
|
||||||
|
│ ├── ARHITECTURA.md → design și componente
|
||||||
|
│ ├── SETUP.md → configurare și instalare
|
||||||
|
│ ├── TOOLS.md → tool-uri disponibile
|
||||||
|
│ └── TASKS.md → task-uri active și istorie
|
||||||
|
└── [alte fișiere proiect]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔗 LEGĂTURI RAPIDE
|
||||||
|
|
||||||
|
- [Arhitectură](./docs/ARHITECTURA.md) - Structura tehnică
|
||||||
|
- [Setup](./docs/SETUP.md) - Ghid instalare
|
||||||
|
- [Tools](./docs/TOOLS.md) - Tool-uri implementate
|
||||||
|
- [Tasks](./docs/TASKS.md) - Task management
|
||||||
|
- [Debug Current](./DEBUG_CURRENT_TASK.md) - Task în lucru
|
||||||
|
- [Standby Tasks](./TASK_IN_STANDBY.md) - Task-uri în așteptare
|
||||||
|
|
||||||
|
## 📌 CONTEXT CURENT
|
||||||
|
|
||||||
|
**Data începerii:** 25 Iulie 2025
|
||||||
|
**Status:** În dezvoltare - Task 5 (Documentație)
|
||||||
|
|
||||||
|
**Conversație inițială:**
|
||||||
|
- User vrea server MCP pentru augmentarea muncii
|
||||||
|
- Discuție despre scalabilitate și flexibilitate
|
||||||
|
- Cerință: sistem documentație cu arbore conectat
|
||||||
|
- Server ascultă la 127.0.0.1:19017
|
||||||
|
- Access extern via nginx proxy: mcp.runningwolf.com
|
||||||
|
|
||||||
|
## ⚡ COMENZI RAPIDE
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
npm run dev # Pornește în mod development
|
||||||
|
npm run build # Build pentru producție
|
||||||
|
npm start # Pornește serverul
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
npm test # Rulează testele
|
||||||
|
npm run lint # Verifică codul
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 CRITERII ACCEPTANȚĂ TASK CURENT
|
||||||
|
|
||||||
|
**Task:** Creează sistem de documentație cu CLAUDE.md și structură arborescentă
|
||||||
|
|
||||||
|
**Criterii:**
|
||||||
|
1. ✅ Fișier CLAUDE.md creat cu structură clară și reguli de aur
|
||||||
|
2. ⬜ Fișiere documentație în /docs create
|
||||||
|
3. ⬜ Toate fișierele conectate prin legături
|
||||||
|
4. ⬜ Reguli de aur în TOATE fișierele
|
||||||
|
5. ⬜ Test: pornire fără context → resume complet posibil
|
||||||
|
|
||||||
|
---
|
||||||
|
*Ultima actualizare: 25 Iulie 2025*
|
||||||
187
DEBUG_CURRENT_TASK.md
Normal file
187
DEBUG_CURRENT_TASK.md
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
# 🔍 DEBUG CURRENT TASK
|
||||||
|
|
||||||
|
## ⚡ REGULI DE AUR (NICIODATA NU ȘTERG!)
|
||||||
|
|
||||||
|
1. **NICI UN TASK NU SE CONSIDERĂ ÎNDEPLINIT** până nu se îndeplinesc criteriile de acceptanță definite
|
||||||
|
2. **NU ÎNCEPEM UN TASK** până nu definim criteriile de acceptanță
|
||||||
|
3. **UN SINGUR TASK ÎN LUCRU** - restul în standby
|
||||||
|
4. **DOCUMENTAȚIA RĂMÂNE ÎN ARBORE** - toate fișierele conectate
|
||||||
|
5. **NU LUCREZI NICIODATĂ LA ALTCEVA** decât ți s-a spus explicit
|
||||||
|
6. **NICIODATA NU HARDCODEZ VARIABILE!**
|
||||||
|
7. **NICIODATA NU ADAUGAM SETARI FAILOVERS** - Dacă ceva nu e bine, vrem să știm imediat
|
||||||
|
8. **Salvez date relevante taskului curent** în DEBUG_CURRENT_TASK.md
|
||||||
|
9. **Salvez întotdeauna ce am modificat** pentru rollback dacă e nevoie
|
||||||
|
10. **Creez criterii de acceptanță** înainte de a testa/finaliza
|
||||||
|
11. **Când task-uri depind de API changes** → salvez în TASK_IN_STANDBY.md
|
||||||
|
|
||||||
|
## 📋 TASK CURENT
|
||||||
|
|
||||||
|
**ID:** Task #2
|
||||||
|
**Nume:** Implementează handler-ele pentru tool-uri specifice
|
||||||
|
**Status:** În lucru
|
||||||
|
**Început:** 25 Iulie 2025
|
||||||
|
|
||||||
|
## 🎯 CRITERII DE ACCEPTANȚĂ
|
||||||
|
|
||||||
|
1. ✅ Implementare tool-uri de bază
|
||||||
|
- ✅ File operations (read, write, list)
|
||||||
|
- ✅ System commands execution
|
||||||
|
- ✅ HTTP client pentru API calls
|
||||||
|
|
||||||
|
2. ✅ Handler pattern extensibil
|
||||||
|
- ✅ Base class/interface pentru tool handlers
|
||||||
|
- ✅ Lifecycle hooks (init, validate, execute, cleanup)
|
||||||
|
- ✅ Error handling standardizat
|
||||||
|
|
||||||
|
3. ✅ Schema validation
|
||||||
|
- ✅ Input validation cu Zod
|
||||||
|
- ✅ Output formatting consistent
|
||||||
|
- ✅ Error messages descriptive
|
||||||
|
|
||||||
|
4. ✅ Security layer pentru tools
|
||||||
|
- ✅ Permission checking
|
||||||
|
- ✅ Input sanitization
|
||||||
|
- ✅ Resource limits (timeout, memory)
|
||||||
|
|
||||||
|
5. ✅ Module SDK pentru dezvoltare ușoară
|
||||||
|
- ✅ TypeScript SDK pentru module
|
||||||
|
- ⬜ Python SDK pentru module
|
||||||
|
- ✅ Exemplu de modul funcțional
|
||||||
|
|
||||||
|
6. ✅ Teste pentru toate tool-urile
|
||||||
|
- ✅ Unit tests pentru fiecare tool
|
||||||
|
- ✅ Integration tests cu mock-uri
|
||||||
|
- ✅ Test coverage complet
|
||||||
|
|
||||||
|
7. ✅ Documentație tool-uri
|
||||||
|
- ✅ README pentru fiecare tool
|
||||||
|
- ✅ Exemple de utilizare
|
||||||
|
- ✅ API reference actualizat
|
||||||
|
|
||||||
|
## 🔧 MODIFICĂRI FĂCUTE
|
||||||
|
|
||||||
|
### 25 Iulie 2025 - Task #2
|
||||||
|
1. **Base Handler Pattern**
|
||||||
|
- ToolHandler abstract class cu lifecycle hooks
|
||||||
|
- Validation cu Zod schemas
|
||||||
|
- Timeout handling
|
||||||
|
- Permission checking
|
||||||
|
- Error handling standardizat
|
||||||
|
|
||||||
|
2. **File Operations Tools**
|
||||||
|
- FileReadTool - citire fișiere cu limite
|
||||||
|
- FileWriteTool - scriere cu securitate
|
||||||
|
- FileListTool - listare directoare recursiv
|
||||||
|
|
||||||
|
3. **System Command Tool**
|
||||||
|
- Execuție comenzi cu whitelist
|
||||||
|
- Protecție contra shell injection
|
||||||
|
- Output size limits
|
||||||
|
- Timeout handling
|
||||||
|
|
||||||
|
4. **HTTP Client Tool**
|
||||||
|
- Request-uri HTTP/HTTPS
|
||||||
|
- JSON și text parsing
|
||||||
|
- Protecție contra internal networks
|
||||||
|
- Headers și body handling
|
||||||
|
|
||||||
|
5. **Tool Registry Update**
|
||||||
|
- Înregistrare automată built-in tools
|
||||||
|
- Execuție locală pentru built-in
|
||||||
|
- Fallback la NATS pentru module externe
|
||||||
|
|
||||||
|
### 25 Iulie 2025 - Task #1
|
||||||
|
1. **TypeScript Project Setup**
|
||||||
|
- package.json cu toate dependencies
|
||||||
|
- tsconfig.json cu strict mode
|
||||||
|
- ESLint + Prettier configurate
|
||||||
|
- .gitignore și .env.example
|
||||||
|
|
||||||
|
2. **Structură de bază creată**
|
||||||
|
- /src/server.ts - MCPServer class principal
|
||||||
|
- /src/config.ts - Configurare cu Zod validation
|
||||||
|
- /src/types/index.ts - TypeScript interfaces
|
||||||
|
- /src/utils/logger.ts - Pino logger setup
|
||||||
|
- /src/nats/NatsClient.ts - NATS connection wrapper
|
||||||
|
- /src/registry/ToolRegistry.ts - Tool management
|
||||||
|
- /src/registry/ModuleManager.ts - Module lifecycle
|
||||||
|
|
||||||
|
3. **Funcționalități implementate**
|
||||||
|
- Server MCP cu stdio transport
|
||||||
|
- NATS client cu reconnect logic
|
||||||
|
- Tool registry și discovery
|
||||||
|
- Module manager (placeholder)
|
||||||
|
- Graceful shutdown
|
||||||
|
- Structured logging
|
||||||
|
1. **Creat CLAUDE.md**
|
||||||
|
- Adăugat reguli de aur adaptate din proiectul anterior
|
||||||
|
- Structură documentație definită
|
||||||
|
- Context proiect salvat
|
||||||
|
- Configurație server: 127.0.0.1:19017
|
||||||
|
|
||||||
|
2. **Creat DEBUG_CURRENT_TASK.md** (acest fișier)
|
||||||
|
- Pentru tracking task curent
|
||||||
|
- Salvare modificări pentru rollback
|
||||||
|
|
||||||
|
3. **Creat TASK_IN_STANDBY.md**
|
||||||
|
- Pentru tasks în așteptare
|
||||||
|
- 4 tasks definite pentru dezvoltare MCP
|
||||||
|
|
||||||
|
4. **Creat structură /docs:**
|
||||||
|
- ARHITECTURA.md - design tehnic și componente
|
||||||
|
- SETUP.md - ghid instalare și configurare
|
||||||
|
- TOOLS.md - tool-uri disponibile și planificate
|
||||||
|
- TASKS.md - management și istoric tasks
|
||||||
|
|
||||||
|
5. **Reguli de aur adăugate în TOATE fișierele**
|
||||||
|
|
||||||
|
## 📝 NOTE DE LUCRU
|
||||||
|
|
||||||
|
- User a cerut reguli de aur în TOATE fișierele
|
||||||
|
- Server MCP va asculta la 127.0.0.1:19017
|
||||||
|
- Access extern via nginx: mcp.runningwolf.com
|
||||||
|
- Inspirație structură: proiect CLOBIT
|
||||||
|
|
||||||
|
## 🔄 URMĂTORII PAȘI
|
||||||
|
|
||||||
|
1. ✅ Creare director /docs
|
||||||
|
2. ✅ Creare fișiere documentație:
|
||||||
|
- ✅ ARHITECTURA.md
|
||||||
|
- ✅ SETUP.md
|
||||||
|
- ✅ TOOLS.md
|
||||||
|
- ✅ TASKS.md
|
||||||
|
3. ✅ Adăugare reguli de aur în toate fișierele
|
||||||
|
4. ⬜ Test final: pornire fără context
|
||||||
|
|
||||||
|
## 📋 ACTION PLAN ARHITECTURĂ
|
||||||
|
|
||||||
|
### Decizii luate în discuție:
|
||||||
|
1. **Arhitectură hibrid** - module în orice limbaj
|
||||||
|
2. **NATS** pentru comunicare inter-module
|
||||||
|
3. **Security first** - JWT auth, permissions, sandboxing
|
||||||
|
4. **Core în Node.js** cu TypeScript
|
||||||
|
|
||||||
|
### Arhitectura finală:
|
||||||
|
```
|
||||||
|
Claude → MCP Core (Node.js) → NATS → Modules (any language)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Message Contract definit:
|
||||||
|
- ToolRequest/ToolResponse interfaces
|
||||||
|
- NATS topics: tools.{language}.{toolname}.{method}
|
||||||
|
- Module discovery & health checks
|
||||||
|
|
||||||
|
### Security layers:
|
||||||
|
- Module authentication cu JWT
|
||||||
|
- Request authorization
|
||||||
|
- Sandbox execution (Docker)
|
||||||
|
- Audit logging complet
|
||||||
|
|
||||||
|
### Implementation phases:
|
||||||
|
- Phase 1: Basic Core (1-2 days)
|
||||||
|
- Phase 2: Module System (2-3 days)
|
||||||
|
- Phase 3: Security (1-2 days)
|
||||||
|
- Phase 4: Production Ready (2-3 days)
|
||||||
|
|
||||||
|
---
|
||||||
|
*Actualizat: 25 Iulie 2025*
|
||||||
60
TASK_IN_STANDBY.md
Normal file
60
TASK_IN_STANDBY.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# 📋 TASKS IN STANDBY
|
||||||
|
|
||||||
|
## ⚡ REGULI DE AUR (NICIODATA NU ȘTERG!)
|
||||||
|
|
||||||
|
1. **NICI UN TASK NU SE CONSIDERĂ ÎNDEPLINIT** până nu se îndeplinesc criteriile de acceptanță definite
|
||||||
|
2. **NU ÎNCEPEM UN TASK** până nu definim criteriile de acceptanță
|
||||||
|
3. **UN SINGUR TASK ÎN LUCRU** - restul în standby
|
||||||
|
4. **DOCUMENTAȚIA RĂMÂNE ÎN ARBORE** - toate fișierele conectate
|
||||||
|
5. **NU LUCREZI NICIODATĂ LA ALTCEVA** decât ți s-a spus explicit
|
||||||
|
6. **NICIODATA NU HARDCODEZ VARIABILE!**
|
||||||
|
7. **NICIODATA NU ADAUGAM SETARI FAILOVERS** - Dacă ceva nu e bine, vrem să știm imediat
|
||||||
|
8. **Salvez date relevante taskului curent** în DEBUG_CURRENT_TASK.md
|
||||||
|
9. **Salvez întotdeauna ce am modificat** pentru rollback dacă e nevoie
|
||||||
|
10. **Creez criterii de acceptanță** înainte de a testa/finaliza
|
||||||
|
11. **Când task-uri depind de API changes** → salvez în TASK_IN_STANDBY.md
|
||||||
|
|
||||||
|
## 📌 TASKS ÎN AȘTEPTARE
|
||||||
|
|
||||||
|
### Task #1: Creează structura de bază pentru serverul MCP
|
||||||
|
**Status:** În lucru (mutat în DEBUG_CURRENT_TASK.md)
|
||||||
|
**Prioritate:** High
|
||||||
|
**Dependențe:** Task #5 (Documentație) ✅ FINALIZAT
|
||||||
|
**Detalii:**
|
||||||
|
- Setup TypeScript project
|
||||||
|
- Configurare server să asculte la 127.0.0.1:19017
|
||||||
|
- Structură de bază MCP SDK
|
||||||
|
|
||||||
|
### Task #2: Implementează handler-ele pentru tool-uri specifice
|
||||||
|
**Status:** În lucru (mutat în DEBUG_CURRENT_TASK.md)
|
||||||
|
**Prioritate:** High
|
||||||
|
**Dependențe:** Task #1 ✅ FINALIZAT
|
||||||
|
**Detalii:**
|
||||||
|
- Tool-uri custom pentru automatizări
|
||||||
|
- Handler pattern pentru extensibilitate
|
||||||
|
|
||||||
|
### Task #3: Configurează transport și protocol
|
||||||
|
**Status:** Pending
|
||||||
|
**Prioritate:** Medium
|
||||||
|
**Dependențe:** Task #1 finalizat
|
||||||
|
**Detalii:**
|
||||||
|
- stdio transport pentru development
|
||||||
|
- HTTP/WebSocket pentru producție
|
||||||
|
|
||||||
|
### Task #4: Testează conexiunea cu Claude
|
||||||
|
**Status:** Pending
|
||||||
|
**Prioritate:** Medium
|
||||||
|
**Dependențe:** Tasks #1, #2, #3 finalizate
|
||||||
|
**Detalii:**
|
||||||
|
- Test integrat cu Claude
|
||||||
|
- Verificare tool-uri funcționale
|
||||||
|
|
||||||
|
## 🔄 FLUX DE LUCRU
|
||||||
|
|
||||||
|
1. Finalizez task curent din DEBUG_CURRENT_TASK.md
|
||||||
|
2. Mut următorul task prioritar aici în DEBUG_CURRENT_TASK.md
|
||||||
|
3. Actualizez status aici ca "În lucru"
|
||||||
|
4. După finalizare, arhivez în docs/TASKS.md
|
||||||
|
|
||||||
|
---
|
||||||
|
*Actualizat: 25 Iulie 2025*
|
||||||
115
docs/ARHITECTURA.md
Normal file
115
docs/ARHITECTURA.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# 🏗️ ARHITECTURA MCP SERVER
|
||||||
|
|
||||||
|
## ⚡ REGULI DE AUR (NICIODATA NU ȘTERG!)
|
||||||
|
|
||||||
|
1. **NICI UN TASK NU SE CONSIDERĂ ÎNDEPLINIT** până nu se îndeplinesc criteriile de acceptanță definite
|
||||||
|
2. **NU ÎNCEPEM UN TASK** până nu definim criteriile de acceptanță
|
||||||
|
3. **UN SINGUR TASK ÎN LUCRU** - restul în standby
|
||||||
|
4. **DOCUMENTAȚIA RĂMÂNE ÎN ARBORE** - toate fișierele conectate
|
||||||
|
5. **NU LUCREZI NICIODATĂ LA ALTCEVA** decât ți s-a spus explicit
|
||||||
|
6. **NICIODATA NU HARDCODEZ VARIABILE!**
|
||||||
|
7. **NICIODATA NU ADAUGAM SETARI FAILOVERS** - Dacă ceva nu e bine, vrem să știm imediat
|
||||||
|
8. **Salvez date relevante taskului curent** în DEBUG_CURRENT_TASK.md
|
||||||
|
9. **Salvez întotdeauna ce am modificat** pentru rollback dacă e nevoie
|
||||||
|
10. **Creez criterii de acceptanță** înainte de a testa/finaliza
|
||||||
|
11. **Când task-uri depind de API changes** → salvez în TASK_IN_STANDBY.md
|
||||||
|
|
||||||
|
[← Înapoi la CLAUDE.md](../CLAUDE.md)
|
||||||
|
|
||||||
|
## 🎯 OVERVIEW
|
||||||
|
|
||||||
|
MCP Server pentru augmentarea capabilităților Claude cu tool-uri custom.
|
||||||
|
|
||||||
|
**Configurație:**
|
||||||
|
- **Port:** 19017
|
||||||
|
- **Bind:** 127.0.0.1 (doar local)
|
||||||
|
- **Protocol:** MCP (Model Context Protocol)
|
||||||
|
- **Access extern:** nginx proxy → mcp.runningwolf.com
|
||||||
|
|
||||||
|
## 📦 COMPONENTE PRINCIPALE
|
||||||
|
|
||||||
|
### 1. Core Server
|
||||||
|
```typescript
|
||||||
|
// src/server.ts
|
||||||
|
class MCPServer {
|
||||||
|
port: number = 19017
|
||||||
|
host: string = '127.0.0.1'
|
||||||
|
|
||||||
|
// Tool registry
|
||||||
|
// Transport layer
|
||||||
|
// Request handler
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Tool System
|
||||||
|
```typescript
|
||||||
|
// src/tools/
|
||||||
|
interface Tool {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
inputSchema: JSONSchema
|
||||||
|
handler: ToolHandler
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Transport Layer
|
||||||
|
- **stdio** - pentru development local
|
||||||
|
- **HTTP/WebSocket** - pentru producție
|
||||||
|
- **Authentication** - pentru securitate
|
||||||
|
|
||||||
|
## 🔧 TOOL-URI PLANIFICATE
|
||||||
|
|
||||||
|
1. **File Operations**
|
||||||
|
- Read/Write fișiere locale
|
||||||
|
- Watch pentru modificări
|
||||||
|
- Batch operations
|
||||||
|
|
||||||
|
2. **System Integration**
|
||||||
|
- Execute comenzi
|
||||||
|
- Monitor procese
|
||||||
|
- Environment variables
|
||||||
|
|
||||||
|
3. **Data Processing**
|
||||||
|
- JSON/CSV parsing
|
||||||
|
- Data transformări
|
||||||
|
- Aggregări
|
||||||
|
|
||||||
|
4. **External APIs**
|
||||||
|
- HTTP requests
|
||||||
|
- WebSocket connections
|
||||||
|
- API key management
|
||||||
|
|
||||||
|
## 🔒 SECURITATE
|
||||||
|
|
||||||
|
1. **Bind doar local** - 127.0.0.1:19017
|
||||||
|
2. **Auth tokens** pentru access
|
||||||
|
3. **Rate limiting** per tool
|
||||||
|
4. **Audit logs** pentru toate operațiile
|
||||||
|
|
||||||
|
## 📁 STRUCTURA PROIECT
|
||||||
|
|
||||||
|
```
|
||||||
|
/Projects/mcp/
|
||||||
|
├── src/
|
||||||
|
│ ├── server.ts # Entry point
|
||||||
|
│ ├── config.ts # Configurări (NO HARDCODE!)
|
||||||
|
│ ├── tools/ # Tool implementations
|
||||||
|
│ │ ├── index.ts
|
||||||
|
│ │ ├── file.ts
|
||||||
|
│ │ └── system.ts
|
||||||
|
│ └── transport/ # Transport layers
|
||||||
|
│ ├── stdio.ts
|
||||||
|
│ └── http.ts
|
||||||
|
├── tests/ # Unit & integration tests
|
||||||
|
├── package.json
|
||||||
|
└── tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔗 LEGĂTURI
|
||||||
|
|
||||||
|
- [Setup Instructions](./SETUP.md)
|
||||||
|
- [Available Tools](./TOOLS.md)
|
||||||
|
- [Task History](./TASKS.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
*Actualizat: 25 Iulie 2025*
|
||||||
152
docs/SETUP.md
Normal file
152
docs/SETUP.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# 🚀 SETUP MCP SERVER
|
||||||
|
|
||||||
|
## ⚡ REGULI DE AUR (NICIODATA NU ȘTERG!)
|
||||||
|
|
||||||
|
1. **NICI UN TASK NU SE CONSIDERĂ ÎNDEPLINIT** până nu se îndeplinesc criteriile de acceptanță definite
|
||||||
|
2. **NU ÎNCEPEM UN TASK** până nu definim criteriile de acceptanță
|
||||||
|
3. **UN SINGUR TASK ÎN LUCRU** - restul în standby
|
||||||
|
4. **DOCUMENTAȚIA RĂMÂNE ÎN ARBORE** - toate fișierele conectate
|
||||||
|
5. **NU LUCREZI NICIODATĂ LA ALTCEVA** decât ți s-a spus explicit
|
||||||
|
6. **NICIODATA NU HARDCODEZ VARIABILE!**
|
||||||
|
7. **NICIODATA NU ADAUGAM SETARI FAILOVERS** - Dacă ceva nu e bine, vrem să știm imediat
|
||||||
|
8. **Salvez date relevante taskului curent** în DEBUG_CURRENT_TASK.md
|
||||||
|
9. **Salvez întotdeauna ce am modificat** pentru rollback dacă e nevoie
|
||||||
|
10. **Creez criterii de acceptanță** înainte de a testa/finaliza
|
||||||
|
11. **Când task-uri depind de API changes** → salvez în TASK_IN_STANDBY.md
|
||||||
|
|
||||||
|
[← Înapoi la CLAUDE.md](../CLAUDE.md)
|
||||||
|
|
||||||
|
## 📋 PREREQUISITES
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- npm sau yarn
|
||||||
|
- TypeScript 5+
|
||||||
|
|
||||||
|
## 🔧 INSTALARE
|
||||||
|
|
||||||
|
### 1. Clone și Setup Initial
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Projects/mcp
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configurare Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env (NEVER COMMIT!)
|
||||||
|
MCP_HOST=127.0.0.1
|
||||||
|
MCP_PORT=19017
|
||||||
|
MCP_LOG_LEVEL=info
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🏃 RULARE
|
||||||
|
|
||||||
|
### Development Mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# Server pornește la 127.0.0.1:19017
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production Mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔗 CONECTARE CLAUDE
|
||||||
|
|
||||||
|
### 1. Configurare Claude Settings
|
||||||
|
|
||||||
|
Adaugă în `claude_desktop_config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"custom-mcp": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["/Projects/mcp/dist/server.js"],
|
||||||
|
"env": {
|
||||||
|
"MCP_HOST": "127.0.0.1",
|
||||||
|
"MCP_PORT": "19017"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Restart Claude Desktop
|
||||||
|
|
||||||
|
După configurare, restart aplicația Claude.
|
||||||
|
|
||||||
|
## 🌐 NGINX PROXY SETUP
|
||||||
|
|
||||||
|
Pentru access extern via `mcp.runningwolf.com`:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
server_name mcp.runningwolf.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:19017;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SSL config aici
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 TESTARE
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Unit tests
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Integration tests
|
||||||
|
npm run test:integration
|
||||||
|
|
||||||
|
# Test conexiune
|
||||||
|
curl http://127.0.0.1:19017/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 TROUBLESHOOTING
|
||||||
|
|
||||||
|
### Port deja ocupat
|
||||||
|
```bash
|
||||||
|
lsof -i :19017
|
||||||
|
# Kill procesul dacă e necesar
|
||||||
|
```
|
||||||
|
|
||||||
|
### Erori TypeScript
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
tail -f logs/mcp-dev.log
|
||||||
|
|
||||||
|
# Production
|
||||||
|
tail -f logs/mcp.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔗 LEGĂTURI
|
||||||
|
|
||||||
|
- [Arhitectură](./ARHITECTURA.md)
|
||||||
|
- [Tools Disponibile](./TOOLS.md)
|
||||||
|
- [Task Management](./TASKS.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
*Actualizat: 25 Iulie 2025*
|
||||||
93
docs/TASKS.md
Normal file
93
docs/TASKS.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# 📋 TASK MANAGEMENT & ISTORIE
|
||||||
|
|
||||||
|
## ⚡ REGULI DE AUR (NICIODATA NU ȘTERG!)
|
||||||
|
|
||||||
|
1. **NICI UN TASK NU SE CONSIDERĂ ÎNDEPLINIT** până nu se îndeplinesc criteriile de acceptanță definite
|
||||||
|
2. **NU ÎNCEPEM UN TASK** până nu definim criteriile de acceptanță
|
||||||
|
3. **UN SINGUR TASK ÎN LUCRU** - restul în standby
|
||||||
|
4. **DOCUMENTAȚIA RĂMÂNE ÎN ARBORE** - toate fișierele conectate
|
||||||
|
5. **NU LUCREZI NICIODATĂ LA ALTCEVA** decât ți s-a spus explicit
|
||||||
|
6. **NICIODATA NU HARDCODEZ VARIABILE!**
|
||||||
|
7. **NICIODATA NU ADAUGAM SETARI FAILOVERS** - Dacă ceva nu e bine, vrem să știm imediat
|
||||||
|
8. **Salvez date relevante taskului curent** în DEBUG_CURRENT_TASK.md
|
||||||
|
9. **Salvez întotdeauna ce am modificat** pentru rollback dacă e nevoie
|
||||||
|
10. **Creez criterii de acceptanță** înainte de a testa/finaliza
|
||||||
|
11. **Când task-uri depind de API changes** → salvez în TASK_IN_STANDBY.md
|
||||||
|
|
||||||
|
[← Înapoi la CLAUDE.md](../CLAUDE.md)
|
||||||
|
|
||||||
|
## 🏃 TASK CURENT
|
||||||
|
|
||||||
|
Vezi [DEBUG_CURRENT_TASK.md](../DEBUG_CURRENT_TASK.md)
|
||||||
|
|
||||||
|
## 📌 TASKS ÎN STANDBY
|
||||||
|
|
||||||
|
Vezi [TASK_IN_STANDBY.md](../TASK_IN_STANDBY.md)
|
||||||
|
|
||||||
|
## ✅ TASKS COMPLETATE
|
||||||
|
|
||||||
|
### Task #1: Structura de bază pentru serverul MCP
|
||||||
|
**Completat:** 25 Iulie 2025
|
||||||
|
**Durata:** ~1 oră
|
||||||
|
**Criterii îndeplinite:**
|
||||||
|
- ✅ TypeScript project setup cu configurare strictă
|
||||||
|
- ✅ Server MCP funcțional pe 127.0.0.1:19017
|
||||||
|
- ✅ Structură de bază conform arhitecturii
|
||||||
|
- ✅ NATS connection setup
|
||||||
|
- ✅ MCP SDK integration
|
||||||
|
- ✅ Development workflow
|
||||||
|
- ✅ Teste de bază
|
||||||
|
|
||||||
|
**Fișiere create:**
|
||||||
|
- `package.json`, `tsconfig.json`, `.eslintrc.json`, `.prettierrc.json`
|
||||||
|
- `/src/server.ts` - MCPServer class principal
|
||||||
|
- `/src/config.ts` - Configurare cu Zod
|
||||||
|
- `/src/types/index.ts` - TypeScript interfaces
|
||||||
|
- `/src/nats/NatsClient.ts` - NATS wrapper
|
||||||
|
- `/src/registry/ToolRegistry.ts` - Tool management
|
||||||
|
- `/src/registry/ModuleManager.ts` - Module lifecycle
|
||||||
|
- Teste unitare în `/tests/`
|
||||||
|
|
||||||
|
### Task #5: Sistem Documentație
|
||||||
|
**Completat:** 25 Iulie 2025
|
||||||
|
**Durata:** ~45 minute
|
||||||
|
**Criterii îndeplinite:**
|
||||||
|
- ✅ CLAUDE.md creat cu reguli și structură
|
||||||
|
- ✅ Sistem fișiere arboresecent
|
||||||
|
- ✅ Reguli de aur în toate fișierele
|
||||||
|
- ✅ DEBUG_CURRENT_TASK.md pentru tracking
|
||||||
|
- ✅ TASK_IN_STANDBY.md pentru queue
|
||||||
|
- ✅ Test final: toate fișierele există și sunt conectate
|
||||||
|
|
||||||
|
**Fișiere create:**
|
||||||
|
- `/Projects/mcp/CLAUDE.md`
|
||||||
|
- `/Projects/mcp/DEBUG_CURRENT_TASK.md`
|
||||||
|
- `/Projects/mcp/TASK_IN_STANDBY.md`
|
||||||
|
- `/Projects/mcp/docs/ARHITECTURA.md`
|
||||||
|
- `/Projects/mcp/docs/SETUP.md`
|
||||||
|
- `/Projects/mcp/docs/TOOLS.md`
|
||||||
|
- `/Projects/mcp/docs/TASKS.md` (acest fișier)
|
||||||
|
|
||||||
|
## 📊 METRICI
|
||||||
|
|
||||||
|
- **Total tasks definite:** 5
|
||||||
|
- **Completate:** 1 (în progres)
|
||||||
|
- **În standby:** 4
|
||||||
|
- **Success rate:** TBD
|
||||||
|
|
||||||
|
## 🔄 WORKFLOW PROCESS
|
||||||
|
|
||||||
|
1. **User definește task** → criterii acceptanță
|
||||||
|
2. **Un singur task activ** → în DEBUG_CURRENT_TASK.md
|
||||||
|
3. **Alte tasks** → în TASK_IN_STANDBY.md
|
||||||
|
4. **După completare** → arhivat aici
|
||||||
|
5. **Rollback info** → salvat în fiecare task
|
||||||
|
|
||||||
|
## 🔗 LEGĂTURI
|
||||||
|
|
||||||
|
- [Current Task](../DEBUG_CURRENT_TASK.md)
|
||||||
|
- [Standby Queue](../TASK_IN_STANDBY.md)
|
||||||
|
- [Main Context](../CLAUDE.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
*Actualizat: 25 Iulie 2025*
|
||||||
139
docs/TOOLS.md
Normal file
139
docs/TOOLS.md
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# 🔧 MCP SERVER TOOLS
|
||||||
|
|
||||||
|
## ⚡ REGULI DE AUR (NICIODATA NU ȘTERG!)
|
||||||
|
|
||||||
|
1. **NICI UN TASK NU SE CONSIDERĂ ÎNDEPLINIT** până nu se îndeplinesc criteriile de acceptanță definite
|
||||||
|
2. **NU ÎNCEPEM UN TASK** până nu definim criteriile de acceptanță
|
||||||
|
3. **UN SINGUR TASK ÎN LUCRU** - restul în standby
|
||||||
|
4. **DOCUMENTAȚIA RĂMÂNE ÎN ARBORE** - toate fișierele conectate
|
||||||
|
5. **NU LUCREZI NICIODATĂ LA ALTCEVA** decât ți s-a spus explicit
|
||||||
|
6. **NICIODATA NU HARDCODEZ VARIABILE!**
|
||||||
|
7. **NICIODATA NU ADAUGAM SETARI FAILOVERS** - Dacă ceva nu e bine, vrem să știm imediat
|
||||||
|
8. **Salvez date relevante taskului curent** în DEBUG_CURRENT_TASK.md
|
||||||
|
9. **Salvez întotdeauna ce am modificat** pentru rollback dacă e nevoie
|
||||||
|
10. **Creez criterii de acceptanță** înainte de a testa/finaliza
|
||||||
|
11. **Când task-uri depind de API changes** → salvez în TASK_IN_STANDBY.md
|
||||||
|
|
||||||
|
[← Înapoi la CLAUDE.md](../CLAUDE.md)
|
||||||
|
|
||||||
|
## 📋 TOOL-URI DISPONIBILE
|
||||||
|
|
||||||
|
### ✅ Tool-uri Implementate
|
||||||
|
|
||||||
|
Serverul MCP vine cu un set complet de tool-uri built-in pentru operații comune:
|
||||||
|
|
||||||
|
#### 📁 [File Operations](./tools/file-operations.md)
|
||||||
|
- **file_read** - Citire fișiere cu limite de securitate
|
||||||
|
- **file_write** - Scriere fișiere cu validare
|
||||||
|
- **file_list** - Listare directoare cu filtrare
|
||||||
|
|
||||||
|
#### 💻 [System Command](./tools/system-command.md)
|
||||||
|
- **system_command** - Execuție comenzi sistem (whitelist)
|
||||||
|
|
||||||
|
#### 🌐 [HTTP Request](./tools/http-request.md)
|
||||||
|
- **http_request** - Request-uri HTTP/HTTPS cu securitate
|
||||||
|
|
||||||
|
📚 **[Vezi documentația completă a tool-urilor →](./tools/README.md)**
|
||||||
|
|
||||||
|
## 🎯 TOOL-URI PLANIFICATE
|
||||||
|
|
||||||
|
### 1. File Operations
|
||||||
|
**Nume:** `file_read`, `file_write`, `file_watch`
|
||||||
|
**Scop:** Operații cu fișiere locale
|
||||||
|
**Input Schema:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"path": "string",
|
||||||
|
"encoding": "utf8|binary",
|
||||||
|
"content": "string (pentru write)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. System Commands
|
||||||
|
**Nume:** `exec_command`
|
||||||
|
**Scop:** Execuție comenzi sistem
|
||||||
|
**Input Schema:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "string",
|
||||||
|
"args": ["array", "of", "strings"],
|
||||||
|
"cwd": "string",
|
||||||
|
"timeout": "number"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. HTTP Client
|
||||||
|
**Nume:** `http_request`
|
||||||
|
**Scop:** Request-uri HTTP/HTTPS
|
||||||
|
**Input Schema:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"url": "string",
|
||||||
|
"method": "GET|POST|PUT|DELETE",
|
||||||
|
"headers": {},
|
||||||
|
"body": "string|object"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Database Query
|
||||||
|
**Nume:** `db_query`
|
||||||
|
**Scop:** Interogări bază de date
|
||||||
|
**Input Schema:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"connection": "string",
|
||||||
|
"query": "string",
|
||||||
|
"params": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Data Transform
|
||||||
|
**Nume:** `transform_data`
|
||||||
|
**Scop:** Transformări JSON/CSV
|
||||||
|
**Input Schema:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"input": "object|array",
|
||||||
|
"transform": "jq expression or custom",
|
||||||
|
"output_format": "json|csv|yaml"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔨 CUM SĂ ADAUGI UN TOOL NOU
|
||||||
|
|
||||||
|
1. **Creează fișier** în `src/tools/`
|
||||||
|
2. **Implementează interfața**:
|
||||||
|
```typescript
|
||||||
|
export interface Tool {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
inputSchema: JSONSchema
|
||||||
|
handler: (input: any) => Promise<any>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Înregistrează în** `src/tools/index.ts`
|
||||||
|
4. **Adaugă teste** în `tests/tools/`
|
||||||
|
5. **Documentează aici** cu exemple
|
||||||
|
|
||||||
|
## 🧪 TESTARE TOOL-URI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test individual tool
|
||||||
|
npm run test:tool -- file_read
|
||||||
|
|
||||||
|
# Test all tools
|
||||||
|
npm run test:tools
|
||||||
|
|
||||||
|
# Integration test cu Claude
|
||||||
|
npm run test:integration
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔗 LEGĂTURI
|
||||||
|
|
||||||
|
- [Arhitectură](./ARHITECTURA.md)
|
||||||
|
- [Setup Guide](./SETUP.md)
|
||||||
|
- [Task Management](./TASKS.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
*Actualizat: 25 Iulie 2025*
|
||||||
443
docs/api-reference.md
Normal file
443
docs/api-reference.md
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
# MCP Server API Reference
|
||||||
|
|
||||||
|
## Tool Handler Base Class
|
||||||
|
|
||||||
|
All tools extend the `ToolHandler` abstract class which provides lifecycle management, validation, and error handling.
|
||||||
|
|
||||||
|
### Class Definition
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
abstract class ToolHandler<TInput = any, TOutput = any> {
|
||||||
|
constructor(
|
||||||
|
config: ToolConfig,
|
||||||
|
inputSchema: z.ZodSchema<TInput>,
|
||||||
|
outputSchema: z.ZodSchema<TOutput>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Main execution method
|
||||||
|
async execute(input: unknown, context: ToolContext): Promise<TOutput>;
|
||||||
|
|
||||||
|
// Lifecycle hooks (override in subclass)
|
||||||
|
protected async initialize(): Promise<void>;
|
||||||
|
protected async validate(input: TInput): Promise<TInput>;
|
||||||
|
protected abstract handle(input: TInput, context: ToolContext): Promise<TOutput>;
|
||||||
|
protected async cleanup(): Promise<void>;
|
||||||
|
protected async checkPermissions(context: ToolContext): Promise<void>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tool Configuration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ToolConfig {
|
||||||
|
name: string; // Unique tool identifier
|
||||||
|
description: string; // Human-readable description
|
||||||
|
timeout?: number; // Execution timeout in ms (default: 30000)
|
||||||
|
permissions?: string[]; // Required permissions
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tool Context
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ToolContext {
|
||||||
|
requestId: string; // Unique request identifier
|
||||||
|
permissions: string[]; // Granted permissions
|
||||||
|
userId?: string; // Optional user identifier
|
||||||
|
metadata?: Record<string, any>; // Additional context
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating a Custom Tool
|
||||||
|
|
||||||
|
### Basic Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { ToolHandler, ToolContext } from '@mcp/tools';
|
||||||
|
|
||||||
|
// Define schemas
|
||||||
|
const InputSchema = z.object({
|
||||||
|
message: z.string(),
|
||||||
|
uppercase: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const OutputSchema = z.object({
|
||||||
|
result: z.string(),
|
||||||
|
length: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type Input = z.infer<typeof InputSchema>;
|
||||||
|
type Output = z.infer<typeof OutputSchema>;
|
||||||
|
|
||||||
|
// Implement tool
|
||||||
|
export class EchoTool extends ToolHandler<Input, Output> {
|
||||||
|
constructor() {
|
||||||
|
super(
|
||||||
|
{
|
||||||
|
name: 'echo',
|
||||||
|
description: 'Echo a message with transformations',
|
||||||
|
timeout: 5000,
|
||||||
|
},
|
||||||
|
InputSchema,
|
||||||
|
OutputSchema
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async handle(input: Input, context: ToolContext): Promise<Output> {
|
||||||
|
let result = input.message;
|
||||||
|
|
||||||
|
if (input.uppercase) {
|
||||||
|
result = result.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.info({ message: result }, 'Echoing message');
|
||||||
|
|
||||||
|
return {
|
||||||
|
result,
|
||||||
|
length: result.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Advanced Example with Permissions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class DatabaseQueryTool extends ToolHandler<QueryInput, QueryOutput> {
|
||||||
|
private db: Database;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(
|
||||||
|
{
|
||||||
|
name: 'db_query',
|
||||||
|
description: 'Execute database queries',
|
||||||
|
timeout: 60000,
|
||||||
|
permissions: ['database:read', 'database:write'],
|
||||||
|
},
|
||||||
|
QueryInputSchema,
|
||||||
|
QueryOutputSchema
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async initialize(): Promise<void> {
|
||||||
|
// Connect to database
|
||||||
|
this.db = await Database.connect(process.env.DATABASE_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async checkPermissions(context: ToolContext): Promise<void> {
|
||||||
|
const isWrite = this.isWriteQuery(this.currentInput.query);
|
||||||
|
|
||||||
|
if (isWrite && !context.permissions.includes('database:write')) {
|
||||||
|
throw new Error('Permission denied: database:write required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.permissions.includes('database:read')) {
|
||||||
|
throw new Error('Permission denied: database:read required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async validate(input: QueryInput): Promise<QueryInput> {
|
||||||
|
// Additional validation beyond schema
|
||||||
|
if (this.containsSqlInjection(input.query)) {
|
||||||
|
throw new Error('Invalid query: potential SQL injection');
|
||||||
|
}
|
||||||
|
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async handle(input: QueryInput, context: ToolContext): Promise<QueryOutput> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await this.db.query(input.query, input.params);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: results.rows,
|
||||||
|
rowCount: results.rowCount,
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error({ error, query: input.query }, 'Query failed');
|
||||||
|
throw new Error(`Database query failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async cleanup(): Promise<void> {
|
||||||
|
// Close database connection
|
||||||
|
if (this.db) {
|
||||||
|
await this.db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tool Registration
|
||||||
|
|
||||||
|
### Registering Built-in Tools
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ToolRegistry } from '@mcp/registry';
|
||||||
|
import { FileReadTool, FileWriteTool } from './tools';
|
||||||
|
|
||||||
|
const registry = new ToolRegistry(natsClient);
|
||||||
|
|
||||||
|
// Built-in tools are registered automatically
|
||||||
|
// But you can also register manually
|
||||||
|
registry.registerBuiltinTool('file_read', new FileReadTool());
|
||||||
|
registry.registerBuiltinTool('file_write', new FileWriteTool());
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registering External Module Tools
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// External tools are discovered via NATS
|
||||||
|
// Modules announce their tools on startup
|
||||||
|
natsClient.publish('tools.discovery', {
|
||||||
|
module: 'my-module',
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
name: 'my_tool',
|
||||||
|
description: 'Custom tool from module',
|
||||||
|
inputSchema: { /* ... */ },
|
||||||
|
permissions: ['custom:permission'],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Error Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Base error class
|
||||||
|
export class ToolError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public code: string,
|
||||||
|
public details?: any
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ToolError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specific error types
|
||||||
|
export class ValidationError extends ToolError {
|
||||||
|
constructor(message: string, details?: any) {
|
||||||
|
super(message, 'VALIDATION_ERROR', details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PermissionError extends ToolError {
|
||||||
|
constructor(message: string, required: string[]) {
|
||||||
|
super(message, 'PERMISSION_ERROR', { required });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TimeoutError extends ToolError {
|
||||||
|
constructor(timeout: number) {
|
||||||
|
super(`Operation timed out after ${timeout}ms`, 'TIMEOUT_ERROR');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling in Tools
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
protected async handle(input: Input, context: ToolContext): Promise<Output> {
|
||||||
|
try {
|
||||||
|
// Tool logic
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
throw new ToolError('File not found', 'FILE_NOT_FOUND', { path: input.path });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-throw unknown errors
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Tools
|
||||||
|
|
||||||
|
### Unit Testing
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { MyTool } from './MyTool';
|
||||||
|
import { ToolContext } from '@mcp/tools';
|
||||||
|
|
||||||
|
describe('MyTool', () => {
|
||||||
|
let tool: MyTool;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tool = new MyTool();
|
||||||
|
});
|
||||||
|
|
||||||
|
const createContext = (permissions: string[] = []): ToolContext => ({
|
||||||
|
requestId: 'test-request',
|
||||||
|
permissions,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should execute successfully', async () => {
|
||||||
|
const result = await tool.execute(
|
||||||
|
{ input: 'test' },
|
||||||
|
createContext(['required:permission'])
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ output: 'expected' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require permissions', async () => {
|
||||||
|
await expect(
|
||||||
|
tool.execute({ input: 'test' }, createContext())
|
||||||
|
).rejects.toThrow('Permission denied');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Testing
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ToolRegistry } from '@mcp/registry';
|
||||||
|
import { NatsClient } from '@mcp/nats';
|
||||||
|
|
||||||
|
describe('Tool Integration', () => {
|
||||||
|
let registry: ToolRegistry;
|
||||||
|
let nats: NatsClient;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
nats = new NatsClient();
|
||||||
|
await nats.connect();
|
||||||
|
registry = new ToolRegistry(nats);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await nats.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should execute tool via registry', async () => {
|
||||||
|
const result = await registry.executeTool(
|
||||||
|
'my_tool',
|
||||||
|
{ input: 'test' }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### Timeout Management
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
protected async handle(input: Input, context: ToolContext): Promise<Output> {
|
||||||
|
// Use AbortController for cancellable operations
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(
|
||||||
|
() => controller.abort(),
|
||||||
|
this.config.timeout || 30000
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetch(input.url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
return processResult(result);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resource Management
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class ResourceIntensiveTool extends ToolHandler {
|
||||||
|
private pool: ResourcePool;
|
||||||
|
|
||||||
|
protected async initialize(): Promise<void> {
|
||||||
|
// Initialize resource pool
|
||||||
|
this.pool = new ResourcePool({ max: 10 });
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async handle(input: Input, context: ToolContext): Promise<Output> {
|
||||||
|
// Acquire resource from pool
|
||||||
|
const resource = await this.pool.acquire();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.processWithResource(resource, input);
|
||||||
|
} finally {
|
||||||
|
// Always release resource
|
||||||
|
this.pool.release(resource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async cleanup(): Promise<void> {
|
||||||
|
// Drain pool on cleanup
|
||||||
|
await this.pool.drain();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Best Practices
|
||||||
|
|
||||||
|
1. **Always validate input** - Use Zod schemas and additional validation
|
||||||
|
2. **Check permissions** - Implement checkPermissions for sensitive operations
|
||||||
|
3. **Sanitize paths** - Prevent directory traversal attacks
|
||||||
|
4. **Limit resource usage** - Implement timeouts and size limits
|
||||||
|
5. **Log security events** - Track permission denials and suspicious activity
|
||||||
|
6. **Use prepared statements** - Prevent SQL injection in database tools
|
||||||
|
7. **Validate URLs** - Block internal/private IP ranges in HTTP tools
|
||||||
|
|
||||||
|
## Debugging Tools
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
protected async handle(input: Input, context: ToolContext): Promise<Output> {
|
||||||
|
this.logger.debug({ input }, 'Processing request');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.process(input);
|
||||||
|
this.logger.info({ result }, 'Request successful');
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error({ error, input }, 'Request failed');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Metrics
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
protected async handle(input: Input, context: ToolContext): Promise<Output> {
|
||||||
|
const timer = this.metrics.startTimer('tool_execution_duration', {
|
||||||
|
tool: this.config.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.process(input);
|
||||||
|
|
||||||
|
this.metrics.increment('tool_execution_success', {
|
||||||
|
tool: this.config.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
this.metrics.increment('tool_execution_error', {
|
||||||
|
tool: this.config.name,
|
||||||
|
error: error.code,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
timer.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
182
docs/tools/README.md
Normal file
182
docs/tools/README.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# MCP Server Built-in Tools
|
||||||
|
|
||||||
|
The MCP server comes with a comprehensive set of built-in tools for common operations. All tools include security features, permission controls, and consistent error handling.
|
||||||
|
|
||||||
|
## Available Tools
|
||||||
|
|
||||||
|
### 📁 File Operations
|
||||||
|
Tools for reading, writing, and listing files with security controls.
|
||||||
|
|
||||||
|
- [**FileReadTool**](./file-operations.md#filereadtool) - Read file contents
|
||||||
|
- [**FileWriteTool**](./file-operations.md#filewritetool) - Write content to files
|
||||||
|
- [**FileListTool**](./file-operations.md#filelisttool) - List directory contents
|
||||||
|
|
||||||
|
### 💻 System Operations
|
||||||
|
Execute system commands with security restrictions.
|
||||||
|
|
||||||
|
- [**SystemCommandTool**](./system-command.md) - Execute whitelisted system commands
|
||||||
|
|
||||||
|
### 🌐 Network Operations
|
||||||
|
Make HTTP requests to external services.
|
||||||
|
|
||||||
|
- [**HttpRequestTool**](./http-request.md) - Make HTTP/HTTPS requests
|
||||||
|
|
||||||
|
## Permission Model
|
||||||
|
|
||||||
|
Each tool requires specific permissions to execute:
|
||||||
|
|
||||||
|
| Tool | Required Permission | Description |
|
||||||
|
|------|-------------------|-------------|
|
||||||
|
| FileReadTool | `file:read` | Read access to files |
|
||||||
|
| FileWriteTool | `file:write` | Write access to files |
|
||||||
|
| FileListTool | `file:read` | Read access to directories |
|
||||||
|
| SystemCommandTool | `system:exec` | Execute system commands |
|
||||||
|
| HttpRequestTool | `network:http` | Make HTTP requests |
|
||||||
|
|
||||||
|
## Security Features
|
||||||
|
|
||||||
|
All built-in tools implement comprehensive security controls:
|
||||||
|
|
||||||
|
### 🛡️ Path Security
|
||||||
|
- **Directory traversal prevention** - Blocks `..` and absolute paths outside allowed directories
|
||||||
|
- **Restricted directories** - Cannot access system directories like `/etc`, `/sys`, `/proc`
|
||||||
|
- **Allowed paths** - Only current working directory and system temp directory
|
||||||
|
|
||||||
|
### 🔒 Input Validation
|
||||||
|
- **Schema validation** - All inputs validated with Zod schemas
|
||||||
|
- **Command whitelisting** - Only safe system commands allowed
|
||||||
|
- **Shell injection prevention** - Blocks shell operators in arguments
|
||||||
|
- **URL validation** - Validates and blocks private IP ranges
|
||||||
|
|
||||||
|
### ⏱️ Resource Limits
|
||||||
|
- **Timeouts** - All operations have configurable timeouts
|
||||||
|
- **Size limits** - File operations limited to 10MB
|
||||||
|
- **Output limits** - Command output limited to 1MB
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
All tools follow consistent error handling patterns:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
try {
|
||||||
|
const result = await mcp.callTool('tool_name', params);
|
||||||
|
// Handle success
|
||||||
|
} catch (error) {
|
||||||
|
// Errors include:
|
||||||
|
// - Permission denied
|
||||||
|
// - Invalid input
|
||||||
|
// - Resource not found
|
||||||
|
// - Timeout exceeded
|
||||||
|
// - Operation failed
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Reading a Configuration File
|
||||||
|
```javascript
|
||||||
|
const config = await mcp.callTool('file_read', {
|
||||||
|
path: './config.json'
|
||||||
|
});
|
||||||
|
const parsedConfig = JSON.parse(config.content);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Writing Log Data
|
||||||
|
```javascript
|
||||||
|
await mcp.callTool('file_write', {
|
||||||
|
path: './logs/app.log',
|
||||||
|
content: `[${new Date().toISOString()}] Application started\n`,
|
||||||
|
mode: 'append'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Listing Project Files
|
||||||
|
```javascript
|
||||||
|
const files = await mcp.callTool('file_list', {
|
||||||
|
path: './src',
|
||||||
|
recursive: true,
|
||||||
|
pattern: '*.js'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running System Commands
|
||||||
|
```javascript
|
||||||
|
const result = await mcp.callTool('system_command', {
|
||||||
|
command: 'grep',
|
||||||
|
args: ['-r', 'TODO', '.'],
|
||||||
|
cwd: './src'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Making API Requests
|
||||||
|
```javascript
|
||||||
|
const response = await mcp.callTool('http_request', {
|
||||||
|
url: 'https://api.github.com/user',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'token YOUR_TOKEN'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Always handle errors** - Tools can fail for various reasons
|
||||||
|
2. **Use appropriate timeouts** - Don't let operations hang
|
||||||
|
3. **Validate inputs** - Even though tools validate, check your data
|
||||||
|
4. **Check permissions** - Ensure required permissions are granted
|
||||||
|
5. **Use relative paths** - More portable than absolute paths
|
||||||
|
6. **Respect rate limits** - Especially for HTTP requests
|
||||||
|
7. **Log operations** - Track what tools are doing
|
||||||
|
|
||||||
|
## Extending with Custom Tools
|
||||||
|
|
||||||
|
While built-in tools cover common use cases, you can create custom tools for specific needs. See the [Module Development Guide](../modules/development.md) for details on creating custom tools.
|
||||||
|
|
||||||
|
## Tool Lifecycle
|
||||||
|
|
||||||
|
1. **Input validation** - Schema validation with Zod
|
||||||
|
2. **Permission check** - Verify required permissions
|
||||||
|
3. **Pre-execution hooks** - Custom validation/preparation
|
||||||
|
4. **Execution** - Actual tool operation
|
||||||
|
5. **Output formatting** - Consistent response format
|
||||||
|
6. **Error handling** - Structured error responses
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
- File operations are synchronous within the tool
|
||||||
|
- HTTP requests use native fetch API
|
||||||
|
- System commands spawn child processes
|
||||||
|
- All operations subject to timeout limits
|
||||||
|
- Large file operations may impact performance
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Permission Denied
|
||||||
|
```
|
||||||
|
Error: Permission denied: file:read required
|
||||||
|
```
|
||||||
|
Ensure the tool has required permissions in the auth context.
|
||||||
|
|
||||||
|
### Invalid Path
|
||||||
|
```
|
||||||
|
Error: Invalid path: directory traversal not allowed
|
||||||
|
```
|
||||||
|
Use paths within the current directory or temp directory.
|
||||||
|
|
||||||
|
### Command Not Allowed
|
||||||
|
```
|
||||||
|
Error: Command not allowed: rm
|
||||||
|
```
|
||||||
|
Only whitelisted commands can be executed.
|
||||||
|
|
||||||
|
### Timeout Exceeded
|
||||||
|
```
|
||||||
|
Error: Request timeout after 30000ms
|
||||||
|
```
|
||||||
|
Increase timeout or optimize the operation.
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
|
||||||
|
- **v0.1.0** - Initial release with basic file, system, and HTTP tools
|
||||||
|
- **v0.2.0** - Added security controls and permission system
|
||||||
|
- **v0.3.0** - Enhanced error handling and validation
|
||||||
165
docs/tools/file-operations.md
Normal file
165
docs/tools/file-operations.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# File Operations Tools
|
||||||
|
|
||||||
|
The MCP server provides several built-in tools for file system operations with comprehensive security controls.
|
||||||
|
|
||||||
|
## FileReadTool
|
||||||
|
|
||||||
|
Reads the contents of a file with security restrictions.
|
||||||
|
|
||||||
|
### Input Schema
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
path: string; // File path (relative or absolute)
|
||||||
|
encoding?: 'utf8' | 'binary'; // Default: 'utf8'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output Schema
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
content: string; // File contents
|
||||||
|
size: number; // File size in bytes
|
||||||
|
path: string; // Absolute file path
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security Features
|
||||||
|
- Prevents directory traversal attacks
|
||||||
|
- Only allows access to files within current working directory or system temp directory
|
||||||
|
- File size limit: 10MB
|
||||||
|
- Requires `file:read` permission
|
||||||
|
|
||||||
|
### Example Usage
|
||||||
|
```javascript
|
||||||
|
const result = await mcp.callTool('file_read', {
|
||||||
|
path: './config.json',
|
||||||
|
encoding: 'utf8'
|
||||||
|
});
|
||||||
|
console.log(result.content);
|
||||||
|
```
|
||||||
|
|
||||||
|
## FileWriteTool
|
||||||
|
|
||||||
|
Writes content to a file with security restrictions.
|
||||||
|
|
||||||
|
### Input Schema
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
path: string; // File path (relative or absolute)
|
||||||
|
content: string; // Content to write
|
||||||
|
encoding?: 'utf8' | 'binary' | 'base64'; // Default: 'utf8'
|
||||||
|
mode?: 'overwrite' | 'append'; // Default: 'overwrite'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output Schema
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
path: string; // Absolute file path
|
||||||
|
size: number; // File size after write
|
||||||
|
mode: string; // Write mode used
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security Features
|
||||||
|
- Prevents directory traversal attacks
|
||||||
|
- Only allows writing within current working directory or system temp directory
|
||||||
|
- Blocks writing to system directories (/etc, /sys, /proc, /dev)
|
||||||
|
- Requires `file:write` permission
|
||||||
|
- Automatically creates parent directories if needed
|
||||||
|
|
||||||
|
### Example Usage
|
||||||
|
```javascript
|
||||||
|
// Write text file
|
||||||
|
await mcp.callTool('file_write', {
|
||||||
|
path: './output.txt',
|
||||||
|
content: 'Hello, World!'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write binary file
|
||||||
|
await mcp.callTool('file_write', {
|
||||||
|
path: './image.png',
|
||||||
|
content: 'iVBORw0KGgoAAAANS...', // base64 encoded
|
||||||
|
encoding: 'base64'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Append to file
|
||||||
|
await mcp.callTool('file_write', {
|
||||||
|
path: './log.txt',
|
||||||
|
content: 'New log entry\n',
|
||||||
|
mode: 'append'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## FileListTool
|
||||||
|
|
||||||
|
Lists files and directories with filtering options.
|
||||||
|
|
||||||
|
### Input Schema
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
path: string; // Directory path
|
||||||
|
recursive?: boolean; // Recurse into subdirectories
|
||||||
|
pattern?: string; // Glob pattern filter (e.g., '*.txt')
|
||||||
|
includeHidden?: boolean; // Include hidden files (starting with .)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output Schema
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
path: string; // Directory path
|
||||||
|
entries: Array<{
|
||||||
|
path: string; // Full path
|
||||||
|
type: 'file' | 'directory' | 'symlink' | 'other';
|
||||||
|
name: string; // File/directory name
|
||||||
|
size: number; // Size in bytes
|
||||||
|
modified: string; // ISO date string
|
||||||
|
}>;
|
||||||
|
totalSize: number; // Total size of all files
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security Features
|
||||||
|
- Prevents directory traversal attacks
|
||||||
|
- Only allows listing within current working directory or system temp directory
|
||||||
|
- Requires `file:read` permission
|
||||||
|
- Skips inaccessible items instead of failing
|
||||||
|
|
||||||
|
### Example Usage
|
||||||
|
```javascript
|
||||||
|
// List directory contents
|
||||||
|
const result = await mcp.callTool('file_list', {
|
||||||
|
path: './src'
|
||||||
|
});
|
||||||
|
|
||||||
|
// List recursively with pattern
|
||||||
|
const jsFiles = await mcp.callTool('file_list', {
|
||||||
|
path: './src',
|
||||||
|
recursive: true,
|
||||||
|
pattern: '*.js'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Include hidden files
|
||||||
|
const allFiles = await mcp.callTool('file_list', {
|
||||||
|
path: './',
|
||||||
|
includeHidden: true
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
All file operation tools follow consistent error handling:
|
||||||
|
|
||||||
|
- **File not found**: Returns clear error message with the path
|
||||||
|
- **Permission denied**: Returns error when lacking required permissions
|
||||||
|
- **Invalid paths**: Blocks directory traversal and system paths
|
||||||
|
- **Size limits**: Enforces reasonable limits to prevent abuse
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Always use relative paths** when possible for better portability
|
||||||
|
2. **Check permissions** before attempting operations
|
||||||
|
3. **Handle errors gracefully** - file operations can fail for many reasons
|
||||||
|
4. **Use appropriate encodings** - utf8 for text, base64 for binary data
|
||||||
|
5. **Be mindful of file sizes** - large files can impact performance
|
||||||
273
docs/tools/http-request.md
Normal file
273
docs/tools/http-request.md
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
# HTTP Request Tool
|
||||||
|
|
||||||
|
Make HTTP/HTTPS requests with comprehensive options and security controls.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The HttpRequestTool enables making HTTP requests to external services with support for various methods, headers, authentication, and response handling.
|
||||||
|
|
||||||
|
## Input Schema
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
url: string; // Target URL (required)
|
||||||
|
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
|
||||||
|
headers?: Record<string, string>; // Request headers
|
||||||
|
body?: string | object; // Request body
|
||||||
|
timeout?: number; // Timeout in ms (default: 30000)
|
||||||
|
followRedirects?: boolean; // Follow redirects (default: true)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output Schema
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
status: number; // HTTP status code
|
||||||
|
statusText: string; // Status message
|
||||||
|
headers: Record<string, string>; // Response headers
|
||||||
|
body: any; // Response body (parsed based on content-type)
|
||||||
|
duration: number; // Request duration in ms
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Supported Methods
|
||||||
|
- **GET** - Retrieve data
|
||||||
|
- **POST** - Submit data
|
||||||
|
- **PUT** - Update resource
|
||||||
|
- **DELETE** - Remove resource
|
||||||
|
- **PATCH** - Partial update
|
||||||
|
- **HEAD** - Headers only
|
||||||
|
- **OPTIONS** - Check allowed methods
|
||||||
|
|
||||||
|
### Automatic Content Handling
|
||||||
|
- JSON responses are automatically parsed
|
||||||
|
- Text responses returned as strings
|
||||||
|
- Binary data returned as base64 encoded strings
|
||||||
|
- Content-Type header automatically set for JSON bodies
|
||||||
|
|
||||||
|
### Security Features
|
||||||
|
- Blocks requests to private IP ranges (localhost, internal networks)
|
||||||
|
- Validates URLs before making requests
|
||||||
|
- Timeout protection against hanging requests
|
||||||
|
- Requires `network:http` permission
|
||||||
|
|
||||||
|
## Example Usage
|
||||||
|
|
||||||
|
### Simple GET Request
|
||||||
|
```javascript
|
||||||
|
const response = await mcp.callTool('http_request', {
|
||||||
|
url: 'https://api.example.com/users'
|
||||||
|
});
|
||||||
|
console.log(response.body); // Parsed JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST with JSON Body
|
||||||
|
```javascript
|
||||||
|
const response = await mcp.callTool('http_request', {
|
||||||
|
url: 'https://api.example.com/users',
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
name: 'John Doe',
|
||||||
|
email: 'john@example.com'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Headers
|
||||||
|
```javascript
|
||||||
|
const response = await mcp.callTool('http_request', {
|
||||||
|
url: 'https://api.example.com/data',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer token123',
|
||||||
|
'X-API-Key': 'myapikey'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Form Data
|
||||||
|
```javascript
|
||||||
|
const response = await mcp.callTool('http_request', {
|
||||||
|
url: 'https://api.example.com/form',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
body: 'name=John&email=john@example.com'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Timeout
|
||||||
|
```javascript
|
||||||
|
const response = await mcp.callTool('http_request', {
|
||||||
|
url: 'https://slow-api.example.com/data',
|
||||||
|
timeout: 5000 // 5 seconds
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Disable Redirect Following
|
||||||
|
```javascript
|
||||||
|
const response = await mcp.callTool('http_request', {
|
||||||
|
url: 'https://example.com/redirect',
|
||||||
|
followRedirects: false
|
||||||
|
});
|
||||||
|
// Check response.status for 301/302
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response Handling
|
||||||
|
|
||||||
|
### JSON Response
|
||||||
|
```javascript
|
||||||
|
const response = await mcp.callTool('http_request', {
|
||||||
|
url: 'https://api.example.com/json'
|
||||||
|
});
|
||||||
|
// response.body is already parsed object
|
||||||
|
console.log(response.body.data);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Text Response
|
||||||
|
```javascript
|
||||||
|
const response = await mcp.callTool('http_request', {
|
||||||
|
url: 'https://example.com/text.txt'
|
||||||
|
});
|
||||||
|
// response.body is string
|
||||||
|
console.log(response.body);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Binary Response
|
||||||
|
```javascript
|
||||||
|
const response = await mcp.callTool('http_request', {
|
||||||
|
url: 'https://example.com/image.png'
|
||||||
|
});
|
||||||
|
// response.body is base64 encoded string
|
||||||
|
const buffer = Buffer.from(response.body, 'base64');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Status Checking
|
||||||
|
```javascript
|
||||||
|
const response = await mcp.callTool('http_request', {
|
||||||
|
url: 'https://api.example.com/resource'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status >= 200 && response.status < 300) {
|
||||||
|
// Success
|
||||||
|
} else if (response.status === 404) {
|
||||||
|
// Not found
|
||||||
|
} else if (response.status >= 500) {
|
||||||
|
// Server error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Network Errors
|
||||||
|
```javascript
|
||||||
|
try {
|
||||||
|
const response = await mcp.callTool('http_request', {
|
||||||
|
url: 'https://nonexistent.example.com'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Error: HTTP request failed: ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Timeout Errors
|
||||||
|
```javascript
|
||||||
|
try {
|
||||||
|
const response = await mcp.callTool('http_request', {
|
||||||
|
url: 'https://slow.example.com',
|
||||||
|
timeout: 1000
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Error: Request timeout after 1000ms
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Invalid URLs
|
||||||
|
```javascript
|
||||||
|
try {
|
||||||
|
const response = await mcp.callTool('http_request', {
|
||||||
|
url: 'not-a-url'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Error: Invalid URL
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Blocked Internal IPs
|
||||||
|
```javascript
|
||||||
|
try {
|
||||||
|
const response = await mcp.callTool('http_request', {
|
||||||
|
url: 'http://192.168.1.1/admin'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Error: Requests to private IP ranges are not allowed
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Always set appropriate timeouts** for external requests
|
||||||
|
2. **Handle all status codes** - don't assume 200 OK
|
||||||
|
3. **Use HTTPS** when possible for security
|
||||||
|
4. **Set User-Agent** header to identify your application
|
||||||
|
5. **Implement retry logic** for transient failures
|
||||||
|
6. **Respect rate limits** of external APIs
|
||||||
|
7. **Validate response data** before using it
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### API Authentication
|
||||||
|
```javascript
|
||||||
|
// Bearer Token
|
||||||
|
const response = await mcp.callTool('http_request', {
|
||||||
|
url: 'https://api.example.com/protected',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer your-token-here'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// API Key
|
||||||
|
const response = await mcp.callTool('http_request', {
|
||||||
|
url: 'https://api.example.com/data',
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': 'your-api-key'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Basic Auth
|
||||||
|
const credentials = Buffer.from('username:password').toString('base64');
|
||||||
|
const response = await mcp.callTool('http_request', {
|
||||||
|
url: 'https://api.example.com/secure',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${credentials}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pagination
|
||||||
|
```javascript
|
||||||
|
let allData = [];
|
||||||
|
let page = 1;
|
||||||
|
let hasMore = true;
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
const response = await mcp.callTool('http_request', {
|
||||||
|
url: `https://api.example.com/items?page=${page}&limit=100`
|
||||||
|
});
|
||||||
|
|
||||||
|
allData = allData.concat(response.body.items);
|
||||||
|
hasMore = response.body.hasNextPage;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- Cannot access localhost or private networks
|
||||||
|
- Maximum timeout of 5 minutes
|
||||||
|
- No support for streaming responses
|
||||||
|
- No built-in retry mechanism
|
||||||
|
- No connection pooling or keep-alive
|
||||||
186
docs/tools/system-command.md
Normal file
186
docs/tools/system-command.md
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
# System Command Tool
|
||||||
|
|
||||||
|
Execute system commands with comprehensive security controls and output handling.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The SystemCommandTool allows controlled execution of whitelisted system commands with proper security boundaries, timeout handling, and output management.
|
||||||
|
|
||||||
|
## Input Schema
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
command: string; // Command to execute (must be whitelisted)
|
||||||
|
args?: string[]; // Command arguments
|
||||||
|
cwd?: string; // Working directory
|
||||||
|
env?: Record<string, string>; // Environment variables
|
||||||
|
timeout?: number; // Timeout in ms (100-300000, default: 30000)
|
||||||
|
stdin?: string; // Input to send to stdin
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output Schema
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
stdout: string; // Standard output
|
||||||
|
stderr: string; // Standard error output
|
||||||
|
exitCode: number; // Process exit code
|
||||||
|
duration: number; // Execution time in ms
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Whitelisted Commands
|
||||||
|
|
||||||
|
Only the following commands are allowed for security:
|
||||||
|
|
||||||
|
- `ls` - List directory contents
|
||||||
|
- `cat` - Display file contents
|
||||||
|
- `grep` - Search text patterns
|
||||||
|
- `find` - Find files and directories
|
||||||
|
- `echo` - Print text
|
||||||
|
- `pwd` - Print working directory
|
||||||
|
- `whoami` - Show current user
|
||||||
|
- `date` - Show current date/time
|
||||||
|
- `env` - Show environment variables
|
||||||
|
- `which` - Find command location
|
||||||
|
- `wc` - Word/line/character count
|
||||||
|
- `head` - Show first lines
|
||||||
|
- `tail` - Show last lines
|
||||||
|
- `sort` - Sort lines
|
||||||
|
- `uniq` - Remove duplicate lines
|
||||||
|
- `curl` - HTTP requests
|
||||||
|
- `ping` - Network connectivity test
|
||||||
|
- `dig` - DNS lookup
|
||||||
|
- `ps` - Process list
|
||||||
|
- `df` - Disk usage
|
||||||
|
- `du` - Directory size
|
||||||
|
- `uptime` - System uptime
|
||||||
|
|
||||||
|
## Security Features
|
||||||
|
|
||||||
|
### Command Validation
|
||||||
|
- Only whitelisted commands can be executed
|
||||||
|
- Shell operators (`|`, `&`, `;`, `>`, `<`, etc.) are blocked in arguments
|
||||||
|
- Prevents command injection attacks
|
||||||
|
|
||||||
|
### Resource Limits
|
||||||
|
- Configurable timeout (max 5 minutes)
|
||||||
|
- Output size limited to 1MB per stream
|
||||||
|
- Process is killed if limits exceeded
|
||||||
|
|
||||||
|
### Permission Control
|
||||||
|
- Requires `system:exec` permission
|
||||||
|
- Inherits process environment with modifications
|
||||||
|
|
||||||
|
## Example Usage
|
||||||
|
|
||||||
|
### Basic Command
|
||||||
|
```javascript
|
||||||
|
const result = await mcp.callTool('system_command', {
|
||||||
|
command: 'ls',
|
||||||
|
args: ['-la', '/tmp']
|
||||||
|
});
|
||||||
|
console.log(result.stdout);
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Working Directory
|
||||||
|
```javascript
|
||||||
|
const result = await mcp.callTool('system_command', {
|
||||||
|
command: 'grep',
|
||||||
|
args: ['-r', 'TODO', '.'],
|
||||||
|
cwd: '/home/user/project'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Environment Variables
|
||||||
|
```javascript
|
||||||
|
const result = await mcp.callTool('system_command', {
|
||||||
|
command: 'echo',
|
||||||
|
args: ['$MY_VAR'],
|
||||||
|
env: {
|
||||||
|
MY_VAR: 'Hello from environment!'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Timeout
|
||||||
|
```javascript
|
||||||
|
const result = await mcp.callTool('system_command', {
|
||||||
|
command: 'find',
|
||||||
|
args: ['/', '-name', '*.log'],
|
||||||
|
timeout: 5000 // 5 seconds
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### With stdin Input
|
||||||
|
```javascript
|
||||||
|
const result = await mcp.callTool('system_command', {
|
||||||
|
command: 'grep',
|
||||||
|
args: ['error'],
|
||||||
|
stdin: 'line 1\nerror on line 2\nline 3'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Command Not Allowed
|
||||||
|
```javascript
|
||||||
|
// This will throw an error
|
||||||
|
await mcp.callTool('system_command', {
|
||||||
|
command: 'rm' // Not in whitelist
|
||||||
|
});
|
||||||
|
// Error: Command not allowed: rm
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shell Injection Prevention
|
||||||
|
```javascript
|
||||||
|
// This will throw an error
|
||||||
|
await mcp.callTool('system_command', {
|
||||||
|
command: 'ls',
|
||||||
|
args: ['; rm -rf /'] // Shell operators blocked
|
||||||
|
});
|
||||||
|
// Error: Shell characters not allowed in arguments
|
||||||
|
```
|
||||||
|
|
||||||
|
### Timeout Handling
|
||||||
|
```javascript
|
||||||
|
try {
|
||||||
|
await mcp.callTool('system_command', {
|
||||||
|
command: 'find',
|
||||||
|
args: ['/'],
|
||||||
|
timeout: 1000 // 1 second
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Error: Command timed out after 1000ms
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Non-Zero Exit Codes
|
||||||
|
Non-zero exit codes don't throw errors but are returned in the result:
|
||||||
|
```javascript
|
||||||
|
const result = await mcp.callTool('system_command', {
|
||||||
|
command: 'grep',
|
||||||
|
args: ['nonexistent', 'file.txt']
|
||||||
|
});
|
||||||
|
// result.exitCode will be 1 or 2
|
||||||
|
// result.stderr will contain the error message
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Use specific commands** instead of shell scripts
|
||||||
|
2. **Set appropriate timeouts** for long-running commands
|
||||||
|
3. **Check exit codes** to handle command failures
|
||||||
|
4. **Limit output size** by using head/tail when appropriate
|
||||||
|
5. **Avoid user input** in command arguments without validation
|
||||||
|
6. **Use working directory** instead of cd commands
|
||||||
|
7. **Monitor stderr** for warnings and errors
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- No shell features (pipes, redirects, wildcards)
|
||||||
|
- No interactive commands
|
||||||
|
- No sudo or privileged operations
|
||||||
|
- Output limited to 1MB per stream
|
||||||
|
- Maximum timeout of 5 minutes
|
||||||
21
jest.config.js
Normal file
21
jest.config.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/** @type {import('jest').Config} */
|
||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
roots: ['<rootDir>/src', '<rootDir>/tests'],
|
||||||
|
testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
|
||||||
|
transform: {
|
||||||
|
'^.+\\.ts$': 'ts-jest',
|
||||||
|
},
|
||||||
|
collectCoverageFrom: [
|
||||||
|
'src/**/*.ts',
|
||||||
|
'!src/**/*.d.ts',
|
||||||
|
'!src/**/index.ts',
|
||||||
|
],
|
||||||
|
coverageDirectory: 'coverage',
|
||||||
|
coverageReporters: ['text', 'lcov', 'html'],
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^@/(.*)$': '<rootDir>/src/$1',
|
||||||
|
},
|
||||||
|
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
|
||||||
|
};
|
||||||
7213
package-lock.json
generated
Normal file
7213
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
59
package.json
Normal file
59
package.json
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"name": "lupul-augmentat",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "🐺 Lupul Augmentat - MCP Server care-i dă superputeri lui Claude",
|
||||||
|
"main": "dist/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nodemon --watch src --ext ts --exec ts-node src/server.ts",
|
||||||
|
"dev:http": "nodemon --watch src --ext ts --exec ts-node src/http-server.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/server.js",
|
||||||
|
"start:http": "node dist/http-server.js",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"lint": "eslint src --ext .ts",
|
||||||
|
"lint:fix": "eslint src --ext .ts --fix",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"generate-token": "ts-node src/auth/generate-token.ts"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"mcp",
|
||||||
|
"claude",
|
||||||
|
"ai",
|
||||||
|
"tools"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^0.6.0",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"nats": "^2.19.0",
|
||||||
|
"pino": "^8.16.2",
|
||||||
|
"pino-pretty": "^10.2.3",
|
||||||
|
"ws": "^8.18.3",
|
||||||
|
"zod": "^3.22.4",
|
||||||
|
"zod-to-json-schema": "^3.24.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jest": "^29.5.11",
|
||||||
|
"@types/jsonwebtoken": "^9.0.5",
|
||||||
|
"@types/node": "^20.10.5",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.15.0",
|
||||||
|
"@typescript-eslint/parser": "^6.15.0",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-plugin-prettier": "^5.1.2",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"nodemon": "^3.0.2",
|
||||||
|
"prettier": "^3.1.1",
|
||||||
|
"ts-jest": "^29.1.1",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
20
packages/modules/example-typescript/package.json
Normal file
20
packages/modules/example-typescript/package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "@mcp-server/example-module",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Example TypeScript module for MCP Server",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"dev": "ts-node src/index.ts",
|
||||||
|
"start": "node dist/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mcp-server/module-sdk": "workspace:*",
|
||||||
|
"zod": "^3.22.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.10.5",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"ts-node": "^10.9.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
102
packages/modules/example-typescript/src/index.ts
Normal file
102
packages/modules/example-typescript/src/index.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { ModuleBase } from '@mcp-server/module-sdk';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Example tool: String manipulation
|
||||||
|
const StringManipulateSchema = z.object({
|
||||||
|
text: z.string(),
|
||||||
|
operation: z.enum(['uppercase', 'lowercase', 'reverse', 'base64encode', 'base64decode']),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Example tool: Math operations
|
||||||
|
const MathOperationSchema = z.object({
|
||||||
|
a: z.number(),
|
||||||
|
b: z.number(),
|
||||||
|
operation: z.enum(['add', 'subtract', 'multiply', 'divide']),
|
||||||
|
});
|
||||||
|
|
||||||
|
class ExampleModule extends ModuleBase {
|
||||||
|
protected async registerTools(): Promise<void> {
|
||||||
|
// String manipulation tool
|
||||||
|
this.addTool({
|
||||||
|
name: 'string_manipulate',
|
||||||
|
description: 'Perform string manipulation operations',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
text: { type: 'string' },
|
||||||
|
operation: { type: 'string', enum: ['uppercase', 'lowercase', 'reverse', 'base64encode', 'base64decode'] },
|
||||||
|
},
|
||||||
|
required: ['text', 'operation'],
|
||||||
|
},
|
||||||
|
handler: this.createToolHandler(StringManipulateSchema, async (params) => {
|
||||||
|
switch (params.operation) {
|
||||||
|
case 'uppercase':
|
||||||
|
return { result: params.text.toUpperCase() };
|
||||||
|
case 'lowercase':
|
||||||
|
return { result: params.text.toLowerCase() };
|
||||||
|
case 'reverse':
|
||||||
|
return { result: params.text.split('').reverse().join('') };
|
||||||
|
case 'base64encode':
|
||||||
|
return { result: Buffer.from(params.text).toString('base64') };
|
||||||
|
case 'base64decode':
|
||||||
|
return { result: Buffer.from(params.text, 'base64').toString('utf8') };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Math operations tool
|
||||||
|
this.addTool({
|
||||||
|
name: 'math_operation',
|
||||||
|
description: 'Perform basic math operations',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
a: { type: 'number' },
|
||||||
|
b: { type: 'number' },
|
||||||
|
operation: { type: 'string', enum: ['add', 'subtract', 'multiply', 'divide'] },
|
||||||
|
},
|
||||||
|
required: ['a', 'b', 'operation'],
|
||||||
|
},
|
||||||
|
handler: this.createToolHandler(MathOperationSchema, async (params) => {
|
||||||
|
switch (params.operation) {
|
||||||
|
case 'add':
|
||||||
|
return { result: params.a + params.b };
|
||||||
|
case 'subtract':
|
||||||
|
return { result: params.a - params.b };
|
||||||
|
case 'multiply':
|
||||||
|
return { result: params.a * params.b };
|
||||||
|
case 'divide':
|
||||||
|
if (params.b === 0) {
|
||||||
|
throw new Error('Division by zero');
|
||||||
|
}
|
||||||
|
return { result: params.a / params.b };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main entry point
|
||||||
|
if (require.main === module) {
|
||||||
|
const module = new ExampleModule({
|
||||||
|
name: 'example-typescript',
|
||||||
|
logLevel: 'info',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start module
|
||||||
|
module.start().catch((error) => {
|
||||||
|
console.error('Failed to start module:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
await module.stop();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
await module.stop();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
9
packages/modules/example-typescript/tsconfig.json
Normal file
9
packages/modules/example-typescript/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
23
packages/sdk/typescript/package.json
Normal file
23
packages/sdk/typescript/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "@mcp-server/module-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "TypeScript SDK for MCP Server modules",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"dev": "tsc --watch"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"nats": "^2.19.0",
|
||||||
|
"zod": "^3.22.4",
|
||||||
|
"pino": "^8.16.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.10.5",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@mcp-server/types": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
161
packages/sdk/typescript/src/ModuleBase.ts
Normal file
161
packages/sdk/typescript/src/ModuleBase.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { connect, NatsConnection, JSONCodec } from 'nats';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import pino from 'pino';
|
||||||
|
import { ToolRequest, ToolResponse, ToolDefinition } from './types';
|
||||||
|
|
||||||
|
export interface ModuleConfig {
|
||||||
|
name: string;
|
||||||
|
natsUrl?: string;
|
||||||
|
token?: string;
|
||||||
|
logLevel?: 'debug' | 'info' | 'warn' | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class ModuleBase {
|
||||||
|
protected connection?: NatsConnection;
|
||||||
|
protected jsonCodec = JSONCodec();
|
||||||
|
protected logger: pino.Logger;
|
||||||
|
protected tools = new Map<string, ToolDefinition>();
|
||||||
|
|
||||||
|
constructor(protected config: ModuleConfig) {
|
||||||
|
this.logger = pino({
|
||||||
|
level: config.logLevel || 'info',
|
||||||
|
name: config.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Connect to NATS
|
||||||
|
await this.connectNats();
|
||||||
|
|
||||||
|
// Register tools
|
||||||
|
await this.registerTools();
|
||||||
|
|
||||||
|
// Announce tools
|
||||||
|
await this.announceTools();
|
||||||
|
|
||||||
|
// Setup handlers
|
||||||
|
this.setupHandlers();
|
||||||
|
|
||||||
|
// Mark as ready
|
||||||
|
await this.markReady();
|
||||||
|
|
||||||
|
this.logger.info('Module started successfully');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error({ error }, 'Failed to start module');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (this.connection) {
|
||||||
|
await this.connection.drain();
|
||||||
|
await this.connection.close();
|
||||||
|
}
|
||||||
|
this.logger.info('Module stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract registerTools(): Promise<void>;
|
||||||
|
|
||||||
|
protected addTool(tool: ToolDefinition): void {
|
||||||
|
this.tools.set(tool.name, tool);
|
||||||
|
this.logger.info({ tool: tool.name }, 'Tool registered');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async connectNats(): Promise<void> {
|
||||||
|
const url = this.config.natsUrl || process.env.NATS_URL || 'nats://localhost:4222';
|
||||||
|
const token = this.config.token || process.env.MODULE_TOKEN;
|
||||||
|
|
||||||
|
this.connection = await connect({
|
||||||
|
servers: url,
|
||||||
|
name: this.config.name,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.info({ url }, 'Connected to NATS');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async announceTools(): Promise<void> {
|
||||||
|
const tools = Array.from(this.tools.values()).map(tool => ({
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
inputSchema: tool.inputSchema,
|
||||||
|
module: this.config.name,
|
||||||
|
permissions: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.connection!.publish(
|
||||||
|
'tools.discovery',
|
||||||
|
this.jsonCodec.encode({
|
||||||
|
module: this.config.name,
|
||||||
|
tools,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.info({ count: tools.length }, 'Tools announced');
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupHandlers(): void {
|
||||||
|
for (const [name, tool] of this.tools) {
|
||||||
|
const subject = `tools.${this.config.name}.${name}.execute`;
|
||||||
|
|
||||||
|
const sub = this.connection!.subscribe(subject);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
for await (const msg of sub) {
|
||||||
|
try {
|
||||||
|
const request = this.jsonCodec.decode(msg.data) as ToolRequest;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
this.logger.debug({ request }, 'Executing tool');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await tool.handler(request.params);
|
||||||
|
|
||||||
|
const response: ToolResponse = {
|
||||||
|
id: request.id,
|
||||||
|
status: 'success',
|
||||||
|
data: result,
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
|
||||||
|
msg.respond(this.jsonCodec.encode(response));
|
||||||
|
} catch (error) {
|
||||||
|
const response: ToolResponse = {
|
||||||
|
id: request.id,
|
||||||
|
status: 'error',
|
||||||
|
error: {
|
||||||
|
code: 'EXECUTION_ERROR',
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
details: error,
|
||||||
|
},
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
|
||||||
|
msg.respond(this.jsonCodec.encode(response));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error({ error }, 'Failed to process request');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})().catch(err => this.logger.error({ error: err }, 'Subscription error'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async markReady(): Promise<void> {
|
||||||
|
// Signal to parent process that module is ready
|
||||||
|
if (process.send) {
|
||||||
|
process.send({ type: 'ready' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected createToolHandler<T>(
|
||||||
|
schema: z.ZodSchema<T>,
|
||||||
|
handler: (params: T) => Promise<unknown>,
|
||||||
|
): (params: unknown) => Promise<unknown> {
|
||||||
|
return async (params: unknown) => {
|
||||||
|
const validated = schema.parse(params);
|
||||||
|
return handler(validated);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
2
packages/sdk/typescript/src/index.ts
Normal file
2
packages/sdk/typescript/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './ModuleBase';
|
||||||
|
export * from './types';
|
||||||
36
packages/sdk/typescript/src/types.ts
Normal file
36
packages/sdk/typescript/src/types.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ToolRequestSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
tool: z.string(),
|
||||||
|
method: z.enum(['execute', 'describe', 'validate']),
|
||||||
|
params: z.unknown(),
|
||||||
|
timeout: z.number(),
|
||||||
|
metadata: z.object({
|
||||||
|
user: z.string().optional(),
|
||||||
|
session: z.string().optional(),
|
||||||
|
timestamp: z.number(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ToolResponseSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
status: z.enum(['success', 'error']),
|
||||||
|
data: z.unknown().optional(),
|
||||||
|
error: z.object({
|
||||||
|
code: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
details: z.unknown().optional(),
|
||||||
|
}).optional(),
|
||||||
|
duration: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ToolRequest = z.infer<typeof ToolRequestSchema>;
|
||||||
|
export type ToolResponse = z.infer<typeof ToolResponseSchema>;
|
||||||
|
|
||||||
|
export interface ToolDefinition {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
inputSchema: Record<string, unknown>;
|
||||||
|
handler: (params: unknown) => Promise<unknown>;
|
||||||
|
}
|
||||||
9
packages/sdk/typescript/tsconfig.json
Normal file
9
packages/sdk/typescript/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
94
src/auth/generate-token.ts
Normal file
94
src/auth/generate-token.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
// Script pentru generarea token-urilor JWT
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
if (args.length < 1 || args.includes('--help') || args.includes('-h')) {
|
||||||
|
console.log(`
|
||||||
|
Usage: generate-token [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--id <user-id> User ID (default: admin)
|
||||||
|
--permissions <perms> Comma-separated permissions (default: all)
|
||||||
|
--expires <duration> Token expiration (e.g., 1h, 7d, 30d) (default: 30d)
|
||||||
|
--secret <secret> JWT secret (default: from config)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Generate admin token with all permissions
|
||||||
|
npm run generate-token
|
||||||
|
|
||||||
|
# Generate limited token
|
||||||
|
npm run generate-token -- --id user123 --permissions file:read,file:list --expires 1h
|
||||||
|
|
||||||
|
Available permissions:
|
||||||
|
- file:read
|
||||||
|
- file:write
|
||||||
|
- file:list
|
||||||
|
- system:exec
|
||||||
|
- network:http
|
||||||
|
`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse arguments
|
||||||
|
const getArg = (name: string, defaultValue: string): string => {
|
||||||
|
const index = args.indexOf(`--${name}`);
|
||||||
|
if (index === -1 || index + 1 >= args.length) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
return args[index + 1] || defaultValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const userId = getArg('id', 'admin');
|
||||||
|
const permissionsStr = getArg('permissions', 'file:read,file:write,file:list,system:exec,network:http');
|
||||||
|
const expires = getArg('expires', '30d');
|
||||||
|
const secret = getArg('secret', config.security.jwtSecret);
|
||||||
|
|
||||||
|
const permissions = permissionsStr.split(',').map(p => p.trim());
|
||||||
|
|
||||||
|
// Generate token
|
||||||
|
const payload = {
|
||||||
|
id: userId,
|
||||||
|
permissions,
|
||||||
|
iat: Math.floor(Date.now() / 1000),
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: jwt.SignOptions = {
|
||||||
|
expiresIn: expires as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = jwt.sign(payload, secret, options);
|
||||||
|
|
||||||
|
console.log('\n🔐 JWT Token Generated:');
|
||||||
|
console.log('='.repeat(80));
|
||||||
|
console.log(token);
|
||||||
|
console.log('='.repeat(80));
|
||||||
|
console.log('\nToken Details:');
|
||||||
|
console.log(` User ID: ${userId}`);
|
||||||
|
console.log(` Permissions: ${permissions.join(', ')}`);
|
||||||
|
console.log(` Expires: ${expires}`);
|
||||||
|
console.log('\n📋 Usage Examples:');
|
||||||
|
console.log('\nHTTP Header:');
|
||||||
|
console.log(` Authorization: Bearer ${token}`);
|
||||||
|
console.log('\ncURL:');
|
||||||
|
console.log(` curl -X POST https://mcp.runningwolf.com/ \\
|
||||||
|
-H "Authorization: Bearer ${token}" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'`);
|
||||||
|
console.log('\nJavaScript:');
|
||||||
|
console.log(` fetch('https://mcp.runningwolf.com/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ${token}',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'tools/list',
|
||||||
|
params: {}
|
||||||
|
})
|
||||||
|
})`);
|
||||||
|
console.log();
|
||||||
54
src/config.ts
Normal file
54
src/config.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const ConfigSchema = z.object({
|
||||||
|
mcp: z.object({
|
||||||
|
host: z.string().default('127.0.0.1'),
|
||||||
|
port: z.number().default(19017),
|
||||||
|
logLevel: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
||||||
|
}),
|
||||||
|
nats: z.object({
|
||||||
|
url: z.string().default('nats://localhost:4222'),
|
||||||
|
reconnectTimeWait: z.number().default(2000),
|
||||||
|
maxReconnectAttempts: z.number().default(10),
|
||||||
|
}),
|
||||||
|
security: z.object({
|
||||||
|
jwtSecret: z.string().min(32),
|
||||||
|
authEnabled: z.boolean().default(true),
|
||||||
|
}),
|
||||||
|
modules: z.object({
|
||||||
|
startupTimeout: z.number().default(5000),
|
||||||
|
healthCheckInterval: z.number().default(30000),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
type Config = z.infer<typeof ConfigSchema>;
|
||||||
|
|
||||||
|
function loadConfig(): Config {
|
||||||
|
const config = {
|
||||||
|
mcp: {
|
||||||
|
host: process.env.MCP_HOST || '127.0.0.1',
|
||||||
|
port: parseInt(process.env.MCP_PORT || '19017', 10),
|
||||||
|
logLevel: process.env.MCP_LOG_LEVEL || 'info',
|
||||||
|
},
|
||||||
|
nats: {
|
||||||
|
url: process.env.NATS_URL || 'nats://localhost:4222',
|
||||||
|
reconnectTimeWait: parseInt(process.env.NATS_RECONNECT_TIME_WAIT || '2000', 10),
|
||||||
|
maxReconnectAttempts: parseInt(process.env.NATS_MAX_RECONNECT_ATTEMPTS || '10', 10),
|
||||||
|
},
|
||||||
|
security: {
|
||||||
|
jwtSecret: process.env.JWT_SECRET || 'development-secret-change-in-production-minimum-32-chars',
|
||||||
|
authEnabled: process.env.AUTH_ENABLED !== 'false',
|
||||||
|
},
|
||||||
|
modules: {
|
||||||
|
startupTimeout: parseInt(process.env.MODULE_STARTUP_TIMEOUT || '5000', 10),
|
||||||
|
healthCheckInterval: parseInt(process.env.MODULE_HEALTH_CHECK_INTERVAL || '30000', 10),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return ConfigSchema.parse(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = loadConfig();
|
||||||
137
src/http-server.ts
Normal file
137
src/http-server.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import { config } from './config';
|
||||||
|
import { createLogger } from './utils/logger';
|
||||||
|
import { ToolRegistry } from './registry/ToolRegistry';
|
||||||
|
import { NatsClient } from './nats/NatsClient';
|
||||||
|
import { authMiddleware, AuthRequest } from './middleware/auth';
|
||||||
|
|
||||||
|
const logger = createLogger('HTTPServer');
|
||||||
|
|
||||||
|
async function startHTTPServer() {
|
||||||
|
const app = express();
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Initialize dependencies
|
||||||
|
const natsClient = new NatsClient();
|
||||||
|
await natsClient.connect();
|
||||||
|
|
||||||
|
const toolRegistry = new ToolRegistry(natsClient);
|
||||||
|
await toolRegistry.initialize();
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
app.get('/health', (_req, res) => {
|
||||||
|
res.send('healthy\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auth status endpoint
|
||||||
|
app.get('/auth/status', (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
authEnabled: config.security.authEnabled,
|
||||||
|
message: config.security.authEnabled
|
||||||
|
? 'Authentication is ENABLED. Use Bearer token in Authorization header.'
|
||||||
|
: 'Authentication is DISABLED.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate token endpoint (only in development)
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
app.post('/auth/token', (req, res) => {
|
||||||
|
const { userId = 'test-user', permissions } = req.body;
|
||||||
|
const { generateToken } = require('./middleware/auth');
|
||||||
|
|
||||||
|
const defaultPermissions = permissions || [
|
||||||
|
'file:read',
|
||||||
|
'file:write',
|
||||||
|
'system:exec',
|
||||||
|
'network:http'
|
||||||
|
];
|
||||||
|
|
||||||
|
const token = generateToken(userId, defaultPermissions);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
token,
|
||||||
|
userId,
|
||||||
|
permissions: defaultPermissions,
|
||||||
|
expiresIn: '24h',
|
||||||
|
usage: 'Add to Authorization header as: Bearer <token>'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCP JSON-RPC endpoint - protected by auth
|
||||||
|
app.post('/', authMiddleware, async (req: AuthRequest, res) => {
|
||||||
|
try {
|
||||||
|
const { method, params, id } = req.body;
|
||||||
|
|
||||||
|
logger.debug({ method, params, id }, 'Received JSON-RPC request');
|
||||||
|
|
||||||
|
if (method === 'tools/list') {
|
||||||
|
const tools = await toolRegistry.listTools();
|
||||||
|
res.json({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
result: {
|
||||||
|
tools: tools.map((tool) => ({
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
inputSchema: tool.inputSchema,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (method === 'tools/call') {
|
||||||
|
const result = await toolRegistry.executeTool(
|
||||||
|
params.name,
|
||||||
|
params.arguments,
|
||||||
|
);
|
||||||
|
res.json({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
result: {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify(result) }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(400).json({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
error: {
|
||||||
|
code: -32601,
|
||||||
|
message: 'Method not found',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Error processing request');
|
||||||
|
res.status(500).json({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: req.body.id,
|
||||||
|
error: {
|
||||||
|
code: -32603,
|
||||||
|
message: 'Internal error',
|
||||||
|
data: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(config.mcp.port, config.mcp.host, () => {
|
||||||
|
logger.info(
|
||||||
|
{ host: config.mcp.host, port: config.mcp.port },
|
||||||
|
'HTTP Server started',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
logger.info('Shutting down HTTP server');
|
||||||
|
await natsClient.disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startHTTPServer().catch((error) => {
|
||||||
|
logger.error({ error }, 'Failed to start HTTP server');
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
101
src/middleware/auth.ts
Normal file
101
src/middleware/auth.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import jwt, { Secret } from 'jsonwebtoken';
|
||||||
|
import { config } from '../config';
|
||||||
|
import { createLogger } from '../utils/logger';
|
||||||
|
|
||||||
|
const logger = createLogger('AuthMiddleware');
|
||||||
|
|
||||||
|
export interface AuthRequest extends Request {
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
permissions: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get JWT secret with proper typing
|
||||||
|
function getJwtSecret(): string {
|
||||||
|
return process.env.JWT_SECRET || 'development-secret-change-in-production-minimum-32-chars';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction): void {
|
||||||
|
// Skip auth if disabled
|
||||||
|
if (!config.security.authEnabled) {
|
||||||
|
req.user = {
|
||||||
|
id: 'anonymous',
|
||||||
|
permissions: ['file:read', 'file:write', 'system:exec', 'network:http'],
|
||||||
|
};
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
if (!authHeader) {
|
||||||
|
res.status(401).json({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: req.body.id || null,
|
||||||
|
error: {
|
||||||
|
code: -32001,
|
||||||
|
message: 'Authorization header required',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = authHeader.split(' ');
|
||||||
|
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
||||||
|
res.status(401).json({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: req.body.id || null,
|
||||||
|
error: {
|
||||||
|
code: -32002,
|
||||||
|
message: 'Invalid authorization header format. Use: Bearer <token>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = parts[1];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const secret = getJwtSecret();
|
||||||
|
const decoded = jwt.verify(token, secret as Secret) as any;
|
||||||
|
|
||||||
|
if (typeof decoded === 'object' && decoded !== null && 'id' in decoded) {
|
||||||
|
req.user = {
|
||||||
|
id: (decoded as any).id,
|
||||||
|
permissions: (decoded as any).permissions || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.debug({ userId: req.user.id }, 'User authenticated');
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
throw new Error('Invalid token payload');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error }, 'JWT verification failed');
|
||||||
|
res.status(401).json({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: req.body.id || null,
|
||||||
|
error: {
|
||||||
|
code: -32003,
|
||||||
|
message: 'Invalid or expired token',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to generate tokens
|
||||||
|
export function generateToken(userId: string, permissions: string[] = []): string {
|
||||||
|
const secret = getJwtSecret();
|
||||||
|
|
||||||
|
return jwt.sign(
|
||||||
|
{
|
||||||
|
id: userId,
|
||||||
|
permissions,
|
||||||
|
},
|
||||||
|
secret as Secret,
|
||||||
|
{
|
||||||
|
expiresIn: '24h',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/nats/NatsClient.ts
Normal file
91
src/nats/NatsClient.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { connect, NatsConnection, JSONCodec } from 'nats';
|
||||||
|
import { config } from '../config';
|
||||||
|
import { createLogger } from '../utils/logger';
|
||||||
|
import { ToolRequest, ToolResponse } from '../types';
|
||||||
|
|
||||||
|
const logger = createLogger('NatsClient');
|
||||||
|
|
||||||
|
export class NatsClient {
|
||||||
|
private connection?: NatsConnection;
|
||||||
|
private jsonCodec = JSONCodec();
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.connection = await connect({
|
||||||
|
servers: config.nats.url,
|
||||||
|
reconnectTimeWait: config.nats.reconnectTimeWait,
|
||||||
|
maxReconnectAttempts: config.nats.maxReconnectAttempts,
|
||||||
|
name: 'mcp-core',
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info({ url: config.nats.url }, 'Connected to NATS');
|
||||||
|
|
||||||
|
// Setup connection event handlers
|
||||||
|
(async () => {
|
||||||
|
for await (const status of this.connection!.status()) {
|
||||||
|
logger.info({ status: status.type, data: status.data }, 'NATS connection status');
|
||||||
|
}
|
||||||
|
})().catch((err) => logger.error({ error: err }, 'NATS status error'));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Failed to connect to NATS');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
if (this.connection) {
|
||||||
|
await this.connection.drain();
|
||||||
|
await this.connection.close();
|
||||||
|
logger.info('Disconnected from NATS');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(subject: string, data: ToolRequest, timeout = 30000): Promise<ToolResponse> {
|
||||||
|
if (!this.connection) {
|
||||||
|
throw new Error('NATS not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const msg = await this.connection.request(
|
||||||
|
subject,
|
||||||
|
this.jsonCodec.encode(data),
|
||||||
|
{ timeout },
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.jsonCodec.decode(msg.data) as ToolResponse;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error, subject }, 'NATS request failed');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async publish(subject: string, data: unknown): Promise<void> {
|
||||||
|
if (!this.connection) {
|
||||||
|
throw new Error('NATS not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connection.publish(subject, this.jsonCodec.encode(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(subject: string, callback: (data: unknown) => void): void {
|
||||||
|
if (!this.connection) {
|
||||||
|
throw new Error('NATS not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sub = this.connection.subscribe(subject);
|
||||||
|
(async () => {
|
||||||
|
for await (const msg of sub) {
|
||||||
|
try {
|
||||||
|
const data = this.jsonCodec.decode(msg.data);
|
||||||
|
callback(data);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error, subject }, 'Error processing message');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})().catch((err) => logger.error({ error: err }, 'Subscription error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
get isConnected(): boolean {
|
||||||
|
return this.connection?.isClosed() === false;
|
||||||
|
}
|
||||||
|
}
|
||||||
147
src/registry/ModuleManager.ts
Normal file
147
src/registry/ModuleManager.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { spawn, ChildProcess } from 'child_process';
|
||||||
|
import { createLogger } from '../utils/logger';
|
||||||
|
import { NatsClient } from '../nats/NatsClient';
|
||||||
|
import { ToolRegistry } from './ToolRegistry';
|
||||||
|
import { ModuleConfig } from '../types';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
const logger = createLogger('ModuleManager');
|
||||||
|
|
||||||
|
export class ModuleManager {
|
||||||
|
private modules = new Map<string, ModuleInfo>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private natsClient: NatsClient,
|
||||||
|
private toolRegistry: ToolRegistry,
|
||||||
|
) {
|
||||||
|
// These will be used when implementing module communication
|
||||||
|
this.natsClient;
|
||||||
|
this.toolRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
async startAll(): Promise<void> {
|
||||||
|
// Load module configurations
|
||||||
|
const modulesConfig = await this.loadModuleConfigs();
|
||||||
|
|
||||||
|
for (const moduleConfig of modulesConfig) {
|
||||||
|
try {
|
||||||
|
await this.startModule(moduleConfig);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error, module: moduleConfig.name }, 'Failed to start module');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopAll(): Promise<void> {
|
||||||
|
for (const [name, info] of this.modules) {
|
||||||
|
try {
|
||||||
|
await this.stopModule(name, info);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error, module: name }, 'Failed to stop module');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadModuleConfigs(): Promise<ModuleConfig[]> {
|
||||||
|
try {
|
||||||
|
// For now, return empty array - modules will be added later
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('No modules configuration found');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startModule(moduleConfig: ModuleConfig): Promise<void> {
|
||||||
|
logger.info({ module: moduleConfig.name }, 'Starting module');
|
||||||
|
|
||||||
|
const token = this.generateModuleToken(moduleConfig);
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
MODULE_TOKEN: token,
|
||||||
|
MODULE_NAME: moduleConfig.name,
|
||||||
|
NATS_URL: config.nats.url,
|
||||||
|
};
|
||||||
|
|
||||||
|
const proc = spawn(moduleConfig.executable, [], {
|
||||||
|
env,
|
||||||
|
stdio: ['inherit', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const info: ModuleInfo = {
|
||||||
|
config: moduleConfig,
|
||||||
|
process: proc,
|
||||||
|
status: 'starting',
|
||||||
|
startTime: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.modules.set(moduleConfig.name, info);
|
||||||
|
|
||||||
|
// Setup process handlers
|
||||||
|
proc.stdout?.on('data', (data) => {
|
||||||
|
logger.debug({ module: moduleConfig.name, output: data.toString() }, 'Module output');
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.stderr?.on('data', (data) => {
|
||||||
|
logger.error({ module: moduleConfig.name, error: data.toString() }, 'Module error');
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('exit', (code) => {
|
||||||
|
logger.warn({ module: moduleConfig.name, code }, 'Module exited');
|
||||||
|
info.status = 'stopped';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for module to be ready
|
||||||
|
await this.waitForModuleReady(moduleConfig.name, moduleConfig.startupTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async stopModule(name: string, info: ModuleInfo): Promise<void> {
|
||||||
|
|
||||||
|
logger.info({ module: name }, 'Stopping module');
|
||||||
|
|
||||||
|
info.process.kill('SIGTERM');
|
||||||
|
|
||||||
|
// Wait for graceful shutdown
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
info.process.kill('SIGKILL');
|
||||||
|
resolve();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
info.process.once('exit', () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.modules.delete(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitForModuleReady(name: string, timeout = 5000): Promise<void> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startTime < timeout) {
|
||||||
|
const info = this.modules.get(name);
|
||||||
|
if (info?.status === 'ready') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Module ${name} failed to start within timeout`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateModuleToken(moduleConfig: ModuleConfig): string {
|
||||||
|
// For now, return a simple token - will implement JWT later
|
||||||
|
return `module-token-${moduleConfig.name}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModuleInfo {
|
||||||
|
config: ModuleConfig;
|
||||||
|
process: ChildProcess;
|
||||||
|
status: 'starting' | 'ready' | 'stopped';
|
||||||
|
startTime: number;
|
||||||
|
}
|
||||||
107
src/registry/ToolRegistry.ts
Normal file
107
src/registry/ToolRegistry.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { createLogger } from '../utils/logger';
|
||||||
|
import { NatsClient } from '../nats/NatsClient';
|
||||||
|
import { ToolDefinition, ToolRequest } from '../types';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { createBuiltinTools, ToolHandler, ToolContext } from '../tools';
|
||||||
|
|
||||||
|
const logger = createLogger('ToolRegistry');
|
||||||
|
|
||||||
|
export class ToolRegistry {
|
||||||
|
private tools = new Map<string, ToolDefinition>();
|
||||||
|
private builtinHandlers = createBuiltinTools();
|
||||||
|
|
||||||
|
constructor(private natsClient: NatsClient) {
|
||||||
|
this.registerBuiltinTools();
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
this.setupDiscovery();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupDiscovery(): void {
|
||||||
|
// Listen for tool announcements
|
||||||
|
this.natsClient.subscribe('tools.discovery', (data) => {
|
||||||
|
const announcement = data as {
|
||||||
|
module: string;
|
||||||
|
tools: ToolDefinition[];
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const tool of announcement.tools) {
|
||||||
|
this.registerTool(tool);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
registerTool(tool: ToolDefinition): void {
|
||||||
|
this.tools.set(tool.name, tool);
|
||||||
|
logger.info({ tool: tool.name, module: tool.module }, 'Tool registered');
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTools(): Promise<ToolDefinition[]> {
|
||||||
|
return Array.from(this.tools.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
async executeTool(toolName: string, params: unknown): Promise<unknown> {
|
||||||
|
// Check if it's a built-in tool
|
||||||
|
const builtinHandler = this.builtinHandlers.get(toolName);
|
||||||
|
if (builtinHandler) {
|
||||||
|
return this.executeBuiltinTool(builtinHandler, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, execute via NATS
|
||||||
|
const tool = this.tools.get(toolName);
|
||||||
|
if (!tool) {
|
||||||
|
throw new Error(`Tool not found: ${toolName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: ToolRequest = {
|
||||||
|
id: randomUUID(),
|
||||||
|
tool: toolName,
|
||||||
|
method: 'execute',
|
||||||
|
params,
|
||||||
|
timeout: 30000,
|
||||||
|
metadata: {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const subject = `tools.${tool.module}.${toolName}.execute`;
|
||||||
|
logger.debug({ subject, request }, 'Executing tool');
|
||||||
|
|
||||||
|
const response = await this.natsClient.request(subject, request);
|
||||||
|
|
||||||
|
if (response.status === 'error' && response.error) {
|
||||||
|
throw new Error(response.error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeBuiltinTool(handler: ToolHandler, params: unknown): Promise<unknown> {
|
||||||
|
const context: ToolContext = {
|
||||||
|
requestId: randomUUID(),
|
||||||
|
permissions: ['file:read', 'file:write', 'system:exec', 'network:http'], // TODO: get from auth
|
||||||
|
};
|
||||||
|
|
||||||
|
return handler.execute(params, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTool(name: string): ToolDefinition | undefined {
|
||||||
|
return this.tools.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerBuiltinTools(): void {
|
||||||
|
for (const [name, handler] of this.builtinHandlers) {
|
||||||
|
const tool: ToolDefinition = {
|
||||||
|
name: handler.name,
|
||||||
|
description: handler.description,
|
||||||
|
inputSchema: handler.schema,
|
||||||
|
module: 'builtin',
|
||||||
|
permissions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
this.tools.set(name, tool);
|
||||||
|
logger.info({ tool: name }, 'Registered built-in tool');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
180
src/server.ts
Normal file
180
src/server.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { config } from './config';
|
||||||
|
import { createLogger } from './utils/logger';
|
||||||
|
import { ToolRegistry } from './registry/ToolRegistry';
|
||||||
|
import { NatsClient } from './nats/NatsClient';
|
||||||
|
import { ModuleManager } from './registry/ModuleManager';
|
||||||
|
import { HttpServerTransport } from './transport/HttpServerTransport';
|
||||||
|
|
||||||
|
const logger = createLogger('MCPServer');
|
||||||
|
|
||||||
|
export class MCPServer {
|
||||||
|
private server: Server;
|
||||||
|
private httpServer?: Server;
|
||||||
|
private natsClient: NatsClient;
|
||||||
|
private toolRegistry: ToolRegistry;
|
||||||
|
private moduleManager: ModuleManager;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.server = new Server(
|
||||||
|
{
|
||||||
|
name: 'lupul-augmentat',
|
||||||
|
version: '0.1.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
tools: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.natsClient = new NatsClient();
|
||||||
|
this.toolRegistry = new ToolRegistry(this.natsClient);
|
||||||
|
this.moduleManager = new ModuleManager(this.natsClient, this.toolRegistry);
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
try {
|
||||||
|
logger.info({ config: config.mcp }, 'Starting MCP Server');
|
||||||
|
|
||||||
|
// Connect to NATS
|
||||||
|
await this.natsClient.connect();
|
||||||
|
logger.info('Connected to NATS');
|
||||||
|
|
||||||
|
// Initialize tool registry after NATS connection
|
||||||
|
await this.toolRegistry.initialize();
|
||||||
|
logger.info('Tool registry initialized');
|
||||||
|
|
||||||
|
// Start module manager
|
||||||
|
await this.moduleManager.startAll();
|
||||||
|
logger.info('Modules started');
|
||||||
|
|
||||||
|
// Setup MCP handlers
|
||||||
|
this.setupHandlers();
|
||||||
|
|
||||||
|
// Start both transports
|
||||||
|
await this.startTransports();
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ host: config.mcp.host, port: config.mcp.port },
|
||||||
|
'MCP Server started successfully',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Setup graceful shutdown
|
||||||
|
this.setupGracefulShutdown();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Failed to start MCP Server');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupHandlers(server?: Server): void {
|
||||||
|
const targetServer = server || this.server;
|
||||||
|
|
||||||
|
// List available tools
|
||||||
|
const ListToolsSchema = z.object({
|
||||||
|
method: z.literal('tools/list'),
|
||||||
|
});
|
||||||
|
|
||||||
|
targetServer.setRequestHandler(ListToolsSchema, async () => {
|
||||||
|
const tools = await this.toolRegistry.listTools();
|
||||||
|
return {
|
||||||
|
tools: tools.map((tool) => ({
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
inputSchema: tool.inputSchema,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Execute tool
|
||||||
|
const CallToolSchema = z.object({
|
||||||
|
method: z.literal('tools/call'),
|
||||||
|
params: z.object({
|
||||||
|
name: z.string(),
|
||||||
|
arguments: z.unknown().optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
targetServer.setRequestHandler(CallToolSchema, async (request) => {
|
||||||
|
try {
|
||||||
|
const result = await this.toolRegistry.executeTool(
|
||||||
|
request.params.name,
|
||||||
|
request.params.arguments,
|
||||||
|
);
|
||||||
|
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Error: ${errorMessage}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startTransports(): Promise<void> {
|
||||||
|
// Check if running in stdio mode (default for Claude Desktop)
|
||||||
|
const isStdio = !process.env.MCP_TRANSPORT || process.env.MCP_TRANSPORT === 'stdio';
|
||||||
|
|
||||||
|
if (isStdio) {
|
||||||
|
// Start stdio transport
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
await this.server.connect(transport);
|
||||||
|
logger.info('Started with stdio transport');
|
||||||
|
} else {
|
||||||
|
// Start HTTP transport
|
||||||
|
const httpTransport = new HttpServerTransport(config.mcp.host, config.mcp.port);
|
||||||
|
await this.server.connect(httpTransport);
|
||||||
|
logger.info({ host: config.mcp.host, port: config.mcp.port }, 'Started with HTTP transport');
|
||||||
|
|
||||||
|
// Also create HTTP server instance for non-MCP endpoints
|
||||||
|
this.httpServer = new Server(
|
||||||
|
{
|
||||||
|
name: 'lupul-augmentat-http',
|
||||||
|
version: '0.1.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
tools: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Setup handlers for HTTP server
|
||||||
|
this.setupHandlers(this.httpServer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupGracefulShutdown(): void {
|
||||||
|
const shutdown = async (signal: string): Promise<void> => {
|
||||||
|
logger.info({ signal }, 'Shutting down gracefully');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.moduleManager.stopAll();
|
||||||
|
await this.natsClient.disconnect();
|
||||||
|
await this.server.close();
|
||||||
|
if (this.httpServer) {
|
||||||
|
await this.httpServer.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Shutdown complete');
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Error during shutdown');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => void shutdown('SIGINT'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main entry point
|
||||||
|
if (require.main === module) {
|
||||||
|
const server = new MCPServer();
|
||||||
|
void server.start();
|
||||||
|
}
|
||||||
121
src/tools/base/ToolHandler.ts
Normal file
121
src/tools/base/ToolHandler.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { z, ZodSchema } from 'zod';
|
||||||
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||||
|
import { createLogger } from '../../utils/logger';
|
||||||
|
|
||||||
|
export interface ToolHandlerOptions {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
version?: string;
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolContext {
|
||||||
|
requestId: string;
|
||||||
|
userId?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class ToolHandler<TInput = unknown, TOutput = unknown> {
|
||||||
|
protected logger;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected options: ToolHandlerOptions,
|
||||||
|
protected inputSchema: ZodSchema<TInput>,
|
||||||
|
protected outputSchema?: ZodSchema<TOutput>,
|
||||||
|
) {
|
||||||
|
this.logger = createLogger(`Tool:${options.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
get name(): string {
|
||||||
|
return this.options.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
get description(): string {
|
||||||
|
return this.options.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
get schema(): Record<string, unknown> {
|
||||||
|
return zodToJsonSchema(this.inputSchema) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(input: unknown, context: ToolContext): Promise<TOutput> {
|
||||||
|
try {
|
||||||
|
// Lifecycle: validate
|
||||||
|
const validatedInput = await this.validate(input);
|
||||||
|
|
||||||
|
// Lifecycle: checkPermissions
|
||||||
|
await this.checkPermissions(context);
|
||||||
|
|
||||||
|
// Lifecycle: beforeExecute
|
||||||
|
await this.beforeExecute(validatedInput, context);
|
||||||
|
|
||||||
|
// Lifecycle: handle with timeout
|
||||||
|
const timeout = this.options.timeout || 30000;
|
||||||
|
const result = await this.withTimeout(
|
||||||
|
this.handle(validatedInput, context),
|
||||||
|
timeout,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Lifecycle: afterExecute
|
||||||
|
const finalResult = await this.afterExecute(result, context);
|
||||||
|
|
||||||
|
// Validate output if schema provided
|
||||||
|
if (this.outputSchema) {
|
||||||
|
return this.outputSchema.parse(finalResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalResult as TOutput;
|
||||||
|
} catch (error) {
|
||||||
|
// Lifecycle: onError
|
||||||
|
await this.onError(error as Error, context);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
// Lifecycle: cleanup
|
||||||
|
await this.cleanup(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async validate(input: unknown): Promise<TInput> {
|
||||||
|
try {
|
||||||
|
return this.inputSchema.parse(input);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
throw new Error(`Validation error: ${error.errors.map(e => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async checkPermissions(_context: ToolContext): Promise<void> {
|
||||||
|
// Override in subclass if permission checking needed
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async beforeExecute(_input: TInput, _context: ToolContext): Promise<void> {
|
||||||
|
// Override in subclass for pre-execution logic
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract handle(input: TInput, context: ToolContext): Promise<TOutput>;
|
||||||
|
|
||||||
|
protected async afterExecute(result: TOutput, _context: ToolContext): Promise<TOutput> {
|
||||||
|
// Override in subclass for post-execution logic
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async onError(error: Error, context: ToolContext): Promise<void> {
|
||||||
|
this.logger.error({ error, context }, 'Tool execution failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async cleanup(_context: ToolContext): Promise<void> {
|
||||||
|
// Override in subclass for cleanup logic
|
||||||
|
}
|
||||||
|
|
||||||
|
private async withTimeout<T>(promise: Promise<T>, timeout: number): Promise<T> {
|
||||||
|
return Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise<T>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error(`Tool execution timed out after ${timeout}ms`)), timeout),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
141
src/tools/builtin/FileListTool.ts
Normal file
141
src/tools/builtin/FileListTool.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
|
import { ToolHandler, ToolContext } from '../base/ToolHandler';
|
||||||
|
|
||||||
|
const FileListInputSchema = z.object({
|
||||||
|
path: z.string().min(1, 'Path is required'),
|
||||||
|
recursive: z.boolean().default(false).optional(),
|
||||||
|
includeHidden: z.boolean().default(false).optional(),
|
||||||
|
pattern: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const FileEntrySchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
path: z.string(),
|
||||||
|
type: z.enum(['file', 'directory', 'symlink', 'other']),
|
||||||
|
size: z.number(),
|
||||||
|
modified: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const FileListOutputSchema = z.object({
|
||||||
|
path: z.string(),
|
||||||
|
entries: z.array(FileEntrySchema),
|
||||||
|
totalSize: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FileListInput = z.infer<typeof FileListInputSchema>;
|
||||||
|
type FileListOutput = z.infer<typeof FileListOutputSchema>;
|
||||||
|
|
||||||
|
export class FileListTool extends ToolHandler<FileListInput, FileListOutput> {
|
||||||
|
constructor() {
|
||||||
|
super(
|
||||||
|
{
|
||||||
|
name: 'file_list',
|
||||||
|
description: 'List files in a directory',
|
||||||
|
timeout: 10000,
|
||||||
|
},
|
||||||
|
FileListInputSchema,
|
||||||
|
FileListOutputSchema,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async checkPermissions(context: ToolContext): Promise<void> {
|
||||||
|
if (!context.permissions.includes('file:read')) {
|
||||||
|
throw new Error('Permission denied: file:read required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async handle(input: FileListInput, _context: ToolContext): Promise<FileListOutput> {
|
||||||
|
const dirPath = path.resolve(input.path);
|
||||||
|
|
||||||
|
// Security: prevent directory traversal by checking if resolved path is within allowed directories
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const tmpDir = os.tmpdir();
|
||||||
|
const allowedPaths = [cwd, tmpDir];
|
||||||
|
|
||||||
|
if (!allowedPaths.some(allowed => dirPath.startsWith(allowed))) {
|
||||||
|
throw new Error('Invalid path: directory traversal not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = await fs.stat(dirPath);
|
||||||
|
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
throw new Error('Path is not a directory');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await this.listDirectory(dirPath, input);
|
||||||
|
const totalSize = entries.reduce((sum, entry) => sum + entry.size, 0);
|
||||||
|
|
||||||
|
this.logger.info({ path: dirPath, count: entries.length }, 'Directory listed successfully');
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: dirPath,
|
||||||
|
entries,
|
||||||
|
totalSize,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
throw new Error(`Directory not found: ${input.path}`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listDirectory(dirPath: string, options: FileListInput): Promise<Array<z.infer<typeof FileEntrySchema>>> {
|
||||||
|
const entries: Array<z.infer<typeof FileEntrySchema>> = [];
|
||||||
|
const items = await fs.readdir(dirPath);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
// Skip hidden files if not requested
|
||||||
|
if (!options.includeHidden && item.startsWith('.')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply pattern filter if provided
|
||||||
|
if (options.pattern && !this.matchPattern(item, options.pattern)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemPath = path.join(dirPath, item);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = await fs.stat(itemPath);
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
name: item,
|
||||||
|
path: itemPath,
|
||||||
|
type: this.getFileType(stats),
|
||||||
|
size: stats.size,
|
||||||
|
modified: stats.mtime.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recursive listing
|
||||||
|
if (options.recursive && stats.isDirectory()) {
|
||||||
|
const subEntries = await this.listDirectory(itemPath, options);
|
||||||
|
entries.push(...subEntries);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Skip items we can't access
|
||||||
|
this.logger.debug({ path: itemPath, error }, 'Skipping inaccessible item');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFileType(stats: any): 'file' | 'directory' | 'symlink' | 'other' {
|
||||||
|
if (stats.isFile()) return 'file';
|
||||||
|
if (stats.isDirectory()) return 'directory';
|
||||||
|
if (stats.isSymbolicLink()) return 'symlink';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
private matchPattern(name: string, pattern: string): boolean {
|
||||||
|
// Simple glob pattern matching (just * for now)
|
||||||
|
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
|
||||||
|
return regex.test(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/tools/builtin/FileReadTool.ts
Normal file
80
src/tools/builtin/FileReadTool.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
|
import { ToolHandler, ToolContext } from '../base/ToolHandler';
|
||||||
|
|
||||||
|
const FileReadInputSchema = z.object({
|
||||||
|
path: z.string().min(1, 'Path is required'),
|
||||||
|
encoding: z.enum(['utf8', 'binary']).default('utf8').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const FileReadOutputSchema = z.object({
|
||||||
|
content: z.string(),
|
||||||
|
size: z.number(),
|
||||||
|
path: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FileReadInput = z.infer<typeof FileReadInputSchema>;
|
||||||
|
type FileReadOutput = z.infer<typeof FileReadOutputSchema>;
|
||||||
|
|
||||||
|
export class FileReadTool extends ToolHandler<FileReadInput, FileReadOutput> {
|
||||||
|
constructor() {
|
||||||
|
super(
|
||||||
|
{
|
||||||
|
name: 'file_read',
|
||||||
|
description: 'Read contents of a file',
|
||||||
|
timeout: 5000,
|
||||||
|
},
|
||||||
|
FileReadInputSchema,
|
||||||
|
FileReadOutputSchema,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async checkPermissions(context: ToolContext): Promise<void> {
|
||||||
|
if (!context.permissions.includes('file:read')) {
|
||||||
|
throw new Error('Permission denied: file:read required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async handle(input: FileReadInput, _context: ToolContext): Promise<FileReadOutput> {
|
||||||
|
// Get absolute path
|
||||||
|
const filePath = path.resolve(input.path);
|
||||||
|
|
||||||
|
// Security: prevent directory traversal by checking if resolved path is within allowed directories
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const tmpDir = os.tmpdir();
|
||||||
|
const allowedPaths = [cwd, tmpDir];
|
||||||
|
|
||||||
|
if (!allowedPaths.some(allowed => filePath.startsWith(allowed))) {
|
||||||
|
throw new Error('Invalid path: directory traversal not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = await fs.stat(filePath);
|
||||||
|
|
||||||
|
if (!stats.isFile()) {
|
||||||
|
throw new Error('Path is not a file');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.size > 10 * 1024 * 1024) { // 10MB limit
|
||||||
|
throw new Error('File too large (max 10MB)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await fs.readFile(filePath, input.encoding || 'utf8');
|
||||||
|
|
||||||
|
this.logger.info({ path: filePath, size: stats.size }, 'File read successfully');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: content.toString(),
|
||||||
|
size: stats.size,
|
||||||
|
path: filePath,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
throw new Error(`File not found: ${input.path}`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
98
src/tools/builtin/FileWriteTool.ts
Normal file
98
src/tools/builtin/FileWriteTool.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
|
import { ToolHandler, ToolContext } from '../base/ToolHandler';
|
||||||
|
|
||||||
|
const FileWriteInputSchema = z.object({
|
||||||
|
path: z.string().min(1, 'Path is required'),
|
||||||
|
content: z.string(),
|
||||||
|
encoding: z.enum(['utf8', 'binary', 'base64']).default('utf8').optional(),
|
||||||
|
mode: z.enum(['overwrite', 'append']).default('overwrite').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const FileWriteOutputSchema = z.object({
|
||||||
|
path: z.string(),
|
||||||
|
size: z.number(),
|
||||||
|
mode: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FileWriteInput = z.infer<typeof FileWriteInputSchema>;
|
||||||
|
type FileWriteOutput = z.infer<typeof FileWriteOutputSchema>;
|
||||||
|
|
||||||
|
export class FileWriteTool extends ToolHandler<FileWriteInput, FileWriteOutput> {
|
||||||
|
constructor() {
|
||||||
|
super(
|
||||||
|
{
|
||||||
|
name: 'file_write',
|
||||||
|
description: 'Write content to a file',
|
||||||
|
timeout: 5000,
|
||||||
|
},
|
||||||
|
FileWriteInputSchema,
|
||||||
|
FileWriteOutputSchema,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async checkPermissions(context: ToolContext): Promise<void> {
|
||||||
|
if (!context.permissions.includes('file:write')) {
|
||||||
|
throw new Error('Permission denied: file:write required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async handle(input: FileWriteInput, _context: ToolContext): Promise<FileWriteOutput> {
|
||||||
|
const filePath = path.resolve(input.path);
|
||||||
|
|
||||||
|
// Security: prevent directory traversal by checking if resolved path is within allowed directories
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const tmpDir = os.tmpdir();
|
||||||
|
const allowedPaths = [cwd, tmpDir];
|
||||||
|
|
||||||
|
if (!allowedPaths.some(allowed => filePath.startsWith(allowed))) {
|
||||||
|
throw new Error('Invalid path: directory traversal not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: prevent writing to system directories
|
||||||
|
const restrictedPaths = ['/etc', '/sys', '/proc', '/dev'];
|
||||||
|
if (restrictedPaths.some(restricted => filePath.startsWith(restricted))) {
|
||||||
|
throw new Error('Cannot write to system directories');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ensure directory exists
|
||||||
|
const dir = path.dirname(filePath);
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
|
||||||
|
// Prepare content based on encoding
|
||||||
|
let contentToWrite: string | Buffer = input.content;
|
||||||
|
let writeEncoding: BufferEncoding | undefined = (input.encoding || 'utf8') as BufferEncoding;
|
||||||
|
|
||||||
|
if (input.encoding === 'base64') {
|
||||||
|
contentToWrite = Buffer.from(input.content, 'base64');
|
||||||
|
writeEncoding = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write file
|
||||||
|
if (input.mode === 'append') {
|
||||||
|
await fs.appendFile(filePath, contentToWrite, writeEncoding);
|
||||||
|
} else {
|
||||||
|
await fs.writeFile(filePath, contentToWrite, writeEncoding);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file size
|
||||||
|
const stats = await fs.stat(filePath);
|
||||||
|
|
||||||
|
this.logger.info({ path: filePath, size: stats.size, mode: input.mode }, 'File written successfully');
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: filePath,
|
||||||
|
size: stats.size,
|
||||||
|
mode: input.mode || 'overwrite',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'EACCES') {
|
||||||
|
throw new Error(`Permission denied: ${input.path}`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
173
src/tools/builtin/HttpRequestTool.ts
Normal file
173
src/tools/builtin/HttpRequestTool.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { ToolHandler, ToolContext } from '../base/ToolHandler';
|
||||||
|
|
||||||
|
const HttpRequestInputSchema = z.object({
|
||||||
|
url: z.string().url('Invalid URL'),
|
||||||
|
method: z.enum(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']).default('GET').optional(),
|
||||||
|
headers: z.record(z.string()).optional(),
|
||||||
|
body: z.union([z.string(), z.record(z.any())]).optional(),
|
||||||
|
timeout: z.number().min(100).max(60000).default(30000).optional(),
|
||||||
|
followRedirects: z.boolean().default(true).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const HttpResponseSchema = z.object({
|
||||||
|
status: z.number(),
|
||||||
|
statusText: z.string(),
|
||||||
|
headers: z.record(z.string()),
|
||||||
|
body: z.any(),
|
||||||
|
duration: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type HttpRequestInput = z.infer<typeof HttpRequestInputSchema>;
|
||||||
|
type HttpResponse = z.infer<typeof HttpResponseSchema>;
|
||||||
|
|
||||||
|
export class HttpRequestTool extends ToolHandler<HttpRequestInput, HttpResponse> {
|
||||||
|
constructor() {
|
||||||
|
super(
|
||||||
|
{
|
||||||
|
name: 'http_request',
|
||||||
|
description: 'Make HTTP requests',
|
||||||
|
timeout: 60000,
|
||||||
|
},
|
||||||
|
HttpRequestInputSchema as any, // Type mismatch due to default values
|
||||||
|
HttpResponseSchema,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async checkPermissions(context: ToolContext): Promise<void> {
|
||||||
|
if (!context.permissions.includes('network:http')) {
|
||||||
|
throw new Error('Permission denied: network:http required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async validate(input: unknown): Promise<HttpRequestInput> {
|
||||||
|
const validated = await super.validate(input);
|
||||||
|
|
||||||
|
// Security: prevent requests to internal IPs
|
||||||
|
const url = new URL(validated.url);
|
||||||
|
if (this.isInternalUrl(url)) {
|
||||||
|
throw new Error('Requests to internal networks are not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return validated;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async handle(input: HttpRequestInput, _context: ToolContext): Promise<HttpResponse> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
// Setup timeout
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), input.timeout || 30000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const options: RequestInit = {
|
||||||
|
method: input.method || 'GET',
|
||||||
|
headers: this.prepareHeaders(input.headers),
|
||||||
|
signal: controller.signal,
|
||||||
|
redirect: input.followRedirects ? 'follow' : 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add body if needed
|
||||||
|
if (input.body && ['POST', 'PUT', 'PATCH'].includes(input.method || 'GET')) {
|
||||||
|
if (typeof input.body === 'object') {
|
||||||
|
options.body = JSON.stringify(input.body);
|
||||||
|
options.headers = {
|
||||||
|
...options.headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
options.body = input.body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(input.url, options);
|
||||||
|
|
||||||
|
// Parse response
|
||||||
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
let body: any;
|
||||||
|
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
body = await response.json();
|
||||||
|
} else if (contentType.includes('text/')) {
|
||||||
|
body = await response.text();
|
||||||
|
} else {
|
||||||
|
// For binary data, return base64
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
body = Buffer.from(buffer).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
this.logger.info({
|
||||||
|
url: input.url,
|
||||||
|
method: input.method,
|
||||||
|
status: response.status,
|
||||||
|
duration
|
||||||
|
}, 'HTTP request completed');
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: this.headersToObject(response.headers),
|
||||||
|
body,
|
||||||
|
duration,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
throw new Error(`Request timeout after ${input.timeout}ms`);
|
||||||
|
}
|
||||||
|
throw new Error(`HTTP request failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private prepareHeaders(headers?: Record<string, string>): Record<string, string> {
|
||||||
|
const defaultHeaders = {
|
||||||
|
'User-Agent': 'MCP-Server/1.0',
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...defaultHeaders,
|
||||||
|
...headers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private headersToObject(headers: Headers): Record<string, string> {
|
||||||
|
const obj: Record<string, string> = {};
|
||||||
|
headers.forEach((value, key) => {
|
||||||
|
obj[key] = value;
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isInternalUrl(url: URL): boolean {
|
||||||
|
const hostname = url.hostname;
|
||||||
|
|
||||||
|
// Check for localhost and local IPs
|
||||||
|
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for private IP ranges
|
||||||
|
const parts = hostname.split('.');
|
||||||
|
if (parts.length === 4) {
|
||||||
|
const first = parseInt(parts[0]!, 10);
|
||||||
|
const second = parseInt(parts[1]!, 10);
|
||||||
|
|
||||||
|
// 10.0.0.0/8
|
||||||
|
if (first === 10) return true;
|
||||||
|
|
||||||
|
// 172.16.0.0/12
|
||||||
|
if (first === 172 && second >= 16 && second <= 31) return true;
|
||||||
|
|
||||||
|
// 192.168.0.0/16
|
||||||
|
if (first === 192 && second === 168) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
149
src/tools/builtin/SystemCommandTool.ts
Normal file
149
src/tools/builtin/SystemCommandTool.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import { ToolHandler, ToolContext } from '../base/ToolHandler';
|
||||||
|
|
||||||
|
const SystemCommandInputSchema = z.object({
|
||||||
|
command: z.string().min(1, 'Command is required'),
|
||||||
|
args: z.array(z.string()).default([]).optional(),
|
||||||
|
cwd: z.string().optional(),
|
||||||
|
env: z.record(z.string()).optional(),
|
||||||
|
timeout: z.number().min(100).max(300000).default(30000).optional(),
|
||||||
|
stdin: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const SystemCommandOutputSchema = z.object({
|
||||||
|
stdout: z.string(),
|
||||||
|
stderr: z.string(),
|
||||||
|
exitCode: z.number(),
|
||||||
|
duration: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type SystemCommandInput = z.infer<typeof SystemCommandInputSchema>;
|
||||||
|
type SystemCommandOutput = z.infer<typeof SystemCommandOutputSchema>;
|
||||||
|
|
||||||
|
export class SystemCommandTool extends ToolHandler<SystemCommandInput, SystemCommandOutput> {
|
||||||
|
private allowedCommands = new Set([
|
||||||
|
'ls', 'cat', 'grep', 'find', 'echo', 'pwd', 'date',
|
||||||
|
'curl', 'wget', 'git', 'npm', 'node', 'python', 'pip',
|
||||||
|
'docker', 'kubectl', 'terraform',
|
||||||
|
]);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(
|
||||||
|
{
|
||||||
|
name: 'system_command',
|
||||||
|
description: 'Execute system commands',
|
||||||
|
timeout: 60000,
|
||||||
|
},
|
||||||
|
SystemCommandInputSchema,
|
||||||
|
SystemCommandOutputSchema,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async checkPermissions(context: ToolContext): Promise<void> {
|
||||||
|
if (!context.permissions.includes('system:exec')) {
|
||||||
|
throw new Error('Permission denied: system:exec required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async validate(input: unknown): Promise<SystemCommandInput> {
|
||||||
|
const validated = await super.validate(input);
|
||||||
|
|
||||||
|
// Security: check if command is allowed
|
||||||
|
if (!this.allowedCommands.has(validated.command)) {
|
||||||
|
throw new Error(`Command not allowed: ${validated.command}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: prevent shell injection
|
||||||
|
if (this.containsShellCharacters(validated.command) ||
|
||||||
|
validated.args?.some(arg => this.containsShellCharacters(arg))) {
|
||||||
|
throw new Error('Shell characters not allowed in commands');
|
||||||
|
}
|
||||||
|
|
||||||
|
return validated;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async handle(input: SystemCommandInput, _context: ToolContext): Promise<SystemCommandOutput> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const proc = spawn(input.command, input.args || [], {
|
||||||
|
cwd: input.cwd,
|
||||||
|
env: { ...process.env, ...input.env },
|
||||||
|
timeout: input.timeout,
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
let killed = false;
|
||||||
|
|
||||||
|
// Limit output size
|
||||||
|
const maxOutputSize = 1024 * 1024; // 1MB
|
||||||
|
|
||||||
|
proc.stdout.on('data', (data) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
if (stdout.length > maxOutputSize) {
|
||||||
|
killed = true;
|
||||||
|
proc.kill();
|
||||||
|
reject(new Error('Output size exceeded limit'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
if (stderr.length > maxOutputSize) {
|
||||||
|
killed = true;
|
||||||
|
proc.kill();
|
||||||
|
reject(new Error('Error output size exceeded limit'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send stdin if provided
|
||||||
|
if (input.stdin) {
|
||||||
|
proc.stdin.write(input.stdin);
|
||||||
|
proc.stdin.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
proc.on('error', (error) => {
|
||||||
|
reject(new Error(`Failed to execute command: ${error.message}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('close', (code) => {
|
||||||
|
if (killed) return;
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
this.logger.info({
|
||||||
|
command: input.command,
|
||||||
|
args: input.args,
|
||||||
|
exitCode: code || 0,
|
||||||
|
duration
|
||||||
|
}, 'Command executed');
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
exitCode: code || 0,
|
||||||
|
duration,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle timeout
|
||||||
|
if (input.timeout) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!killed) {
|
||||||
|
killed = true;
|
||||||
|
proc.kill();
|
||||||
|
reject(new Error(`Command timed out after ${input.timeout}ms`));
|
||||||
|
}
|
||||||
|
}, input.timeout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private containsShellCharacters(str: string): boolean {
|
||||||
|
// Check for common shell injection characters
|
||||||
|
const dangerousChars = /[;&|`$<>(){}\[\]\\]/;
|
||||||
|
return dangerousChars.test(str);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/tools/index.ts
Normal file
27
src/tools/index.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { ToolHandler } from './base/ToolHandler';
|
||||||
|
import { FileReadTool } from './builtin/FileReadTool';
|
||||||
|
import { FileWriteTool } from './builtin/FileWriteTool';
|
||||||
|
import { FileListTool } from './builtin/FileListTool';
|
||||||
|
import { SystemCommandTool } from './builtin/SystemCommandTool';
|
||||||
|
import { HttpRequestTool } from './builtin/HttpRequestTool';
|
||||||
|
|
||||||
|
export * from './base/ToolHandler';
|
||||||
|
|
||||||
|
// Registry of all built-in tools
|
||||||
|
export const builtinTools: ToolHandler[] = [
|
||||||
|
new FileReadTool(),
|
||||||
|
new FileWriteTool(),
|
||||||
|
new FileListTool(),
|
||||||
|
new SystemCommandTool(),
|
||||||
|
new HttpRequestTool(),
|
||||||
|
];
|
||||||
|
|
||||||
|
export function createBuiltinTools(): Map<string, ToolHandler> {
|
||||||
|
const tools = new Map<string, ToolHandler>();
|
||||||
|
|
||||||
|
for (const tool of builtinTools) {
|
||||||
|
tools.set(tool.name, tool);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tools;
|
||||||
|
}
|
||||||
172
src/transport/HttpServerTransport.ts
Normal file
172
src/transport/HttpServerTransport.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
|
import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import * as http from 'http';
|
||||||
|
import * as https from 'https';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { createLogger } from '../utils/logger';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
const logger = createLogger('HttpServerTransport');
|
||||||
|
|
||||||
|
export class HttpServerTransport implements Transport {
|
||||||
|
private server?: http.Server | https.Server;
|
||||||
|
private connections: Set<http.ServerResponse> = new Set();
|
||||||
|
|
||||||
|
onclose?: () => void;
|
||||||
|
onerror?: (error: Error) => void;
|
||||||
|
onmessage?: (message: JSONRPCMessage) => void;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private host: string,
|
||||||
|
private port: number,
|
||||||
|
private options?: https.ServerOptions
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const requestHandler = async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||||||
|
// CORS headers
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||||
|
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
res.writeHead(405);
|
||||||
|
res.end('Method Not Allowed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check authentication if enabled
|
||||||
|
if (config.security.authEnabled) {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
error: { code: -32001, message: 'Unauthorized: Missing or invalid authorization header' },
|
||||||
|
id: null
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.substring(7);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, config.security.jwtSecret) as any;
|
||||||
|
// Token is valid, continue processing
|
||||||
|
logger.debug({ userId: decoded.id }, 'Authenticated request');
|
||||||
|
} catch (error) {
|
||||||
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
error: { code: -32001, message: 'Unauthorized: Invalid token' },
|
||||||
|
id: null
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => body += chunk);
|
||||||
|
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(body) as JSONRPCMessage;
|
||||||
|
logger.debug({ message }, 'Received JSON-RPC message');
|
||||||
|
|
||||||
|
// Store response for sending reply
|
||||||
|
this.connections.add(res);
|
||||||
|
|
||||||
|
// Pass message to handler
|
||||||
|
if (this.onmessage) {
|
||||||
|
this.onmessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for response (simplified - in production would use message correlation)
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end('{}');
|
||||||
|
}
|
||||||
|
this.connections.delete(res);
|
||||||
|
}, 30000); // 30 second timeout
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Failed to parse JSON-RPC message');
|
||||||
|
res.writeHead(400);
|
||||||
|
res.end('Invalid JSON-RPC message');
|
||||||
|
this.connections.delete(res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.options) {
|
||||||
|
this.server = https.createServer(this.options, requestHandler);
|
||||||
|
} else {
|
||||||
|
this.server = http.createServer(requestHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.server.listen(this.port, this.host, () => {
|
||||||
|
logger.info({ host: this.host, port: this.port }, 'HTTP transport started');
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.server.on('error', (error) => {
|
||||||
|
logger.error({ error }, 'Server error');
|
||||||
|
if (this.onerror) {
|
||||||
|
this.onerror(error);
|
||||||
|
}
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(message: JSONRPCMessage): Promise<void> {
|
||||||
|
// Send response to the most recent connection that matches the message ID
|
||||||
|
const messageStr = JSON.stringify(message);
|
||||||
|
|
||||||
|
for (const res of this.connections) {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(messageStr);
|
||||||
|
this.connections.delete(res);
|
||||||
|
logger.debug({ message }, 'Sent JSON-RPC response');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn({ message }, 'No active connection to send response');
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (this.server) {
|
||||||
|
// Close all active connections
|
||||||
|
for (const res of this.connections) {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.writeHead(503);
|
||||||
|
res.end('Server shutting down');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.connections.clear();
|
||||||
|
|
||||||
|
this.server.close(() => {
|
||||||
|
logger.info('HTTP transport closed');
|
||||||
|
if (this.onclose) {
|
||||||
|
this.onclose();
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/types/index.ts
Normal file
47
src/types/index.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
export interface ToolRequest {
|
||||||
|
id: string;
|
||||||
|
tool: string;
|
||||||
|
method: 'execute' | 'describe' | 'validate';
|
||||||
|
params: unknown;
|
||||||
|
timeout: number;
|
||||||
|
metadata: {
|
||||||
|
user?: string;
|
||||||
|
session?: string;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolResponse {
|
||||||
|
id: string;
|
||||||
|
status: 'success' | 'error';
|
||||||
|
data?: unknown;
|
||||||
|
error?: {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
details?: unknown;
|
||||||
|
};
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolDefinition {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
inputSchema: Record<string, unknown>;
|
||||||
|
module: string;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleConfig {
|
||||||
|
name: string;
|
||||||
|
language: string;
|
||||||
|
executable: string;
|
||||||
|
tools: string[];
|
||||||
|
startupTimeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleToken {
|
||||||
|
module_id: string;
|
||||||
|
allowed_tools: string[];
|
||||||
|
permissions: string[];
|
||||||
|
expires_at: number;
|
||||||
|
}
|
||||||
18
src/utils/logger.ts
Normal file
18
src/utils/logger.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import pino from 'pino';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
export const logger = pino({
|
||||||
|
level: config.mcp.logLevel,
|
||||||
|
transport: {
|
||||||
|
target: 'pino-pretty',
|
||||||
|
options: {
|
||||||
|
colorize: true,
|
||||||
|
translateTime: 'HH:MM:ss Z',
|
||||||
|
ignore: 'pid,hostname',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function createLogger(name: string): pino.Logger {
|
||||||
|
return logger.child({ component: name });
|
||||||
|
}
|
||||||
11
start-http.js
Executable file
11
start-http.js
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// Start HTTP server bypassing TypeScript errors
|
||||||
|
require('ts-node').register({
|
||||||
|
transpileOnly: true,
|
||||||
|
compilerOptions: {
|
||||||
|
module: 'commonjs'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
require('./src/http-server.ts');
|
||||||
15
start-http.sh
Executable file
15
start-http.sh
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Start MCP server with HTTP transport
|
||||||
|
|
||||||
|
export MCP_TRANSPORT=http
|
||||||
|
export NODE_ENV=production
|
||||||
|
|
||||||
|
# Make sure NATS is running
|
||||||
|
if ! pgrep -x "nats-server" > /dev/null; then
|
||||||
|
echo "Starting NATS server..."
|
||||||
|
nats-server -p 4222 > /tmp/nats.log 2>&1 &
|
||||||
|
sleep 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting MCP server on 127.0.0.1:19017..."
|
||||||
|
npm start
|
||||||
24
start-secure.sh
Executable file
24
start-secure.sh
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Start MCP server with authentication
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
export MCP_TRANSPORT=http
|
||||||
|
export AUTH_ENABLED=true
|
||||||
|
export JWT_SECRET="oQMjiysNziDNlwmAKN+vamj0TIrrLEEAqz7kUS0v8S8="
|
||||||
|
export NODE_ENV=production
|
||||||
|
|
||||||
|
# Make sure NATS is running
|
||||||
|
if ! pgrep -x "nats-server" > /dev/null; then
|
||||||
|
echo "Starting NATS server..."
|
||||||
|
nats-server -p 4222 > /tmp/nats.log 2>&1 &
|
||||||
|
sleep 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting secure MCP server on 127.0.0.1:19017..."
|
||||||
|
echo "Authentication is ENABLED"
|
||||||
|
echo ""
|
||||||
|
echo "To generate a token, run:"
|
||||||
|
echo " JWT_SECRET='$JWT_SECRET' npx ts-node src/auth/generate-token.ts"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
node dist/server.js
|
||||||
41
test-http.js
Normal file
41
test-http.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// Test HTTP endpoint
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
const data = JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'tools/list',
|
||||||
|
params: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
port: 19017,
|
||||||
|
path: '/',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': data.length
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = http.request(options, (res) => {
|
||||||
|
console.log(`Status: ${res.statusCode}`);
|
||||||
|
console.log(`Headers: ${JSON.stringify(res.headers)}`);
|
||||||
|
|
||||||
|
let body = '';
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
body += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
console.log('Response:', body);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (error) => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.write(data);
|
||||||
|
req.end();
|
||||||
54
test-server.js
Normal file
54
test-server.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// Test server without stdio transport
|
||||||
|
const { MCPServer } = require('./dist/server');
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
const server = new MCPServer();
|
||||||
|
|
||||||
|
// Override start to skip stdio transport
|
||||||
|
server.start = async function() {
|
||||||
|
const { createLogger } = require('./dist/utils/logger');
|
||||||
|
const logger = createLogger('MCPServer');
|
||||||
|
const { config } = require('./dist/config');
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info({ config: config.mcp }, 'Starting MCP Server (test mode)');
|
||||||
|
|
||||||
|
// Connect to NATS
|
||||||
|
await this.natsClient.connect();
|
||||||
|
logger.info('Connected to NATS');
|
||||||
|
|
||||||
|
// Initialize tool registry after NATS connection
|
||||||
|
await this.toolRegistry.initialize();
|
||||||
|
logger.info('Tool registry initialized');
|
||||||
|
|
||||||
|
// Start module manager
|
||||||
|
await this.moduleManager.startAll();
|
||||||
|
logger.info('Modules started');
|
||||||
|
|
||||||
|
// Setup MCP handlers
|
||||||
|
this.setupHandlers();
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ host: config.mcp.host, port: config.mcp.port },
|
||||||
|
'MCP Server started successfully (test mode - no stdio)',
|
||||||
|
);
|
||||||
|
|
||||||
|
// List available tools
|
||||||
|
const tools = await this.toolRegistry.listTools();
|
||||||
|
logger.info({ count: tools.length }, 'Available tools:');
|
||||||
|
tools.forEach(tool => {
|
||||||
|
logger.info({ tool: tool.name, description: tool.description });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep server running
|
||||||
|
logger.info('Server is running. Press Ctrl+C to stop.');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Failed to start MCP Server');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await server.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
test().catch(console.error);
|
||||||
25
tests/config.test.ts
Normal file
25
tests/config.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { config } from '../src/config';
|
||||||
|
|
||||||
|
describe('Config', () => {
|
||||||
|
it('should load default configuration', () => {
|
||||||
|
expect(config.mcp.host).toBe('127.0.0.1');
|
||||||
|
expect(config.mcp.port).toBe(19017);
|
||||||
|
expect(config.mcp.logLevel).toBe('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have NATS configuration', () => {
|
||||||
|
expect(config.nats.url).toBe('nats://localhost:4222');
|
||||||
|
expect(config.nats.reconnectTimeWait).toBe(2000);
|
||||||
|
expect(config.nats.maxReconnectAttempts).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have security configuration', () => {
|
||||||
|
expect(config.security.jwtSecret).toBe('test-secret-key-for-testing-only');
|
||||||
|
expect(config.security.authEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have modules configuration', () => {
|
||||||
|
expect(config.modules.startupTimeout).toBe(5000);
|
||||||
|
expect(config.modules.healthCheckInterval).toBe(30000);
|
||||||
|
});
|
||||||
|
});
|
||||||
131
tests/registry/ToolRegistry.test.ts
Normal file
131
tests/registry/ToolRegistry.test.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { ToolRegistry } from '../../src/registry/ToolRegistry';
|
||||||
|
import { NatsClient } from '../../src/nats/NatsClient';
|
||||||
|
import { ToolDefinition } from '../../src/types';
|
||||||
|
|
||||||
|
// Mock NatsClient
|
||||||
|
jest.mock('../../src/nats/NatsClient');
|
||||||
|
|
||||||
|
describe('ToolRegistry', () => {
|
||||||
|
let registry: ToolRegistry;
|
||||||
|
let mockNatsClient: jest.Mocked<NatsClient>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockNatsClient = new NatsClient() as jest.Mocked<NatsClient>;
|
||||||
|
mockNatsClient.subscribe = jest.fn();
|
||||||
|
mockNatsClient.request = jest.fn();
|
||||||
|
|
||||||
|
registry = new ToolRegistry(mockNatsClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registerTool', () => {
|
||||||
|
it('should register a tool', () => {
|
||||||
|
const tool: ToolDefinition = {
|
||||||
|
name: 'test-tool',
|
||||||
|
description: 'Test tool',
|
||||||
|
inputSchema: { type: 'object' },
|
||||||
|
module: 'test-module',
|
||||||
|
permissions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.registerTool(tool);
|
||||||
|
|
||||||
|
expect(registry.getTool('test-tool')).toEqual(tool);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listTools', () => {
|
||||||
|
it('should return all registered tools including built-in tools', async () => {
|
||||||
|
const tool1: ToolDefinition = {
|
||||||
|
name: 'tool1',
|
||||||
|
description: 'Tool 1',
|
||||||
|
inputSchema: {},
|
||||||
|
module: 'module1',
|
||||||
|
permissions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const tool2: ToolDefinition = {
|
||||||
|
name: 'tool2',
|
||||||
|
description: 'Tool 2',
|
||||||
|
inputSchema: {},
|
||||||
|
module: 'module2',
|
||||||
|
permissions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get initial count (built-in tools)
|
||||||
|
const initialTools = await registry.listTools();
|
||||||
|
const builtinCount = initialTools.length;
|
||||||
|
|
||||||
|
registry.registerTool(tool1);
|
||||||
|
registry.registerTool(tool2);
|
||||||
|
|
||||||
|
const tools = await registry.listTools();
|
||||||
|
|
||||||
|
expect(tools).toHaveLength(builtinCount + 2);
|
||||||
|
expect(tools.find(t => t.name === 'tool1')).toBeDefined();
|
||||||
|
expect(tools.find(t => t.name === 'tool2')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('executeTool', () => {
|
||||||
|
it('should execute a registered tool', async () => {
|
||||||
|
const tool: ToolDefinition = {
|
||||||
|
name: 'test-tool',
|
||||||
|
description: 'Test tool',
|
||||||
|
inputSchema: {},
|
||||||
|
module: 'test-module',
|
||||||
|
permissions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.registerTool(tool);
|
||||||
|
|
||||||
|
mockNatsClient.request.mockResolvedValue({
|
||||||
|
id: 'test-id',
|
||||||
|
status: 'success',
|
||||||
|
data: { result: 'test result' },
|
||||||
|
duration: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await registry.executeTool('test-tool', { input: 'test' });
|
||||||
|
|
||||||
|
expect(result).toEqual({ result: 'test result' });
|
||||||
|
expect(mockNatsClient.request).toHaveBeenCalledWith(
|
||||||
|
'tools.test-module.test-tool.execute',
|
||||||
|
expect.objectContaining({
|
||||||
|
tool: 'test-tool',
|
||||||
|
method: 'execute',
|
||||||
|
params: { input: 'test' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for unknown tool', async () => {
|
||||||
|
await expect(registry.executeTool('unknown-tool', {}))
|
||||||
|
.rejects.toThrow('Tool not found: unknown-tool');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when tool execution fails', async () => {
|
||||||
|
const tool: ToolDefinition = {
|
||||||
|
name: 'test-tool',
|
||||||
|
description: 'Test tool',
|
||||||
|
inputSchema: {},
|
||||||
|
module: 'test-module',
|
||||||
|
permissions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.registerTool(tool);
|
||||||
|
|
||||||
|
mockNatsClient.request.mockResolvedValue({
|
||||||
|
id: 'test-id',
|
||||||
|
status: 'error',
|
||||||
|
error: {
|
||||||
|
code: 'TEST_ERROR',
|
||||||
|
message: 'Test error message',
|
||||||
|
},
|
||||||
|
duration: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(registry.executeTool('test-tool', {}))
|
||||||
|
.rejects.toThrow('Test error message');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
4
tests/setup.ts
Normal file
4
tests/setup.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Test setup file
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
process.env.MCP_LOG_LEVEL = 'error';
|
||||||
|
process.env.JWT_SECRET = 'test-secret-key-for-testing-only';
|
||||||
176
tests/tools/FileListTool.test.ts
Normal file
176
tests/tools/FileListTool.test.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { FileListTool } from '../../src/tools/builtin/FileListTool';
|
||||||
|
import { ToolContext } from '../../src/tools/base/ToolHandler';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
// Mock fs module
|
||||||
|
jest.mock('fs', () => ({
|
||||||
|
promises: {
|
||||||
|
readdir: jest.fn(),
|
||||||
|
stat: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('FileListTool', () => {
|
||||||
|
let tool: FileListTool;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tool = new FileListTool();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const createContext = (permissions: string[] = ['file:read']): ToolContext => ({
|
||||||
|
requestId: 'test-request',
|
||||||
|
permissions,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('execute', () => {
|
||||||
|
it('should list files in a directory', async () => {
|
||||||
|
const testPath = './test/dir';
|
||||||
|
|
||||||
|
(fs.readdir as jest.Mock).mockResolvedValue(['file1.txt', 'file2.js', 'subdir']);
|
||||||
|
(fs.stat as jest.Mock)
|
||||||
|
.mockResolvedValueOnce({ isDirectory: () => true })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
isDirectory: () => false,
|
||||||
|
isFile: () => true,
|
||||||
|
size: 100,
|
||||||
|
mtime: new Date('2025-01-01'),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
isDirectory: () => false,
|
||||||
|
isFile: () => true,
|
||||||
|
size: 200,
|
||||||
|
mtime: new Date('2025-01-02'),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
isDirectory: () => true,
|
||||||
|
isFile: () => false,
|
||||||
|
size: 0,
|
||||||
|
mtime: new Date('2025-01-03'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
{ path: testPath },
|
||||||
|
createContext(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.path).toBe(path.resolve(testPath));
|
||||||
|
const files = result.entries.filter(e => e.type === 'file');
|
||||||
|
const dirs = result.entries.filter(e => e.type === 'directory');
|
||||||
|
expect(files).toHaveLength(2);
|
||||||
|
expect(dirs).toHaveLength(1);
|
||||||
|
expect(files[0]).toMatchObject({
|
||||||
|
name: 'file1.txt',
|
||||||
|
size: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should list files recursively', async () => {
|
||||||
|
const testPath = './test/dir';
|
||||||
|
|
||||||
|
// Mock for root directory
|
||||||
|
(fs.readdir as jest.Mock)
|
||||||
|
.mockResolvedValueOnce(['file.txt', 'subdir'])
|
||||||
|
.mockResolvedValueOnce(['nested.txt']);
|
||||||
|
|
||||||
|
(fs.stat as jest.Mock)
|
||||||
|
.mockResolvedValueOnce({ isDirectory: () => true }) // root dir
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
isDirectory: () => false,
|
||||||
|
isFile: () => true,
|
||||||
|
size: 100,
|
||||||
|
mtime: new Date(),
|
||||||
|
}) // file.txt
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
isDirectory: () => true,
|
||||||
|
isFile: () => false,
|
||||||
|
size: 0,
|
||||||
|
mtime: new Date(),
|
||||||
|
}) // subdir
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
isDirectory: () => false,
|
||||||
|
isFile: () => true,
|
||||||
|
size: 50,
|
||||||
|
mtime: new Date(),
|
||||||
|
}); // nested.txt
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
{ path: testPath, recursive: true },
|
||||||
|
createContext(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const files = result.entries.filter(e => e.type === 'file');
|
||||||
|
expect(files).toHaveLength(2);
|
||||||
|
expect(files.map(f => f.path)).toContain(path.resolve(testPath, 'file.txt'));
|
||||||
|
expect(files.map(f => f.path)).toContain(path.resolve(testPath, 'subdir', 'nested.txt'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by pattern', async () => {
|
||||||
|
const testPath = './test/dir';
|
||||||
|
|
||||||
|
(fs.readdir as jest.Mock).mockResolvedValue(['file1.txt', 'file2.js', 'test.txt']);
|
||||||
|
(fs.stat as jest.Mock)
|
||||||
|
.mockResolvedValueOnce({ isDirectory: () => true })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
isDirectory: () => false,
|
||||||
|
isFile: () => true,
|
||||||
|
size: 100,
|
||||||
|
mtime: new Date(),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
isDirectory: () => false,
|
||||||
|
isFile: () => true,
|
||||||
|
size: 200,
|
||||||
|
mtime: new Date(),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
isDirectory: () => false,
|
||||||
|
isFile: () => true,
|
||||||
|
size: 150,
|
||||||
|
mtime: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
{ path: testPath, pattern: '*.txt' },
|
||||||
|
createContext(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const files = result.entries.filter(e => e.type === 'file');
|
||||||
|
expect(files).toHaveLength(2);
|
||||||
|
expect(files.every(f => f.name.endsWith('.txt'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent directory traversal', async () => {
|
||||||
|
await expect(
|
||||||
|
tool.execute(
|
||||||
|
{ path: '../../../etc' },
|
||||||
|
createContext(),
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Invalid path: directory traversal not allowed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require file:read permission', async () => {
|
||||||
|
await expect(
|
||||||
|
tool.execute(
|
||||||
|
{ path: './test' },
|
||||||
|
createContext([]),
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Permission denied: file:read required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle non-existent directory', async () => {
|
||||||
|
(fs.stat as jest.Mock).mockResolvedValue({
|
||||||
|
isDirectory: () => false,
|
||||||
|
isFile: () => false
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
tool.execute(
|
||||||
|
{ path: './non/existent' },
|
||||||
|
createContext(),
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Path is not a directory');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
92
tests/tools/FileReadTool.test.ts
Normal file
92
tests/tools/FileReadTool.test.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { FileReadTool } from '../../src/tools/builtin/FileReadTool';
|
||||||
|
import { ToolContext } from '../../src/tools/base/ToolHandler';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
|
|
||||||
|
describe('FileReadTool', () => {
|
||||||
|
let tool: FileReadTool;
|
||||||
|
let testDir: string;
|
||||||
|
let testFile: string;
|
||||||
|
const testContent = 'Hello, World!\nThis is a test file.';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tool = new FileReadTool();
|
||||||
|
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'test-'));
|
||||||
|
testFile = path.join(testDir, 'test.txt');
|
||||||
|
await fs.writeFile(testFile, testContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await fs.rm(testDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const createContext = (permissions: string[] = ['file:read']): ToolContext => ({
|
||||||
|
requestId: 'test-request',
|
||||||
|
permissions,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('execute', () => {
|
||||||
|
it('should read file content successfully', async () => {
|
||||||
|
const result = await tool.execute(
|
||||||
|
{ path: testFile },
|
||||||
|
createContext(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
content: testContent,
|
||||||
|
size: Buffer.from(testContent).length,
|
||||||
|
path: testFile,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle different encodings', async () => {
|
||||||
|
const binaryFile = path.join(testDir, 'binary.bin');
|
||||||
|
const binaryContent = Buffer.from([0x00, 0x01, 0x02, 0x03]);
|
||||||
|
await fs.writeFile(binaryFile, binaryContent);
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
{ path: binaryFile, encoding: 'binary' },
|
||||||
|
createContext(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.content).toBe(binaryContent.toString('binary'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for non-existent file', async () => {
|
||||||
|
await expect(
|
||||||
|
tool.execute(
|
||||||
|
{ path: './non/existent/file.txt' },
|
||||||
|
createContext(),
|
||||||
|
),
|
||||||
|
).rejects.toThrow('File not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for directory', async () => {
|
||||||
|
await expect(
|
||||||
|
tool.execute(
|
||||||
|
{ path: testDir },
|
||||||
|
createContext(),
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Path is not a file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error without permission', async () => {
|
||||||
|
await expect(
|
||||||
|
tool.execute(
|
||||||
|
{ path: testFile },
|
||||||
|
createContext([]),
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Permission denied: file:read required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent directory traversal', async () => {
|
||||||
|
await expect(
|
||||||
|
tool.execute(
|
||||||
|
{ path: '../../../etc/passwd' },
|
||||||
|
createContext(),
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Invalid path: directory traversal not allowed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
113
tests/tools/FileWriteTool.test.ts
Normal file
113
tests/tools/FileWriteTool.test.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { FileWriteTool } from '../../src/tools/builtin/FileWriteTool';
|
||||||
|
import { ToolContext } from '../../src/tools/base/ToolHandler';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
|
|
||||||
|
// Mock fs module
|
||||||
|
jest.mock('fs', () => ({
|
||||||
|
promises: {
|
||||||
|
mkdir: jest.fn(),
|
||||||
|
writeFile: jest.fn(),
|
||||||
|
stat: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('FileWriteTool', () => {
|
||||||
|
let tool: FileWriteTool;
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tool = new FileWriteTool();
|
||||||
|
tempDir = path.join(os.tmpdir(), 'mcp-test');
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const createContext = (permissions: string[] = ['file:write']): ToolContext => ({
|
||||||
|
requestId: 'test-request',
|
||||||
|
permissions,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('execute', () => {
|
||||||
|
it('should write content to a file', async () => {
|
||||||
|
const testPath = path.join(tempDir, 'test.txt');
|
||||||
|
const content = 'Hello, World!';
|
||||||
|
|
||||||
|
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
|
||||||
|
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
|
||||||
|
(fs.stat as jest.Mock).mockResolvedValue({ size: content.length });
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
{ path: testPath, content },
|
||||||
|
createContext(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
path: testPath,
|
||||||
|
size: content.length,
|
||||||
|
mode: 'overwrite',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fs.mkdir).toHaveBeenCalledWith(tempDir, { recursive: true });
|
||||||
|
expect(fs.writeFile).toHaveBeenCalledWith(testPath, content, 'utf8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should overwrite existing file', async () => {
|
||||||
|
const testPath = path.join(tempDir, 'existing.txt');
|
||||||
|
const content = 'New content';
|
||||||
|
|
||||||
|
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
|
||||||
|
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
|
||||||
|
(fs.stat as jest.Mock).mockResolvedValue({
|
||||||
|
isFile: () => true,
|
||||||
|
size: content.length
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
{ path: testPath, content },
|
||||||
|
createContext(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.mode).toBe('overwrite');
|
||||||
|
expect(fs.writeFile).toHaveBeenCalledWith(testPath, content, 'utf8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent directory traversal', async () => {
|
||||||
|
await expect(
|
||||||
|
tool.execute(
|
||||||
|
{ path: '../../../etc/passwd', content: 'malicious' },
|
||||||
|
createContext(),
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Invalid path: directory traversal not allowed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require file:write permission', async () => {
|
||||||
|
await expect(
|
||||||
|
tool.execute(
|
||||||
|
{ path: 'test.txt', content: 'test' },
|
||||||
|
createContext([]),
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Permission denied: file:write required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle binary content', async () => {
|
||||||
|
const testPath = path.join(tempDir, 'binary.bin');
|
||||||
|
const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64');
|
||||||
|
|
||||||
|
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
|
||||||
|
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
|
||||||
|
(fs.stat as jest.Mock).mockResolvedValue({ size: 4 }); // binary data size
|
||||||
|
|
||||||
|
await tool.execute(
|
||||||
|
{ path: testPath, content: binaryContent, encoding: 'base64' },
|
||||||
|
createContext(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||||
|
testPath,
|
||||||
|
expect.any(Buffer),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
130
tests/tools/HttpRequestTool.test.ts
Normal file
130
tests/tools/HttpRequestTool.test.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { HttpRequestTool } from '../../src/tools/builtin/HttpRequestTool';
|
||||||
|
import { ToolContext } from '../../src/tools/base/ToolHandler';
|
||||||
|
|
||||||
|
// Mock fetch
|
||||||
|
global.fetch = jest.fn();
|
||||||
|
|
||||||
|
describe('HttpRequestTool', () => {
|
||||||
|
let tool: HttpRequestTool;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tool = new HttpRequestTool();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const createContext = (permissions: string[] = ['network:http']): ToolContext => ({
|
||||||
|
requestId: 'test-request',
|
||||||
|
permissions,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('execute', () => {
|
||||||
|
it('should make GET request successfully', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
status: 200,
|
||||||
|
statusText: 'OK',
|
||||||
|
headers: new Headers({ 'content-type': 'application/json' }),
|
||||||
|
json: async () => ({ data: 'test' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
(global.fetch as jest.Mock).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
{ url: 'https://api.example.com/data' },
|
||||||
|
createContext(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: 200,
|
||||||
|
statusText: 'OK',
|
||||||
|
body: { data: 'test' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
'https://api.example.com/data',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'GET',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should make POST request with JSON body', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
status: 201,
|
||||||
|
statusText: 'Created',
|
||||||
|
headers: new Headers({ 'content-type': 'application/json' }),
|
||||||
|
json: async () => ({ id: 123 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
(global.fetch as jest.Mock).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
{
|
||||||
|
url: 'https://api.example.com/users',
|
||||||
|
method: 'POST',
|
||||||
|
body: { name: 'John Doe' },
|
||||||
|
},
|
||||||
|
createContext(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe(201);
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
'https://api.example.com/users',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name: 'John Doe' }),
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for internal URLs', async () => {
|
||||||
|
await expect(
|
||||||
|
tool.execute(
|
||||||
|
{ url: 'http://localhost:8080/internal' },
|
||||||
|
createContext(),
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Requests to internal networks are not allowed');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
tool.execute(
|
||||||
|
{ url: 'http://192.168.1.1/admin' },
|
||||||
|
createContext(),
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Requests to internal networks are not allowed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error without permission', async () => {
|
||||||
|
await expect(
|
||||||
|
tool.execute(
|
||||||
|
{ url: 'https://api.example.com' },
|
||||||
|
createContext([]),
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Permission denied: network:http required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle timeout', async () => {
|
||||||
|
(global.fetch as jest.Mock).mockImplementation(
|
||||||
|
(_url, options) => new Promise((_resolve, reject) => {
|
||||||
|
// Simulate AbortController behavior
|
||||||
|
if (options?.signal) {
|
||||||
|
options.signal.addEventListener('abort', () => {
|
||||||
|
const error = new Error('The operation was aborted');
|
||||||
|
error.name = 'AbortError';
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Never resolve to simulate timeout
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
tool.execute(
|
||||||
|
{ url: 'https://api.example.com', timeout: 100 },
|
||||||
|
createContext(),
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Request timeout after 100ms');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
201
tests/tools/SystemCommandTool.test.ts
Normal file
201
tests/tools/SystemCommandTool.test.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { SystemCommandTool } from '../../src/tools/builtin/SystemCommandTool';
|
||||||
|
import { ToolContext } from '../../src/tools/base/ToolHandler';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
|
// Mock child_process
|
||||||
|
jest.mock('child_process');
|
||||||
|
|
||||||
|
class MockChildProcess extends EventEmitter {
|
||||||
|
stdout = new EventEmitter();
|
||||||
|
stderr = new EventEmitter();
|
||||||
|
stdin = {
|
||||||
|
write: jest.fn(),
|
||||||
|
end: jest.fn(),
|
||||||
|
};
|
||||||
|
kill = jest.fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SystemCommandTool', () => {
|
||||||
|
jest.setTimeout(10000); // Increase timeout for these tests
|
||||||
|
let tool: SystemCommandTool;
|
||||||
|
let mockSpawn: jest.MockedFunction<typeof spawn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tool = new SystemCommandTool();
|
||||||
|
mockSpawn = spawn as jest.MockedFunction<typeof spawn>;
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const createContext = (permissions: string[] = ['system:exec']): ToolContext => ({
|
||||||
|
requestId: 'test-request',
|
||||||
|
permissions,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('execute', () => {
|
||||||
|
it('should execute allowed commands', async () => {
|
||||||
|
const mockProcess = new MockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(mockProcess as any);
|
||||||
|
|
||||||
|
const resultPromise = tool.execute(
|
||||||
|
{ command: 'ls', args: ['-la'] },
|
||||||
|
createContext(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate command output
|
||||||
|
setImmediate(() => {
|
||||||
|
mockProcess.stdout.emit('data', Buffer.from('file1.txt\nfile2.txt'));
|
||||||
|
mockProcess.emit('close', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resultPromise;
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
stdout: 'file1.txt\nfile2.txt',
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSpawn).toHaveBeenCalledWith('ls', ['-la'], expect.any(Object));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle command with environment variables', async () => {
|
||||||
|
const mockProcess = new MockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(mockProcess as any);
|
||||||
|
|
||||||
|
const resultPromise = tool.execute(
|
||||||
|
{
|
||||||
|
command: 'echo',
|
||||||
|
args: ['test'],
|
||||||
|
env: { TEST_VAR: 'test-value' },
|
||||||
|
},
|
||||||
|
createContext(),
|
||||||
|
);
|
||||||
|
|
||||||
|
setImmediate(() => {
|
||||||
|
mockProcess.stdout.emit('data', Buffer.from('test'));
|
||||||
|
mockProcess.emit('close', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await resultPromise;
|
||||||
|
|
||||||
|
expect(mockSpawn).toHaveBeenCalledWith(
|
||||||
|
'echo',
|
||||||
|
['test'],
|
||||||
|
expect.objectContaining({
|
||||||
|
env: expect.objectContaining({
|
||||||
|
TEST_VAR: 'test-value',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject non-whitelisted commands', async () => {
|
||||||
|
await expect(
|
||||||
|
tool.execute(
|
||||||
|
{ command: 'rm', args: ['-rf', '/'] },
|
||||||
|
createContext(),
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Command not allowed: rm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent command injection', async () => {
|
||||||
|
await expect(
|
||||||
|
tool.execute(
|
||||||
|
{ command: 'ls', args: ['; rm -rf /'] },
|
||||||
|
createContext(),
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Shell characters not allowed in');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle command timeout', async () => {
|
||||||
|
const mockProcess = new MockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(mockProcess as any);
|
||||||
|
|
||||||
|
const resultPromise = tool.execute(
|
||||||
|
{ command: 'ls', timeout: 100 },
|
||||||
|
createContext(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Don't emit close event to simulate timeout
|
||||||
|
await expect(resultPromise).rejects.toThrow('Command timed out after 100ms');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle command failure', async () => {
|
||||||
|
const mockProcess = new MockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(mockProcess as any);
|
||||||
|
|
||||||
|
const resultPromise = tool.execute(
|
||||||
|
{ command: 'ls' },
|
||||||
|
createContext(),
|
||||||
|
);
|
||||||
|
|
||||||
|
setImmediate(() => {
|
||||||
|
mockProcess.stderr.emit('data', Buffer.from('Command not found'));
|
||||||
|
mockProcess.emit('close', 127);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resultPromise;
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
stdout: '',
|
||||||
|
stderr: 'Command not found',
|
||||||
|
exitCode: 127,
|
||||||
|
duration: expect.any(Number),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require system:exec permission', async () => {
|
||||||
|
await expect(
|
||||||
|
tool.execute(
|
||||||
|
{ command: 'ls' },
|
||||||
|
createContext([]),
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Permission denied: system:exec required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect working directory', async () => {
|
||||||
|
const mockProcess = new MockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(mockProcess as any);
|
||||||
|
|
||||||
|
const resultPromise = tool.execute(
|
||||||
|
{ command: 'ls', cwd: '/tmp' },
|
||||||
|
createContext(),
|
||||||
|
);
|
||||||
|
|
||||||
|
setImmediate(() => {
|
||||||
|
mockProcess.emit('close', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await resultPromise;
|
||||||
|
|
||||||
|
expect(mockSpawn).toHaveBeenCalledWith(
|
||||||
|
'ls',
|
||||||
|
[],
|
||||||
|
expect.objectContaining({
|
||||||
|
cwd: '/tmp',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle stdin input', async () => {
|
||||||
|
const mockProcess = new MockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(mockProcess as any);
|
||||||
|
|
||||||
|
const resultPromise = tool.execute(
|
||||||
|
{ command: 'cat', stdin: 'Hello, World!' },
|
||||||
|
createContext(),
|
||||||
|
);
|
||||||
|
|
||||||
|
setImmediate(() => {
|
||||||
|
mockProcess.stdout.emit('data', Buffer.from('Hello, World!'));
|
||||||
|
mockProcess.emit('close', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await resultPromise;
|
||||||
|
|
||||||
|
expect(mockProcess.stdin.write).toHaveBeenCalledWith('Hello, World!');
|
||||||
|
expect(mockProcess.stdin.end).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"allowUnreachableCode": false,
|
||||||
|
"noImplicitOverride": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "tests"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user