feat: implement v2 3-tier architecture (mcpctl → mcplocal → mcpd)
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled

- 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:
Michal
2026-02-22 11:42:06 +00:00
parent a4fe5fdbe2
commit b8c5cf718a
82 changed files with 5832 additions and 123 deletions

View File

@@ -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"
}

View File

@@ -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 });

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

View File

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

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

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

View File

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

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