feat: implement mcpd core server framework with Fastify
Add Fastify server with config validation (Zod), health/healthz endpoints, auth middleware (Bearer token + session lookup), security plugins (CORS, Helmet, rate limiting), error handler, audit logging, and graceful shutdown. 36 tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -158,7 +158,7 @@
|
|||||||
"1",
|
"1",
|
||||||
"2"
|
"2"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [
|
"subtasks": [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
@@ -220,7 +220,8 @@
|
|||||||
"testStrategy": "TDD for all components: error handler HTTP codes, audit middleware creates records, graceful shutdown handles SIGTERM.",
|
"testStrategy": "TDD for all components: error handler HTTP codes, audit middleware creates records, graceful shutdown handles SIGTERM.",
|
||||||
"parentId": "undefined"
|
"parentId": "undefined"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"updatedAt": "2026-02-21T04:21:50.389Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "4",
|
"id": "4",
|
||||||
@@ -730,9 +731,9 @@
|
|||||||
],
|
],
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lastModified": "2026-02-21T04:17:17.744Z",
|
"lastModified": "2026-02-21T04:21:50.389Z",
|
||||||
"taskCount": 24,
|
"taskCount": 24,
|
||||||
"completedCount": 3,
|
"completedCount": 4,
|
||||||
"tags": [
|
"tags": [
|
||||||
"master"
|
"master"
|
||||||
]
|
]
|
||||||
|
|||||||
4
pnpm-lock.yaml
generated
4
pnpm-lock.yaml
generated
@@ -112,6 +112,10 @@ importers:
|
|||||||
zod:
|
zod:
|
||||||
specifier: ^3.24.0
|
specifier: ^3.24.0
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^25.3.0
|
||||||
|
version: 25.3.0
|
||||||
|
|
||||||
src/shared:
|
src/shared:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -14,12 +14,15 @@
|
|||||||
"test:run": "vitest run"
|
"test:run": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fastify": "^5.0.0",
|
|
||||||
"@fastify/cors": "^10.0.0",
|
"@fastify/cors": "^10.0.0",
|
||||||
"@fastify/helmet": "^12.0.0",
|
"@fastify/helmet": "^12.0.0",
|
||||||
"@fastify/rate-limit": "^10.0.0",
|
"@fastify/rate-limit": "^10.0.0",
|
||||||
"zod": "^3.24.0",
|
"@mcpctl/db": "workspace:*",
|
||||||
"@mcpctl/shared": "workspace:*",
|
"@mcpctl/shared": "workspace:*",
|
||||||
"@mcpctl/db": "workspace:*"
|
"fastify": "^5.0.0",
|
||||||
|
"zod": "^3.24.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
src/mcpd/src/config/index.ts
Normal file
2
src/mcpd/src/config/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { McpdConfigSchema, loadConfigFromEnv } from './schema.js';
|
||||||
|
export type { McpdConfig } from './schema.js';
|
||||||
25
src/mcpd/src/config/schema.ts
Normal file
25
src/mcpd/src/config/schema.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const McpdConfigSchema = z.object({
|
||||||
|
port: z.number().int().positive().default(3000),
|
||||||
|
host: z.string().default('0.0.0.0'),
|
||||||
|
databaseUrl: z.string().min(1),
|
||||||
|
logLevel: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||||
|
corsOrigins: z.array(z.string()).default(['*']),
|
||||||
|
rateLimitMax: z.number().int().positive().default(100),
|
||||||
|
rateLimitWindowMs: z.number().int().positive().default(60_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type McpdConfig = z.infer<typeof McpdConfigSchema>;
|
||||||
|
|
||||||
|
export function loadConfigFromEnv(env: Record<string, string | undefined> = process.env): McpdConfig {
|
||||||
|
return McpdConfigSchema.parse({
|
||||||
|
port: env['MCPD_PORT'] !== undefined ? parseInt(env['MCPD_PORT'], 10) : undefined,
|
||||||
|
host: env['MCPD_HOST'],
|
||||||
|
databaseUrl: env['DATABASE_URL'],
|
||||||
|
logLevel: env['MCPD_LOG_LEVEL'],
|
||||||
|
corsOrigins: env['MCPD_CORS_ORIGINS']?.split(',').map((s) => s.trim()),
|
||||||
|
rateLimitMax: env['MCPD_RATE_LIMIT_MAX'] !== undefined ? parseInt(env['MCPD_RATE_LIMIT_MAX'], 10) : undefined,
|
||||||
|
rateLimitWindowMs: env['MCPD_RATE_LIMIT_WINDOW_MS'] !== undefined ? parseInt(env['MCPD_RATE_LIMIT_WINDOW_MS'], 10) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,2 +1,15 @@
|
|||||||
// mcpd daemon server entry point
|
export { createServer } from './server.js';
|
||||||
// Will be implemented in Task 3
|
export type { ServerDeps } from './server.js';
|
||||||
|
export { McpdConfigSchema, loadConfigFromEnv } from './config/index.js';
|
||||||
|
export type { McpdConfig } from './config/index.js';
|
||||||
|
export {
|
||||||
|
createAuthMiddleware,
|
||||||
|
registerSecurityPlugins,
|
||||||
|
errorHandler,
|
||||||
|
registerAuditHook,
|
||||||
|
} from './middleware/index.js';
|
||||||
|
export type { AuthDeps, AuditDeps, ErrorResponse } from './middleware/index.js';
|
||||||
|
export { registerHealthRoutes } from './routes/index.js';
|
||||||
|
export type { HealthDeps } from './routes/index.js';
|
||||||
|
export { setupGracefulShutdown } from './utils/index.js';
|
||||||
|
export type { ShutdownDeps } from './utils/index.js';
|
||||||
|
|||||||
59
src/mcpd/src/middleware/audit.ts
Normal file
59
src/mcpd/src/middleware/audit.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
|
export interface AuditDeps {
|
||||||
|
createAuditLog: (entry: {
|
||||||
|
userId: string;
|
||||||
|
action: string;
|
||||||
|
resource: string;
|
||||||
|
resourceId?: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
}) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAuditHook(app: FastifyInstance, deps: AuditDeps): void {
|
||||||
|
app.addHook('onResponse', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
// Only audit mutating methods on authenticated requests
|
||||||
|
if (request.userId === undefined) return;
|
||||||
|
if (request.method === 'GET' || request.method === 'HEAD' || request.method === 'OPTIONS') return;
|
||||||
|
|
||||||
|
const action = methodToAction(request.method);
|
||||||
|
const { resource, resourceId } = parseRoute(request.url);
|
||||||
|
|
||||||
|
const entry: Parameters<typeof deps.createAuditLog>[0] = {
|
||||||
|
userId: request.userId,
|
||||||
|
action,
|
||||||
|
resource,
|
||||||
|
details: {
|
||||||
|
method: request.method,
|
||||||
|
url: request.url,
|
||||||
|
statusCode: reply.statusCode,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (resourceId !== undefined) {
|
||||||
|
entry.resourceId = resourceId;
|
||||||
|
}
|
||||||
|
await deps.createAuditLog(entry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function methodToAction(method: string): string {
|
||||||
|
switch (method) {
|
||||||
|
case 'POST': return 'CREATE';
|
||||||
|
case 'PUT':
|
||||||
|
case 'PATCH': return 'UPDATE';
|
||||||
|
case 'DELETE': return 'DELETE';
|
||||||
|
default: return method;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRoute(url: string): { resource: string; resourceId: string | undefined } {
|
||||||
|
const parts = url.split('?')[0]?.split('/').filter(Boolean) ?? [];
|
||||||
|
// Pattern: /api/v1/resource/:id
|
||||||
|
if (parts.length >= 3 && parts[0] === 'api') {
|
||||||
|
return { resource: parts[2] ?? 'unknown', resourceId: parts[3] };
|
||||||
|
}
|
||||||
|
if (parts.length >= 1) {
|
||||||
|
return { resource: parts[0] ?? 'unknown', resourceId: parts[1] };
|
||||||
|
}
|
||||||
|
return { resource: 'unknown', resourceId: undefined };
|
||||||
|
}
|
||||||
40
src/mcpd/src/middleware/auth.ts
Normal file
40
src/mcpd/src/middleware/auth.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
|
||||||
|
export interface AuthDeps {
|
||||||
|
findSession: (token: string) => Promise<{ userId: string; expiresAt: Date } | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface FastifyRequest {
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAuthMiddleware(deps: AuthDeps) {
|
||||||
|
return async function authMiddleware(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||||
|
const header = request.headers.authorization;
|
||||||
|
if (header === undefined || !header.startsWith('Bearer ')) {
|
||||||
|
reply.code(401).send({ error: 'Missing or invalid Authorization header' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = header.slice(7);
|
||||||
|
if (token.length === 0) {
|
||||||
|
reply.code(401).send({ error: 'Empty token' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await deps.findSession(token);
|
||||||
|
if (session === null) {
|
||||||
|
reply.code(401).send({ error: 'Invalid token' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.expiresAt < new Date()) {
|
||||||
|
reply.code(401).send({ error: 'Token expired' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
request.userId = session.userId;
|
||||||
|
};
|
||||||
|
}
|
||||||
60
src/mcpd/src/middleware/error-handler.ts
Normal file
60
src/mcpd/src/middleware/error-handler.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
|
|
||||||
|
export interface ErrorResponse {
|
||||||
|
error: string;
|
||||||
|
statusCode: number;
|
||||||
|
details?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorHandler(
|
||||||
|
error: FastifyError,
|
||||||
|
_request: FastifyRequest,
|
||||||
|
reply: FastifyReply,
|
||||||
|
): void {
|
||||||
|
// Zod validation errors
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
reply.code(400).send({
|
||||||
|
error: 'Validation error',
|
||||||
|
statusCode: 400,
|
||||||
|
details: error.issues,
|
||||||
|
} satisfies ErrorResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fastify validation errors (from schema validation)
|
||||||
|
if (error.validation !== undefined) {
|
||||||
|
reply.code(400).send({
|
||||||
|
error: 'Validation error',
|
||||||
|
statusCode: 400,
|
||||||
|
details: error.validation,
|
||||||
|
} satisfies ErrorResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit exceeded
|
||||||
|
if (error.statusCode === 429) {
|
||||||
|
reply.code(429).send({
|
||||||
|
error: 'Rate limit exceeded',
|
||||||
|
statusCode: 429,
|
||||||
|
} satisfies ErrorResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known HTTP errors
|
||||||
|
const statusCode = error.statusCode ?? 500;
|
||||||
|
if (statusCode < 500) {
|
||||||
|
reply.code(statusCode).send({
|
||||||
|
error: error.message,
|
||||||
|
statusCode,
|
||||||
|
} satisfies ErrorResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal server errors — don't leak details
|
||||||
|
reply.log.error(error);
|
||||||
|
reply.code(500).send({
|
||||||
|
error: 'Internal server error',
|
||||||
|
statusCode: 500,
|
||||||
|
} satisfies ErrorResponse);
|
||||||
|
}
|
||||||
7
src/mcpd/src/middleware/index.ts
Normal file
7
src/mcpd/src/middleware/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { createAuthMiddleware } from './auth.js';
|
||||||
|
export type { AuthDeps } from './auth.js';
|
||||||
|
export { registerSecurityPlugins } from './security.js';
|
||||||
|
export { errorHandler } from './error-handler.js';
|
||||||
|
export type { ErrorResponse } from './error-handler.js';
|
||||||
|
export { registerAuditHook } from './audit.js';
|
||||||
|
export type { AuditDeps } from './audit.js';
|
||||||
24
src/mcpd/src/middleware/security.ts
Normal file
24
src/mcpd/src/middleware/security.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import cors from '@fastify/cors';
|
||||||
|
import helmet from '@fastify/helmet';
|
||||||
|
import rateLimit from '@fastify/rate-limit';
|
||||||
|
import type { McpdConfig } from '../config/index.js';
|
||||||
|
|
||||||
|
export async function registerSecurityPlugins(
|
||||||
|
app: FastifyInstance,
|
||||||
|
config: McpdConfig,
|
||||||
|
): Promise<void> {
|
||||||
|
await app.register(cors, {
|
||||||
|
origin: config.corsOrigins,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(helmet, {
|
||||||
|
contentSecurityPolicy: false, // API server, no HTML
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(rateLimit, {
|
||||||
|
max: config.rateLimitMax,
|
||||||
|
timeWindow: config.rateLimitWindowMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
30
src/mcpd/src/routes/health.ts
Normal file
30
src/mcpd/src/routes/health.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { APP_VERSION } from '@mcpctl/shared';
|
||||||
|
|
||||||
|
export interface HealthDeps {
|
||||||
|
checkDb: () => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerHealthRoutes(app: FastifyInstance, deps: HealthDeps): void {
|
||||||
|
app.get('/health', async (_request, reply) => {
|
||||||
|
const dbOk = await deps.checkDb().catch(() => false);
|
||||||
|
|
||||||
|
const status = dbOk ? 'healthy' : 'degraded';
|
||||||
|
const statusCode = dbOk ? 200 : 503;
|
||||||
|
|
||||||
|
reply.code(statusCode).send({
|
||||||
|
status,
|
||||||
|
version: APP_VERSION,
|
||||||
|
uptime: process.uptime(),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
checks: {
|
||||||
|
database: dbOk ? 'ok' : 'error',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simple liveness probe
|
||||||
|
app.get('/healthz', async (_request, reply) => {
|
||||||
|
reply.code(200).send({ status: 'ok' });
|
||||||
|
});
|
||||||
|
}
|
||||||
2
src/mcpd/src/routes/index.ts
Normal file
2
src/mcpd/src/routes/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { registerHealthRoutes } from './health.js';
|
||||||
|
export type { HealthDeps } from './health.js';
|
||||||
34
src/mcpd/src/server.ts
Normal file
34
src/mcpd/src/server.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import Fastify from 'fastify';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { McpdConfig } from './config/index.js';
|
||||||
|
import { registerSecurityPlugins } from './middleware/security.js';
|
||||||
|
import { errorHandler } from './middleware/error-handler.js';
|
||||||
|
import { registerHealthRoutes } from './routes/health.js';
|
||||||
|
import type { HealthDeps } from './routes/health.js';
|
||||||
|
import type { AuthDeps } from './middleware/auth.js';
|
||||||
|
import type { AuditDeps } from './middleware/audit.js';
|
||||||
|
|
||||||
|
export interface ServerDeps {
|
||||||
|
health: HealthDeps;
|
||||||
|
auth?: AuthDeps;
|
||||||
|
audit?: AuditDeps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createServer(config: McpdConfig, deps: ServerDeps): Promise<FastifyInstance> {
|
||||||
|
const app = Fastify({
|
||||||
|
logger: {
|
||||||
|
level: config.logLevel,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handler
|
||||||
|
app.setErrorHandler(errorHandler);
|
||||||
|
|
||||||
|
// Security plugins
|
||||||
|
await registerSecurityPlugins(app, config);
|
||||||
|
|
||||||
|
// Health routes (no auth required)
|
||||||
|
registerHealthRoutes(app, deps.health);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
2
src/mcpd/src/utils/index.ts
Normal file
2
src/mcpd/src/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { setupGracefulShutdown } from './shutdown.js';
|
||||||
|
export type { ShutdownDeps } from './shutdown.js';
|
||||||
33
src/mcpd/src/utils/shutdown.ts
Normal file
33
src/mcpd/src/utils/shutdown.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
|
||||||
|
export interface ShutdownDeps {
|
||||||
|
disconnectDb: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupGracefulShutdown(
|
||||||
|
app: FastifyInstance,
|
||||||
|
deps: ShutdownDeps,
|
||||||
|
processRef: NodeJS.Process = process,
|
||||||
|
): void {
|
||||||
|
let shuttingDown = false;
|
||||||
|
|
||||||
|
const shutdown = async (signal: string): Promise<void> => {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
|
||||||
|
app.log.info(`Received ${signal}, shutting down gracefully...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.close();
|
||||||
|
await deps.disconnectDb();
|
||||||
|
app.log.info('Server shut down successfully');
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(err, 'Error during shutdown');
|
||||||
|
}
|
||||||
|
|
||||||
|
processRef.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
processRef.on('SIGTERM', () => { void shutdown('SIGTERM'); });
|
||||||
|
processRef.on('SIGINT', () => { void shutdown('SIGINT'); });
|
||||||
|
}
|
||||||
102
src/mcpd/tests/audit.test.ts
Normal file
102
src/mcpd/tests/audit.test.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { registerAuditHook } from '../src/middleware/audit.js';
|
||||||
|
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('audit middleware', () => {
|
||||||
|
it('logs mutating requests from authenticated users', async () => {
|
||||||
|
const createAuditLog = vi.fn(async () => {});
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
|
||||||
|
// Simulate authenticated request
|
||||||
|
app.addHook('preHandler', async (request) => {
|
||||||
|
request.userId = 'user-1';
|
||||||
|
});
|
||||||
|
|
||||||
|
registerAuditHook(app, { createAuditLog });
|
||||||
|
|
||||||
|
app.post('/api/v1/servers', async () => ({ ok: true }));
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
await app.inject({ method: 'POST', url: '/api/v1/servers', payload: {} });
|
||||||
|
|
||||||
|
expect(createAuditLog).toHaveBeenCalledOnce();
|
||||||
|
expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
userId: 'user-1',
|
||||||
|
action: 'CREATE',
|
||||||
|
resource: 'servers',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not log GET requests', async () => {
|
||||||
|
const createAuditLog = vi.fn(async () => {});
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
|
||||||
|
app.addHook('preHandler', async (request) => {
|
||||||
|
request.userId = 'user-1';
|
||||||
|
});
|
||||||
|
|
||||||
|
registerAuditHook(app, { createAuditLog });
|
||||||
|
app.get('/api/v1/servers', async () => []);
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
await app.inject({ method: 'GET', url: '/api/v1/servers' });
|
||||||
|
expect(createAuditLog).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not log unauthenticated requests', async () => {
|
||||||
|
const createAuditLog = vi.fn(async () => {});
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
|
||||||
|
registerAuditHook(app, { createAuditLog });
|
||||||
|
app.post('/api/v1/servers', async () => ({ ok: true }));
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
await app.inject({ method: 'POST', url: '/api/v1/servers', payload: {} });
|
||||||
|
expect(createAuditLog).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps DELETE method to DELETE action', async () => {
|
||||||
|
const createAuditLog = vi.fn(async () => {});
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
|
||||||
|
app.addHook('preHandler', async (request) => {
|
||||||
|
request.userId = 'user-1';
|
||||||
|
});
|
||||||
|
|
||||||
|
registerAuditHook(app, { createAuditLog });
|
||||||
|
app.delete('/api/v1/servers/:id', async () => ({ ok: true }));
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
await app.inject({ method: 'DELETE', url: '/api/v1/servers/srv-123' });
|
||||||
|
expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
action: 'DELETE',
|
||||||
|
resource: 'servers',
|
||||||
|
resourceId: 'srv-123',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps PUT/PATCH to UPDATE action', async () => {
|
||||||
|
const createAuditLog = vi.fn(async () => {});
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
|
||||||
|
app.addHook('preHandler', async (request) => {
|
||||||
|
request.userId = 'user-1';
|
||||||
|
});
|
||||||
|
|
||||||
|
registerAuditHook(app, { createAuditLog });
|
||||||
|
app.put('/api/v1/servers/:id', async () => ({ ok: true }));
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
await app.inject({ method: 'PUT', url: '/api/v1/servers/srv-1', payload: {} });
|
||||||
|
expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
action: 'UPDATE',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
101
src/mcpd/tests/auth.test.ts
Normal file
101
src/mcpd/tests/auth.test.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { createAuthMiddleware } from '../src/middleware/auth.js';
|
||||||
|
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupApp(findSession: (token: string) => Promise<{ userId: string; expiresAt: Date } | null>) {
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
const authMiddleware = createAuthMiddleware({ findSession });
|
||||||
|
|
||||||
|
app.addHook('preHandler', authMiddleware);
|
||||||
|
app.get('/protected', async (request) => {
|
||||||
|
return { userId: request.userId };
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.ready();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('auth middleware', () => {
|
||||||
|
it('returns 401 when no Authorization header', async () => {
|
||||||
|
await setupApp(async () => null);
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/protected' });
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(res.json<{ error: string }>().error).toContain('Authorization');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when header is not Bearer', async () => {
|
||||||
|
await setupApp(async () => null);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/protected',
|
||||||
|
headers: { authorization: 'Basic abc123' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when token is empty', async () => {
|
||||||
|
await setupApp(async () => null);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/protected',
|
||||||
|
headers: { authorization: 'Bearer ' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(res.json<{ error: string }>().error).toContain('Empty');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when token not found', async () => {
|
||||||
|
await setupApp(async () => null);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/protected',
|
||||||
|
headers: { authorization: 'Bearer invalid-token' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(res.json<{ error: string }>().error).toContain('Invalid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when token is expired', async () => {
|
||||||
|
const pastDate = new Date(Date.now() - 86400_000);
|
||||||
|
await setupApp(async () => ({ userId: 'user-1', expiresAt: pastDate }));
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/protected',
|
||||||
|
headers: { authorization: 'Bearer expired-token' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(res.json<{ error: string }>().error).toContain('expired');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes valid token and sets userId', async () => {
|
||||||
|
const futureDate = new Date(Date.now() + 86400_000);
|
||||||
|
await setupApp(async () => ({ userId: 'user-42', expiresAt: futureDate }));
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/protected',
|
||||||
|
headers: { authorization: 'Bearer valid-token' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json<{ userId: string }>().userId).toBe('user-42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls findSession with the token', async () => {
|
||||||
|
const findSession = vi.fn(async () => ({
|
||||||
|
userId: 'user-1',
|
||||||
|
expiresAt: new Date(Date.now() + 86400_000),
|
||||||
|
}));
|
||||||
|
await setupApp(findSession);
|
||||||
|
await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/protected',
|
||||||
|
headers: { authorization: 'Bearer my-token' },
|
||||||
|
});
|
||||||
|
expect(findSession).toHaveBeenCalledWith('my-token');
|
||||||
|
});
|
||||||
|
});
|
||||||
81
src/mcpd/tests/config.test.ts
Normal file
81
src/mcpd/tests/config.test.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { McpdConfigSchema, loadConfigFromEnv } from '../src/config/index.js';
|
||||||
|
|
||||||
|
describe('McpdConfigSchema', () => {
|
||||||
|
it('requires databaseUrl', () => {
|
||||||
|
expect(() => McpdConfigSchema.parse({})).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides defaults with minimal input', () => {
|
||||||
|
const config = McpdConfigSchema.parse({ databaseUrl: 'postgresql://localhost/test' });
|
||||||
|
expect(config.port).toBe(3000);
|
||||||
|
expect(config.host).toBe('0.0.0.0');
|
||||||
|
expect(config.logLevel).toBe('info');
|
||||||
|
expect(config.corsOrigins).toEqual(['*']);
|
||||||
|
expect(config.rateLimitMax).toBe(100);
|
||||||
|
expect(config.rateLimitWindowMs).toBe(60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates full config', () => {
|
||||||
|
const config = McpdConfigSchema.parse({
|
||||||
|
port: 4000,
|
||||||
|
host: '127.0.0.1',
|
||||||
|
databaseUrl: 'postgresql://localhost/test',
|
||||||
|
logLevel: 'debug',
|
||||||
|
corsOrigins: ['http://localhost:3000'],
|
||||||
|
rateLimitMax: 50,
|
||||||
|
rateLimitWindowMs: 30_000,
|
||||||
|
});
|
||||||
|
expect(config.port).toBe(4000);
|
||||||
|
expect(config.logLevel).toBe('debug');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid log level', () => {
|
||||||
|
expect(() => McpdConfigSchema.parse({
|
||||||
|
databaseUrl: 'postgresql://localhost/test',
|
||||||
|
logLevel: 'verbose',
|
||||||
|
})).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects zero port', () => {
|
||||||
|
expect(() => McpdConfigSchema.parse({
|
||||||
|
databaseUrl: 'postgresql://localhost/test',
|
||||||
|
port: 0,
|
||||||
|
})).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadConfigFromEnv', () => {
|
||||||
|
it('loads config from environment variables', () => {
|
||||||
|
const config = loadConfigFromEnv({
|
||||||
|
DATABASE_URL: 'postgresql://localhost/test',
|
||||||
|
MCPD_PORT: '4000',
|
||||||
|
MCPD_HOST: '127.0.0.1',
|
||||||
|
MCPD_LOG_LEVEL: 'debug',
|
||||||
|
});
|
||||||
|
expect(config.port).toBe(4000);
|
||||||
|
expect(config.host).toBe('127.0.0.1');
|
||||||
|
expect(config.databaseUrl).toBe('postgresql://localhost/test');
|
||||||
|
expect(config.logLevel).toBe('debug');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses defaults for missing env vars', () => {
|
||||||
|
const config = loadConfigFromEnv({
|
||||||
|
DATABASE_URL: 'postgresql://localhost/test',
|
||||||
|
});
|
||||||
|
expect(config.port).toBe(3000);
|
||||||
|
expect(config.host).toBe('0.0.0.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses CORS origins from comma-separated string', () => {
|
||||||
|
const config = loadConfigFromEnv({
|
||||||
|
DATABASE_URL: 'postgresql://localhost/test',
|
||||||
|
MCPD_CORS_ORIGINS: 'http://a.com, http://b.com',
|
||||||
|
});
|
||||||
|
expect(config.corsOrigins).toEqual(['http://a.com', 'http://b.com']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when DATABASE_URL is missing', () => {
|
||||||
|
expect(() => loadConfigFromEnv({})).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
72
src/mcpd/tests/error-handler.test.ts
Normal file
72
src/mcpd/tests/error-handler.test.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { ZodError, z } from 'zod';
|
||||||
|
import { errorHandler } from '../src/middleware/error-handler.js';
|
||||||
|
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupApp() {
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
app.setErrorHandler(errorHandler);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('errorHandler', () => {
|
||||||
|
it('returns 400 for ZodError', async () => {
|
||||||
|
const a = setupApp();
|
||||||
|
a.get('/test', async () => {
|
||||||
|
z.object({ name: z.string() }).parse({});
|
||||||
|
});
|
||||||
|
await a.ready();
|
||||||
|
|
||||||
|
const res = await a.inject({ method: 'GET', url: '/test' });
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
const body = res.json<{ error: string; details: unknown[] }>();
|
||||||
|
expect(body.error).toBe('Validation error');
|
||||||
|
expect(body.details).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 for unknown errors and hides details', async () => {
|
||||||
|
const a = setupApp();
|
||||||
|
a.get('/test', async () => {
|
||||||
|
throw new Error('secret database password leaked');
|
||||||
|
});
|
||||||
|
await a.ready();
|
||||||
|
|
||||||
|
const res = await a.inject({ method: 'GET', url: '/test' });
|
||||||
|
expect(res.statusCode).toBe(500);
|
||||||
|
const body = res.json<{ error: string }>();
|
||||||
|
expect(body.error).toBe('Internal server error');
|
||||||
|
expect(JSON.stringify(body)).not.toContain('secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct status for HTTP errors', async () => {
|
||||||
|
const a = setupApp();
|
||||||
|
a.get('/test', async (_req, reply) => {
|
||||||
|
reply.code(404).send({ error: 'Not found', statusCode: 404 });
|
||||||
|
});
|
||||||
|
await a.ready();
|
||||||
|
|
||||||
|
const res = await a.inject({ method: 'GET', url: '/test' });
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 429 for rate limit errors', async () => {
|
||||||
|
const a = setupApp();
|
||||||
|
a.get('/test', async () => {
|
||||||
|
const err = new Error('Rate limit') as Error & { statusCode: number };
|
||||||
|
err.statusCode = 429;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
await a.ready();
|
||||||
|
|
||||||
|
const res = await a.inject({ method: 'GET', url: '/test' });
|
||||||
|
expect(res.statusCode).toBe(429);
|
||||||
|
expect(res.json<{ error: string }>().error).toBe('Rate limit exceeded');
|
||||||
|
});
|
||||||
|
});
|
||||||
71
src/mcpd/tests/health.test.ts
Normal file
71
src/mcpd/tests/health.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { registerHealthRoutes } from '../src/routes/health.js';
|
||||||
|
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /health', () => {
|
||||||
|
it('returns healthy when DB is up', async () => {
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
registerHealthRoutes(app, { checkDb: async () => true });
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/health' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json<{ status: string; version: string; checks: { database: string } }>();
|
||||||
|
expect(body.status).toBe('healthy');
|
||||||
|
expect(body.version).toBeDefined();
|
||||||
|
expect(body.checks.database).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns degraded when DB is down', async () => {
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
registerHealthRoutes(app, { checkDb: async () => false });
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/health' });
|
||||||
|
expect(res.statusCode).toBe(503);
|
||||||
|
const body = res.json<{ status: string; checks: { database: string } }>();
|
||||||
|
expect(body.status).toBe('degraded');
|
||||||
|
expect(body.checks.database).toBe('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns degraded when DB check throws', async () => {
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
registerHealthRoutes(app, {
|
||||||
|
checkDb: async () => { throw new Error('connection refused'); },
|
||||||
|
});
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/health' });
|
||||||
|
expect(res.statusCode).toBe(503);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes uptime and timestamp', async () => {
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
registerHealthRoutes(app, { checkDb: async () => true });
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/health' });
|
||||||
|
const body = res.json<{ uptime: number; timestamp: string }>();
|
||||||
|
expect(body.uptime).toBeGreaterThan(0);
|
||||||
|
expect(body.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /healthz', () => {
|
||||||
|
it('returns ok (liveness probe)', async () => {
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
registerHealthRoutes(app, { checkDb: async () => true });
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/healthz' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json<{ status: string }>().status).toBe('ok');
|
||||||
|
});
|
||||||
|
});
|
||||||
83
src/mcpd/tests/server.test.ts
Normal file
83
src/mcpd/tests/server.test.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { createServer } from '../src/server.js';
|
||||||
|
import type { McpdConfig } from '../src/config/index.js';
|
||||||
|
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const testConfig: McpdConfig = {
|
||||||
|
port: 3000,
|
||||||
|
host: '0.0.0.0',
|
||||||
|
databaseUrl: 'postgresql://localhost/test',
|
||||||
|
logLevel: 'fatal', // suppress logs in tests
|
||||||
|
corsOrigins: ['*'],
|
||||||
|
rateLimitMax: 100,
|
||||||
|
rateLimitWindowMs: 60_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('createServer', () => {
|
||||||
|
it('creates a Fastify instance', async () => {
|
||||||
|
app = await createServer(testConfig, {
|
||||||
|
health: { checkDb: async () => true },
|
||||||
|
});
|
||||||
|
expect(app).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers health endpoint', async () => {
|
||||||
|
app = await createServer(testConfig, {
|
||||||
|
health: { checkDb: async () => true },
|
||||||
|
});
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/health' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers healthz endpoint', async () => {
|
||||||
|
app = await createServer(testConfig, {
|
||||||
|
health: { checkDb: async () => true },
|
||||||
|
});
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/healthz' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for unknown routes', async () => {
|
||||||
|
app = await createServer(testConfig, {
|
||||||
|
health: { checkDb: async () => true },
|
||||||
|
});
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/nonexistent' });
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes CORS headers', async () => {
|
||||||
|
app = await createServer(testConfig, {
|
||||||
|
health: { checkDb: async () => true },
|
||||||
|
});
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'OPTIONS',
|
||||||
|
url: '/health',
|
||||||
|
headers: { origin: 'http://localhost:3000' },
|
||||||
|
});
|
||||||
|
expect(res.headers['access-control-allow-origin']).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes security headers from Helmet', async () => {
|
||||||
|
app = await createServer(testConfig, {
|
||||||
|
health: { checkDb: async () => true },
|
||||||
|
});
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/health' });
|
||||||
|
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"outDir": "dist"
|
"outDir": "dist",
|
||||||
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"references": [
|
"references": [
|
||||||
|
|||||||
Reference in New Issue
Block a user