🐺 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:
Claude (Lupul Augmentat)
2025-10-09 06:24:58 +02:00
commit 475f89af74
59 changed files with 12827 additions and 0 deletions

25
tests/config.test.ts Normal file
View 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);
});
});

View 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
View 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';

View 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');
});
});
});

View 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');
});
});
});

View 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,
);
});
});
});

View 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');
});
});
});

View 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();
});
});
});