diff --git a/components/mcp/index.ts b/components/mcp/index.ts new file mode 100644 index 000000000..08503d7ee --- /dev/null +++ b/components/mcp/index.ts @@ -0,0 +1,80 @@ +/** + * Native MCP (Model Context Protocol) server component for Harper. + * + * Foundation PR (#613): exports a presence-gated registration hook used by + * the operations and HTTP host servers. The hook installs a placeholder + * route that returns HTTP 503 with body `{ error: 'mcp_not_implemented', + * profile }` until the Streamable HTTP transport lands in #614. A profile + * is enabled when its sub-block exists in config (matches Harper's + * `replication` convention — no explicit `enabled` flag). Tracking: #465. + */ +import harperLogger from '../../utility/logging/harper_logger.ts'; + +export type McpProfile = 'operations' | 'application'; + +interface McpProfileConfig { + mountPath?: string; +} + +interface FullConfig { + mcp?: { + operations?: McpProfileConfig; + application?: McpProfileConfig; + }; +} + +interface FastifyLike { + post: (path: string, ...rest: unknown[]) => unknown; +} + +export interface RegisterMcpProfileArgs { + profile: McpProfile; + host: FastifyLike; + config: FullConfig; + /** Route-level options forwarded to the host's `post(path, options, handler)` 3-arg form (e.g., Fastify `preValidation`). */ + routeOptions?: Record; +} + +const DEFAULT_MOUNT_PATH = '/mcp'; + +/** + * Register the MCP profile on its host server when enabled in config. + * + * The stub responder is intentionally minimal — sub-issue #614 replaces it + * with the real Streamable HTTP transport without changing this gate. + */ +export function registerMcpProfile({ profile, host, config, routeOptions }: RegisterMcpProfileArgs): void { + const profileConfig = config?.mcp?.[profile]; + if (!profileConfig) { + harperLogger.trace(`MCP ${profile} profile not configured, skipping registration`); + return; + } + + const mountPath = profileConfig.mountPath ?? DEFAULT_MOUNT_PATH; + const handler = createStubHandler(profile); + if (routeOptions) { + host.post(mountPath, routeOptions, handler); + } else { + host.post(mountPath, handler); + } + harperLogger.info(`MCP ${profile} profile registered at ${mountPath}`); +} + +/** + * Builds the placeholder 503 handler. Returned function is Fastify-compatible: + * `(request, reply)` where `reply` exposes `code()`, `header()`, and `send()`. + */ +export function createStubHandler(profile: McpProfile) { + return async function mcpStubHandler(_request: unknown, reply: McpReply): Promise { + reply.code(503); + reply.header('Retry-After', '0'); + reply.header('Content-Type', 'application/json'); + reply.send({ error: 'mcp_not_implemented', profile }); + }; +} + +interface McpReply { + code: (status: number) => McpReply; + header: (name: string, value: string) => McpReply; + send: (body: unknown) => McpReply; +} diff --git a/server/operationsServer.ts b/server/operationsServer.ts index 5406bc02b..cdf446323 100644 --- a/server/operationsServer.ts +++ b/server/operationsServer.ts @@ -25,6 +25,8 @@ import { } from './serverHelpers/serverHandlers.js'; import { registerBunFastifyInstance } from './http.ts'; import { registerContentHandlers } from './serverHelpers/contentTypes.ts'; +import { getConfigObj } from '../config/configUtils.js'; +import { registerMcpProfile } from '../components/mcp/index.ts'; import type { OperationFunctionName } from './serverHelpers/serverUtilities.ts'; type ParsedSqlObject = any; import { generateJsonApi } from '../resources/openApi.ts'; @@ -183,6 +185,21 @@ function buildServer(isHttps: boolean, resources: Resources): FastifyInstance { }); registerContentHandlers(app); + // Presence-based enablement (matches Harper's `replication` convention): + // register iff `mcp.operations` is present in the merged config. The + // nested config tree from `getConfigObj()` is used directly here because + // Joi defaults under `mcp` are not propagated to env.get's flat map — + // only six hardcoded defaults are re-applied in configUtils.validateConfig. + const fullConfig = getConfigObj() ?? {}; + if (fullConfig.mcp?.operations) { + registerMcpProfile({ + profile: 'operations', + host: app, + config: fullConfig, + routeOptions: { preValidation: [authHandler] }, + }); + } + // Add a simple health check app.get('/health', () => 'Harper is running.'); diff --git a/unitTests/components/mcp/index.test.js b/unitTests/components/mcp/index.test.js new file mode 100644 index 000000000..c244c0656 --- /dev/null +++ b/unitTests/components/mcp/index.test.js @@ -0,0 +1,114 @@ +const assert = require('node:assert/strict'); +const { registerMcpProfile, createStubHandler } = require('#src/components/mcp/index'); + +function makeFakeFastify() { + const calls = []; + return { + calls, + post(path, optsOrHandler, maybeHandler) { + if (typeof optsOrHandler === 'function') { + calls.push({ path, options: undefined, handler: optsOrHandler }); + } else { + calls.push({ path, options: optsOrHandler, handler: maybeHandler }); + } + }, + }; +} + +function makeFakeReply() { + const reply = { + statusCode: undefined, + headers: {}, + body: undefined, + code(status) { + this.statusCode = status; + return this; + }, + header(name, value) { + this.headers[name] = value; + return this; + }, + send(payload) { + this.body = payload; + return this; + }, + }; + return reply; +} + +describe('components/mcp/index', () => { + describe('registerMcpProfile', () => { + it('does nothing when the mcp config block is absent', () => { + const host = makeFakeFastify(); + registerMcpProfile({ profile: 'operations', host, config: {} }); + assert.equal(host.calls.length, 0); + }); + + it('does nothing when the profile sub-block is absent under mcp', () => { + const host = makeFakeFastify(); + registerMcpProfile({ + profile: 'operations', + host, + config: { mcp: { application: { mountPath: '/x' } } }, + }); + assert.equal(host.calls.length, 0); + }); + + it('registers POST /mcp when the operations profile block is present', () => { + const host = makeFakeFastify(); + registerMcpProfile({ + profile: 'operations', + host, + config: { mcp: { operations: {} } }, + }); + assert.equal(host.calls.length, 1); + assert.equal(host.calls[0].path, '/mcp'); + assert.equal(typeof host.calls[0].handler, 'function'); + }); + + it('honors a custom mountPath', () => { + const host = makeFakeFastify(); + registerMcpProfile({ + profile: 'application', + host, + config: { mcp: { application: { mountPath: '/agent' } } }, + }); + assert.equal(host.calls.length, 1); + assert.equal(host.calls[0].path, '/agent'); + }); + + it('forwards routeOptions to the host as the second argument', () => { + const host = makeFakeFastify(); + const sentinel = { preValidation: ['fake-auth-handler'] }; + registerMcpProfile({ + profile: 'operations', + host, + config: { mcp: { operations: {} } }, + routeOptions: sentinel, + }); + assert.equal(host.calls.length, 1); + assert.deepEqual(host.calls[0].options, sentinel); + assert.equal(typeof host.calls[0].handler, 'function'); + }); + }); + + describe('stub handler', () => { + it('returns 503 with mcp_not_implemented body for the operations profile', async () => { + const handler = createStubHandler('operations'); + const reply = makeFakeReply(); + await handler({}, reply); + assert.equal(reply.statusCode, 503); + assert.equal(reply.headers['Retry-After'], '0'); + assert.equal(reply.headers['Content-Type'], 'application/json'); + assert.deepEqual(reply.body, { error: 'mcp_not_implemented', profile: 'operations' }); + }); + + it('returns 503 with mcp_not_implemented body for the application profile', async () => { + const handler = createStubHandler('application'); + const reply = makeFakeReply(); + await handler({}, reply); + assert.equal(reply.statusCode, 503); + assert.deepEqual(reply.body, { error: 'mcp_not_implemented', profile: 'application' }); + }); + }); +}); diff --git a/unitTests/validation/configValidator.test.js b/unitTests/validation/configValidator.test.js index b17f56490..cb9ab345c 100644 --- a/unitTests/validation/configValidator.test.js +++ b/unitTests/validation/configValidator.test.js @@ -386,6 +386,75 @@ describe('Test configValidator module', () => { ); }); + describe('mcp config', () => { + it('validates clean when the mcp block is absent (profile off)', () => { + const result = configValidator(testUtils.deepClone(FAKE_CONFIG), true); + expect(result.error).to.be.undefined; + expect(result.value.mcp).to.be.undefined; + }); + + it('applies the default mountPath when mcp.operations is present but empty', () => { + const config = testUtils.deepClone(FAKE_CONFIG); + config.mcp = { operations: {} }; + const result = configValidator(config, true); + expect(result.error).to.be.undefined; + expect(result.value.mcp.operations.mountPath).to.equal('/mcp'); + }); + + it('validates clean when both profile blocks are supplied with full keys', () => { + const config = testUtils.deepClone(FAKE_CONFIG); + config.mcp = { + operations: { + mountPath: '/mcp', + allow: ['describe_*', 'list_*'], + deny: [], + maxTools: 200, + rateLimit: { + perToolPerSecond: 10, + perToolBurst: 20, + sessionConcurrency: 25, + sessionPerSecond: 100, + }, + }, + application: { + mountPath: '/mcp', + allow: [], + deny: [], + maxTools: 500, + searchMaxResults: 100, + rateLimit: { + perToolPerSecond: 25, + perToolBurst: 50, + sessionConcurrency: 50, + sessionPerSecond: 200, + }, + }, + session: { + idleTimeoutSeconds: 1800, + allowClientDelete: true, + }, + }; + const result = configValidator(config, true); + expect(result.error).to.be.undefined; + }); + + it('rejects mcp.operations.mountPath with a non-string', () => { + const config = testUtils.deepClone(FAKE_CONFIG); + config.mcp = { operations: { mountPath: 42 } }; + const result = configValidator(config, true); + expect(result.error).to.not.be.undefined; + expect(result.error.message).to.include("'mcp.operations.mountPath' must be a string"); + }); + + it('rejects mcp.operations.maxTools below 1', () => { + const config = testUtils.deepClone(FAKE_CONFIG); + config.mcp = { operations: { maxTools: 0 } }; + const result = configValidator(config, true); + expect(result.error).to.not.be.undefined; + expect(result.error.message).to.include("'mcp.operations.maxTools' must be greater than or equal to 1"); + }); + }); + // #629 (Phase 2 of #510): models config block. describe('models config', () => { function baseConfig() { diff --git a/utility/hdbTerms.ts b/utility/hdbTerms.ts index 26f2b9e55..b8f19f9c7 100644 --- a/utility/hdbTerms.ts +++ b/utility/hdbTerms.ts @@ -524,6 +524,25 @@ export const CONFIG_PARAMS = { OPERATIONSAPI_NETWORK_TIMEOUT: 'operationsApi_network_timeout', OPERATIONSAPI_SYSINFO_NETWORK: 'operationsApi_sysInfo_network', OPERATIONSAPI_SYSINFO_DISK: 'operationsApi_sysInfo_disk', + MCP_OPERATIONS_MOUNTPATH: 'mcp_operations_mountPath', + MCP_OPERATIONS_ALLOW: 'mcp_operations_allow', + MCP_OPERATIONS_DENY: 'mcp_operations_deny', + MCP_OPERATIONS_MAXTOOLS: 'mcp_operations_maxTools', + MCP_OPERATIONS_RATELIMIT_PERTOOLPERSECOND: 'mcp_operations_rateLimit_perToolPerSecond', + MCP_OPERATIONS_RATELIMIT_PERTOOLBURST: 'mcp_operations_rateLimit_perToolBurst', + MCP_OPERATIONS_RATELIMIT_SESSIONCONCURRENCY: 'mcp_operations_rateLimit_sessionConcurrency', + MCP_OPERATIONS_RATELIMIT_SESSIONPERSECOND: 'mcp_operations_rateLimit_sessionPerSecond', + MCP_APPLICATION_MOUNTPATH: 'mcp_application_mountPath', + MCP_APPLICATION_ALLOW: 'mcp_application_allow', + MCP_APPLICATION_DENY: 'mcp_application_deny', + MCP_APPLICATION_MAXTOOLS: 'mcp_application_maxTools', + MCP_APPLICATION_SEARCHMAXRESULTS: 'mcp_application_searchMaxResults', + MCP_APPLICATION_RATELIMIT_PERTOOLPERSECOND: 'mcp_application_rateLimit_perToolPerSecond', + MCP_APPLICATION_RATELIMIT_PERTOOLBURST: 'mcp_application_rateLimit_perToolBurst', + MCP_APPLICATION_RATELIMIT_SESSIONCONCURRENCY: 'mcp_application_rateLimit_sessionConcurrency', + MCP_APPLICATION_RATELIMIT_SESSIONPERSECOND: 'mcp_application_rateLimit_sessionPerSecond', + MCP_SESSION_IDLETIMEOUTSECONDS: 'mcp_session_idleTimeoutSeconds', + MCP_SESSION_ALLOWCLIENTDELETE: 'mcp_session_allowClientDelete', REPLICATION: 'replication', REPLICATION_HOSTNAME: 'replication_hostname', REPLICATION_URL: 'replication_url', diff --git a/validation/configValidator.ts b/validation/configValidator.ts index 6d18bab37..75047454a 100644 --- a/validation/configValidator.ts +++ b/validation/configValidator.ts @@ -66,6 +66,35 @@ export function configValidator(configJson, skipFsValidation = false) { privateKey: pemFileConstraints, }); + // MCP — sub-issue #613 lands the config surface ahead of the transport (#614). + // Presence-based enablement: a profile is on iff its sub-block exists in + // config (same convention as `replication`). No `enabled` field. + const mcpRateLimitSchema = Joi.object({ + perToolPerSecond: number.min(0).optional(), + perToolBurst: number.min(0).optional(), + sessionConcurrency: number.min(0).optional(), + sessionPerSecond: number.min(0).optional(), + }); + const mcpOperationsSchema = Joi.object({ + mountPath: string.optional().default('/mcp'), + allow: array.items(string).optional(), + deny: array.items(string).optional(), + maxTools: number.min(1).optional(), + rateLimit: mcpRateLimitSchema.optional(), + }); + const mcpApplicationSchema = mcpOperationsSchema.keys({ + searchMaxResults: number.min(1).optional(), + }); + const mcpSessionSchema = Joi.object({ + idleTimeoutSeconds: number.min(1).optional(), + allowClientDelete: boolean.optional(), + }); + const mcpSchema = Joi.object({ + operations: mcpOperationsSchema.optional(), + application: mcpApplicationSchema.optional(), + session: mcpSessionSchema.optional(), + }); + // Models — `models:` block opts a deployment into the per-backend registry. // Per-backend shape is validated by a discriminated alternative on the // `backend` field. Phase 2 (#629) lands ollama; Phase 3 (#630) lands openai. @@ -246,6 +275,7 @@ export function configValidator(configJson, skipFsValidation = false) { maxFreeSpaceToLoad: number.optional(), maxFreeSpaceToRetain: number.optional(), }).required(), + mcp: mcpSchema.optional(), models: modelsSchema.optional(), ignoreScripts: boolean.optional(), tls: Joi.alternatives([Joi.array().items(tlsConstraints), tlsConstraints]),