Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions components/mcp/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}

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<void> {
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;
}
17 changes: 17 additions & 0 deletions server/operationsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.');

Expand Down
114 changes: 114 additions & 0 deletions unitTests/components/mcp/index.test.js
Original file line number Diff line number Diff line change
@@ -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' });
});
});
});
69 changes: 69 additions & 0 deletions unitTests/validation/configValidator.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
19 changes: 19 additions & 0 deletions utility/hdbTerms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
30 changes: 30 additions & 0 deletions validation/configValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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]),
Expand Down
Loading