feat: implement v2 3-tier architecture (mcpctl → mcplocal → mcpd)
- Rename local-proxy to mcplocal with HTTP server, LLM pipeline, mcpd discovery - Add LLM pre-processing: token estimation, filter cache, metrics, Gemini CLI + DeepSeek providers - Add mcpd auth (login/logout) and MCP proxy endpoints - Update CLI: dual URLs (mcplocalUrl/mcpdUrl), auth commands, --direct flag - Add tiered health monitoring, shell completions, e2e integration tests - 57 test files, 597 tests passing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,11 +20,13 @@
|
||||
"@mcpctl/db": "workspace:*",
|
||||
"@mcpctl/shared": "workspace:*",
|
||||
"@prisma/client": "^6.0.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"dockerode": "^4.0.9",
|
||||
"fastify": "^5.0.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/dockerode": "^4.0.1",
|
||||
"@types/node": "^25.3.0"
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
HealthAggregator,
|
||||
BackupService,
|
||||
RestoreService,
|
||||
AuthService,
|
||||
McpProxyService,
|
||||
} from './services/index.js';
|
||||
import {
|
||||
registerMcpServerRoutes,
|
||||
@@ -30,6 +32,8 @@ import {
|
||||
registerAuditLogRoutes,
|
||||
registerHealthMonitoringRoutes,
|
||||
registerBackupRoutes,
|
||||
registerAuthRoutes,
|
||||
registerMcpProxyRoutes,
|
||||
} from './routes/index.js';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -64,6 +68,8 @@ async function main(): Promise<void> {
|
||||
const healthAggregator = new HealthAggregator(metricsCollector, orchestrator);
|
||||
const backupService = new BackupService(serverRepo, profileRepo, projectRepo);
|
||||
const restoreService = new RestoreService(serverRepo, profileRepo, projectRepo);
|
||||
const authService = new AuthService(prisma);
|
||||
const mcpProxyService = new McpProxyService(instanceRepo);
|
||||
|
||||
// Server
|
||||
const app = await createServer(config, {
|
||||
@@ -87,6 +93,12 @@ async function main(): Promise<void> {
|
||||
registerAuditLogRoutes(app, auditLogService);
|
||||
registerHealthMonitoringRoutes(app, { healthAggregator, metricsCollector });
|
||||
registerBackupRoutes(app, { backupService, restoreService });
|
||||
registerAuthRoutes(app, { authService });
|
||||
registerMcpProxyRoutes(app, {
|
||||
mcpProxyService,
|
||||
auditLogService,
|
||||
authDeps: { findSession: (token) => authService.findSession(token) },
|
||||
});
|
||||
|
||||
// Start
|
||||
await app.listen({ port: config.port, host: config.host });
|
||||
|
||||
31
src/mcpd/src/routes/auth.ts
Normal file
31
src/mcpd/src/routes/auth.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { AuthService } from '../services/auth.service.js';
|
||||
import { createAuthMiddleware } from '../middleware/auth.js';
|
||||
|
||||
export interface AuthRouteDeps {
|
||||
authService: AuthService;
|
||||
}
|
||||
|
||||
export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDeps): void {
|
||||
const authMiddleware = createAuthMiddleware({
|
||||
findSession: (token) => deps.authService.findSession(token),
|
||||
});
|
||||
|
||||
// POST /api/v1/auth/login — no auth required
|
||||
app.post<{
|
||||
Body: { email: string; password: string };
|
||||
}>('/api/v1/auth/login', async (request) => {
|
||||
const { email, password } = request.body;
|
||||
const result = await deps.authService.login(email, password);
|
||||
return result;
|
||||
});
|
||||
|
||||
// POST /api/v1/auth/logout — auth required
|
||||
app.post('/api/v1/auth/logout', { preHandler: [authMiddleware] }, async (request) => {
|
||||
const header = request.headers.authorization;
|
||||
// Auth middleware already validated the header; extract the token
|
||||
const token = header!.slice(7);
|
||||
await deps.authService.logout(token);
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
@@ -9,3 +9,7 @@ export { registerHealthMonitoringRoutes } from './health-monitoring.js';
|
||||
export type { HealthMonitoringDeps } from './health-monitoring.js';
|
||||
export { registerBackupRoutes } from './backup.js';
|
||||
export type { BackupDeps } from './backup.js';
|
||||
export { registerAuthRoutes } from './auth.js';
|
||||
export type { AuthRouteDeps } from './auth.js';
|
||||
export { registerMcpProxyRoutes } from './mcp-proxy.js';
|
||||
export type { McpProxyRouteDeps } from './mcp-proxy.js';
|
||||
|
||||
37
src/mcpd/src/routes/mcp-proxy.ts
Normal file
37
src/mcpd/src/routes/mcp-proxy.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { McpProxyService } from '../services/mcp-proxy-service.js';
|
||||
import type { AuditLogService } from '../services/audit-log.service.js';
|
||||
import { createAuthMiddleware, type AuthDeps } from '../middleware/auth.js';
|
||||
|
||||
export interface McpProxyRouteDeps {
|
||||
mcpProxyService: McpProxyService;
|
||||
auditLogService: AuditLogService;
|
||||
authDeps: AuthDeps;
|
||||
}
|
||||
|
||||
export function registerMcpProxyRoutes(app: FastifyInstance, deps: McpProxyRouteDeps): void {
|
||||
const authMiddleware = createAuthMiddleware(deps.authDeps);
|
||||
|
||||
app.post<{
|
||||
Body: {
|
||||
serverId: string;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
}>('/api/v1/mcp/proxy', { preHandler: [authMiddleware] }, async (request) => {
|
||||
const { serverId, method, params } = request.body;
|
||||
|
||||
const result = await deps.mcpProxyService.execute({ serverId, method, params });
|
||||
|
||||
// Log to audit with userId (set by auth middleware)
|
||||
await deps.auditLogService.create({
|
||||
userId: request.userId!,
|
||||
action: 'MCP_PROXY',
|
||||
resource: 'mcp-server',
|
||||
resourceId: serverId,
|
||||
details: { method, hasParams: params !== undefined },
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
66
src/mcpd/src/services/auth.service.ts
Normal file
66
src/mcpd/src/services/auth.service.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
/** 30 days in milliseconds */
|
||||
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface LoginResult {
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
user: { id: string; email: string; role: string };
|
||||
}
|
||||
|
||||
export class AuthenticationError extends Error {
|
||||
readonly statusCode = 401;
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'AuthenticationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async login(email: string, password: string): Promise<LoginResult> {
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (user === null) {
|
||||
throw new AuthenticationError('Invalid email or password');
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!valid) {
|
||||
throw new AuthenticationError('Invalid email or password');
|
||||
}
|
||||
|
||||
const token = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
|
||||
|
||||
await this.prisma.session.create({
|
||||
data: {
|
||||
token,
|
||||
userId: user.id,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
token,
|
||||
expiresAt,
|
||||
user: { id: user.id, email: user.email, role: user.role },
|
||||
};
|
||||
}
|
||||
|
||||
async logout(token: string): Promise<void> {
|
||||
// Delete the session by token; ignore if already deleted
|
||||
await this.prisma.session.deleteMany({ where: { token } });
|
||||
}
|
||||
|
||||
async findSession(token: string): Promise<{ userId: string; expiresAt: Date } | null> {
|
||||
const session = await this.prisma.session.findUnique({ where: { token } });
|
||||
if (session === null) {
|
||||
return null;
|
||||
}
|
||||
return { userId: session.userId, expiresAt: session.expiresAt };
|
||||
}
|
||||
}
|
||||
@@ -19,3 +19,7 @@ export { BackupService } from './backup/index.js';
|
||||
export type { BackupBundle, BackupOptions } from './backup/index.js';
|
||||
export { RestoreService } from './backup/index.js';
|
||||
export type { RestoreOptions, RestoreResult, ConflictStrategy } from './backup/index.js';
|
||||
export { AuthService, AuthenticationError } from './auth.service.js';
|
||||
export type { LoginResult } from './auth.service.js';
|
||||
export { McpProxyService } from './mcp-proxy-service.js';
|
||||
export type { McpProxyRequest, McpProxyResponse } from './mcp-proxy-service.js';
|
||||
|
||||
76
src/mcpd/src/services/mcp-proxy-service.ts
Normal file
76
src/mcpd/src/services/mcp-proxy-service.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { McpInstance } from '@prisma/client';
|
||||
import type { IMcpInstanceRepository } from '../repositories/interfaces.js';
|
||||
import { NotFoundError } from './mcp-server.service.js';
|
||||
import { InvalidStateError } from './instance.service.js';
|
||||
|
||||
export interface McpProxyRequest {
|
||||
serverId: string;
|
||||
method: string;
|
||||
params?: Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
export interface McpProxyResponse {
|
||||
jsonrpc: '2.0';
|
||||
id: number;
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string; data?: unknown };
|
||||
}
|
||||
|
||||
export class McpProxyService {
|
||||
constructor(private readonly instanceRepo: IMcpInstanceRepository) {}
|
||||
|
||||
async execute(request: McpProxyRequest): Promise<McpProxyResponse> {
|
||||
// Find a running instance for this server
|
||||
const instances = await this.instanceRepo.findAll(request.serverId);
|
||||
const running = instances.find((i) => i.status === 'RUNNING');
|
||||
|
||||
if (!running) {
|
||||
throw new NotFoundError(`No running instance found for server '${request.serverId}'`);
|
||||
}
|
||||
|
||||
if (running.port === null || running.port === undefined) {
|
||||
throw new InvalidStateError(
|
||||
`Running instance '${running.id}' for server '${request.serverId}' has no port assigned`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.sendJsonRpc(running, request.method, request.params);
|
||||
}
|
||||
|
||||
private async sendJsonRpc(
|
||||
instance: McpInstance,
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
): Promise<McpProxyResponse> {
|
||||
const url = `http://localhost:${instance.port}`;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method,
|
||||
};
|
||||
if (params !== undefined) {
|
||||
body.params = params;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
error: {
|
||||
code: -32000,
|
||||
message: `MCP server returned HTTP ${response.status}: ${response.statusText}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const result = (await response.json()) as McpProxyResponse;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user