Skip to content

Commit 169376e

Browse files
kylebernhardyclaude
andcommitted
[MCP] foundation: component scaffold + config schema + boot gating
Lands the empty MCP component, the mcp: config block, and a config-gated registration hook in the operations server. Until #614 ships the Streamable HTTP transport, flipping mcp.operations.enabled: true serves a 503 with `{error:"mcp_not_implemented", profile:"operations"}` from the configured mountPath — enough to prove the gate end-to-end. The application-profile call site is intentionally deferred to #614, where the transport will register itself via server.http(...). The shared registerMcpProfile() supports both profiles already (verified by tests). Defaults: mcp.{operations,application}.enabled: false. Existing deployments see no change on upgrade. Closes #613 Tracking: #465 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent ff3db79 commit 169376e

7 files changed

Lines changed: 340 additions & 0 deletions

File tree

components/mcp/index.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Native MCP (Model Context Protocol) server component for Harper.
3+
*
4+
* Foundation PR (#613): exports a config-gated registration hook used by the
5+
* operations and HTTP host servers. The hook installs a placeholder route
6+
* that returns HTTP 503 with body `{ error: 'mcp_not_implemented', profile }`
7+
* until the Streamable HTTP transport lands in #614. Tracking: #465.
8+
*/
9+
import harperLogger from '../../utility/logging/harper_logger.ts';
10+
11+
export type McpProfile = 'operations' | 'application';
12+
13+
interface McpProfileConfig {
14+
enabled?: boolean;
15+
mountPath?: string;
16+
}
17+
18+
interface FullConfig {
19+
mcp?: {
20+
operations?: McpProfileConfig;
21+
application?: McpProfileConfig;
22+
};
23+
}
24+
25+
interface FastifyLike {
26+
post: (path: string, ...rest: unknown[]) => unknown;
27+
}
28+
29+
export interface RegisterMcpProfileArgs {
30+
profile: McpProfile;
31+
host: FastifyLike;
32+
config: FullConfig;
33+
}
34+
35+
const DEFAULT_MOUNT_PATH = '/mcp';
36+
37+
/**
38+
* Register the MCP profile on its host server when enabled in config.
39+
*
40+
* The stub responder is intentionally minimal — sub-issue #614 replaces it
41+
* with the real Streamable HTTP transport without changing this gate.
42+
*/
43+
export function registerMcpProfile({ profile, host, config }: RegisterMcpProfileArgs): void {
44+
const profileConfig = config?.mcp?.[profile];
45+
if (!profileConfig?.enabled) {
46+
harperLogger.trace(`MCP ${profile} profile disabled, skipping registration`);
47+
return;
48+
}
49+
50+
const mountPath = profileConfig.mountPath ?? DEFAULT_MOUNT_PATH;
51+
host.post(mountPath, createStubHandler(profile));
52+
harperLogger.info(`MCP ${profile} profile registered at ${mountPath}`);
53+
}
54+
55+
/**
56+
* Builds the placeholder 503 handler. Returned function is Fastify-compatible:
57+
* `(request, reply)` where `reply` exposes `code()`, `header()`, and `send()`.
58+
*/
59+
export function createStubHandler(profile: McpProfile) {
60+
return async function mcpStubHandler(_request: unknown, reply: McpReply): Promise<void> {
61+
reply.code(503);
62+
reply.header('Retry-After', '0');
63+
reply.header('Content-Type', 'application/json');
64+
reply.send({ error: 'mcp_not_implemented', profile });
65+
};
66+
}
67+
68+
interface McpReply {
69+
code: (status: number) => McpReply;
70+
header: (name: string, value: string) => McpReply;
71+
send: (body: unknown) => McpReply;
72+
}

server/operationsServer.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
} from './serverHelpers/serverHandlers.js';
2626
import { registerBunFastifyInstance } from './http.ts';
2727
import { registerContentHandlers } from './serverHelpers/contentTypes.ts';
28+
import { registerMcpProfile } from '../components/mcp/index.ts';
2829
import type { OperationFunctionName } from './serverHelpers/serverUtilities.ts';
2930
type ParsedSqlObject = any;
3031
import { generateJsonApi } from '../resources/openApi.ts';
@@ -181,6 +182,21 @@ function buildServer(isHttps: boolean, resources: Resources): FastifyInstance {
181182
});
182183
registerContentHandlers(app);
183184

