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