🐺 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:
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user