185+
if (env.get(terms.CONFIG_PARAMS.MCP_OPERATIONS_ENABLED)) {
186+
registerMcpProfile({
187+
profile: 'operations',
188+
host: app,
189+
config: {
190+
mcp: {
191+
operations: {
192+
enabled: true,
193+
mountPath: env.get(terms.CONFIG_PARAMS.MCP_OPERATIONS_MOUNTPATH) ?? '/mcp',
194+
},
195+
},
196+
},
197+
});
198+
}
199+
184200
// Add a simple health check
185201
app.get('/health', () => 'Harper is running.');
186202

static/defaultConfig.yaml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,37 @@ tls:
7777
privateKey: null
7878
node:
7979
hostname: null
80+
mcp:
81+
operations:
82+
enabled: false
83+
mountPath: /mcp
84+
allow:
85+
- describe_*
86+
- list_*
87+
- search_*
88+
- get_*
89+
- system_information
90+
- read_log
91+
- read_audit_log
92+
deny: []
93+
maxTools: 200
94+
rateLimit:
95+
perToolPerSecond: 10
96+
perToolBurst: 20
97+
sessionConcurrency: 25
98+
sessionPerSecond: 100
99+
application:
100+
enabled: false
101+
mountPath: /mcp
102+
allow: []
103+
deny: []
104+
maxTools: 500
105+
searchMaxResults: 100
106+
rateLimit:
107+
perToolPerSecond: 25
108+
perToolBurst: 50
109+
sessionConcurrency: 50
110+
sessionPerSecond: 200
111+
session:
112+
idleTimeoutSeconds: 1800
113+
allowClientDelete: true
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
const assert = require('node:assert/strict');
2+
const { registerMcpProfile, createStubHandler } = require('#src/components/mcp/index');
3+
4+
function makeFakeFastify() {
5+
const calls = [];
6+
return {
7+
calls,
8+
post(path, handler) {
9+
calls.push({ path, handler });
10+
},
11+
};
12+
}
13+
14+
function makeFakeReply() {
15+
const reply = {
16+
statusCode: undefined,
17+
headers: {},
18+
body: undefined,
19+
code(status) {
20+
this.statusCode = status;
21+
return this;
22+
},
23+
header(name, value) {
24+
this.headers[name] = value;
25+
return this;
26+
},
27+
send(payload) {
28+
this.body = payload;
29+
return this;
30+
},
31+
};
32+
return reply;
33+
}
34+
35+
describe('components/mcp/index', () => {
36+
describe('registerMcpProfile', () => {
37+
it('does nothing when the profile is disabled', () => {
38+
const host = makeFakeFastify();
39+
registerMcpProfile({
40+
profile: 'operations',
41+
host,
42+
config: { mcp: { operations: { enabled: false } } },
43+
});
44+
assert.equal(host.calls.length, 0);
45+
});
46+
47+
it('does nothing when the mcp config block is absent', () => {
48+
const host = makeFakeFastify();
49+
registerMcpProfile({ profile: 'operations', host, config: {} });
50+
assert.equal(host.calls.length, 0);
51+
});
52+
53+
it('registers POST /mcp when operations profile is enabled with defaults', () => {
54+
const host = makeFakeFastify();
55+
registerMcpProfile({
56+
profile: 'operations',
57+
host,
58+
config: { mcp: { operations: { enabled: true } } },
59+
});
60+
assert.equal(host.calls.length, 1);
61+
assert.equal(host.calls[0].path, '/mcp');
62+
assert.equal(typeof host.calls[0].handler, 'function');
63+
});
64+
65+
it('honors a custom mountPath', () => {
66+
const host = makeFakeFastify();
67+
registerMcpProfile({
68+
profile: 'application',
69+
host,
70+
config: { mcp: { application: { enabled: true, mountPath: '/agent' } } },
71+
});
72+
assert.equal(host.calls.length, 1);
73+
assert.equal(host.calls[0].path, '/agent');
74+
});
75+
});
76+
77+
describe('stub handler', () => {
78+
it('returns 503 with mcp_not_implemented body for the operations profile', async () => {
79+
const handler = createStubHandler('operations');
80+
const reply = makeFakeReply();
81+
await handler({}, reply);
82+
assert.equal(reply.statusCode, 503);
83+
assert.equal(reply.headers['Retry-After'], '0');
84+
assert.equal(reply.headers['Content-Type'], 'application/json');
85+
assert.deepEqual(reply.body, { error: 'mcp_not_implemented', profile: 'operations' });
86+
});
87+
88+
it('returns 503 with mcp_not_implemented body for the application profile', async () => {
89+
const handler = createStubHandler('application');
90+
const reply = makeFakeReply();
91+
await handler({}, reply);
92+
assert.equal(reply.statusCode, 503);
93+
assert.deepEqual(reply.body, { error: 'mcp_not_implemented', profile: 'application' });
94+
});
95+
});
96+
});

unitTests/validation/configValidator.test.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,4 +385,75 @@ describe('Test configValidator module', () => {
385385
"Invalid logging.rotation.interval value. Value should be a number followed by unit e.g. '10D'"
386386
);
387387
});
388+
389+
describe('mcp config', () => {
390+
it('validates clean when mcp block is absent', () => {
391+
const result = configValidator(testUtils.deepClone(FAKE_CONFIG), true);
392+
expect(result.error).to.be.undefined;
393+
});
394+
395+
it('validates clean and applies defaults when only mcp.operations.enabled is given', () => {
396+
const config = testUtils.deepClone(FAKE_CONFIG);
397+
config.mcp = { operations: { enabled: false } };
398+
const result = configValidator(config, true);
399+
expect(result.error).to.be.undefined;
400+
expect(result.value.mcp.operations.enabled).to.equal(false);
401+
expect(result.value.mcp.operations.mountPath).to.equal('/mcp');
402+
});
403+
404+
it('validates clean when the full mcp block from defaults is supplied', () => {
405+
const config = testUtils.deepClone(FAKE_CONFIG);
406+
config.mcp = {
407+
operations: {
408+
enabled: false,
409+
mountPath: '/mcp',
410+
allow: ['describe_*', 'list_*'],
411+
deny: [],
412+
maxTools: 200,
413+
rateLimit: {
414+
perToolPerSecond: 10,
415+
perToolBurst: 20,
416+
sessionConcurrency: 25,
417+
sessionPerSecond: 100,
418+
},
419+
},
420+
application: {
421+
enabled: false,
422+
mountPath: '/mcp',
423+
allow: [],
424+
deny: [],
425+
maxTools: 500,
426+
searchMaxResults: 100,
427+
rateLimit: {
428+
perToolPerSecond: 25,
429+
perToolBurst: 50,
430+
sessionConcurrency: 50,
431+
sessionPerSecond: 200,
432+
},
433+
},
434+
session: {
435+
idleTimeoutSeconds: 1800,
436+
allowClientDelete: true,
437+
},
438+
};
439+
const result = configValidator(config, true);
440+
expect(result.error).to.be.undefined;
441+
});
442+
443+
it('rejects mcp.operations.enabled with a non-boolean', () => {
444+
const config = testUtils.deepClone(FAKE_CONFIG);
445+
config.mcp = { operations: { enabled: 'yes' } };
446+
const result = configValidator(config, true);
447+
expect(result.error).to.not.be.undefined;
448+
expect(result.error.message).to.include("'mcp.operations.enabled' must be a boolean");
449+
});
450+
451+
it('rejects mcp.operations.mountPath with a non-string', () => {
452+
const config = testUtils.deepClone(FAKE_CONFIG);
453+
config.mcp = { operations: { mountPath: 42 } };
454+
const result = configValidator(config, true);
455+
expect(result.error).to.not.be.undefined;
456+
expect(result.error.message).to.include("'mcp.operations.mountPath' must be a string");
457+
});
458+
});
388459
});

utility/hdbTerms.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,27 @@ export const CONFIG_PARAMS = {
523523
OPERATIONSAPI_NETWORK_TIMEOUT: 'operationsApi_network_timeout',
524524
OPERATIONSAPI_SYSINFO_NETWORK: 'operationsApi_sysInfo_network',
525525
OPERATIONSAPI_SYSINFO_DISK: 'operationsApi_sysInfo_disk',
526+
MCP_OPERATIONS_ENABLED: 'mcp_operations_enabled',
527+
MCP_OPERATIONS_MOUNTPATH: 'mcp_operations_mountPath',
528+
MCP_OPERATIONS_ALLOW: 'mcp_operations_allow',
529+
MCP_OPERATIONS_DENY: 'mcp_operations_deny',
530+
MCP_OPERATIONS_MAXTOOLS: 'mcp_operations_maxTools',
531+
MCP_OPERATIONS_RATELIMIT_PERTOOLPERSECOND: 'mcp_operations_rateLimit_perToolPerSecond',
532+
MCP_OPERATIONS_RATELIMIT_PERTOOLBURST: 'mcp_operations_rateLimit_perToolBurst',
533+
MCP_OPERATIONS_RATELIMIT_SESSIONCONCURRENCY: 'mcp_operations_rateLimit_sessionConcurrency',
534+
MCP_OPERATIONS_RATELIMIT_SESSIONPERSECOND: 'mcp_operations_rateLimit_sessionPerSecond',
535+
MCP_APPLICATION_ENABLED: 'mcp_application_enabled',
536+
MCP_APPLICATION_MOUNTPATH: 'mcp_application_mountPath',
537+
MCP_APPLICATION_ALLOW: 'mcp_application_allow',
538+
MCP_APPLICATION_DENY: 'mcp_application_deny',
539+
MCP_APPLICATION_MAXTOOLS: 'mcp_application_maxTools',
540+
MCP_APPLICATION_SEARCHMAXRESULTS: 'mcp_application_searchMaxResults',
541+
MCP_APPLICATION_RATELIMIT_PERTOOLPERSECOND: 'mcp_application_rateLimit_perToolPerSecond',
542+
MCP_APPLICATION_RATELIMIT_PERTOOLBURST: 'mcp_application_rateLimit_perToolBurst',
543+
MCP_APPLICATION_RATELIMIT_SESSIONCONCURRENCY: 'mcp_application_rateLimit_sessionConcurrency',
544+
MCP_APPLICATION_RATELIMIT_SESSIONPERSECOND: 'mcp_application_rateLimit_sessionPerSecond',
545+
MCP_SESSION_IDLETIMEOUTSECONDS: 'mcp_session_idleTimeoutSeconds',
546+
MCP_SESSION_ALLOWCLIENTDELETE: 'mcp_session_allowClientDelete',
526547
REPLICATION: 'replication',
527548
REPLICATION_HOSTNAME: 'replication_hostname',
528549
REPLICATION_URL: 'replication_url',

validation/configValidator.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,35 @@ export function configValidator(configJson, skipFsValidation = false) {
6666
privateKey: pemFileConstraints,
6767
});
6868

69+
// MCP — sub-issue #613 lands the config surface ahead of the transport (#614).
70+
// Both profiles default to enabled:false so existing deployments are unchanged on upgrade.
71+
const mcpRateLimitSchema = Joi.object({
72+
perToolPerSecond: number.min(0).optional(),
73+
perToolBurst: number.min(0).optional(),
74+
sessionConcurrency: number.min(0).optional(),
75+
sessionPerSecond: number.min(0).optional(),
76+
});
77+
const mcpOperationsSchema = Joi.object({
78+
enabled: boolean.optional().default(false),
79+
mountPath: string.optional().default('/mcp'),
80+
allow: array.items(string).optional(),
81+
deny: array.items(string).optional(),
82+
maxTools: number.min(1).optional(),
83+
rateLimit: mcpRateLimitSchema.optional(),
84+
});
85+
const mcpApplicationSchema = mcpOperationsSchema.keys({
86+
searchMaxResults: number.min(1).optional(),
87+
});
88+
const mcpSessionSchema = Joi.object({
89+
idleTimeoutSeconds: number.min(1).optional(),
90+
allowClientDelete: boolean.optional(),
91+
});
92+
const mcpSchema = Joi.object({
93+
operations: mcpOperationsSchema.optional(),
94+
application: mcpApplicationSchema.optional(),
95+
session: mcpSessionSchema.optional(),
96+
});
97+
6998
const configSchema = Joi.object({
7099
authentication: Joi.alternatives(
71100
Joi.object({
@@ -195,6 +224,7 @@ export function configValidator(configJson, skipFsValidation = false) {
195224
maxFreeSpaceToLoad: number.optional(),
196225
maxFreeSpaceToRetain: number.optional(),
197226
}).required(),
227+
mcp: mcpSchema.optional(),
198228
ignoreScripts: boolean.optional(),
199229
tls: Joi.alternatives([Joi.array().items(tlsConstraints), tlsConstraints]),
200230
});

0 commit comments

Comments
 (0)