feat(api): add MCP server for scan management
Add a Model Context Protocol server to apps/api/src/mcp/, exposing five tools backed by scan-manager.ts: - start_scan, get_scan, list_scans, cancel_scan, get_report The MCP server runs on port 3100 (MCP_PORT env var) using StreamableHTTPServerTransport from @modelcontextprotocol/sdk, alongside the existing Hono API server. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.14.0",
|
"@hono/node-server": "^1.14.0",
|
||||||
"@kubernetes/client-node": "^1.4.0",
|
"@kubernetes/client-node": "^1.4.0",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@shannon/worker": "workspace:*",
|
"@shannon/worker": "workspace:*",
|
||||||
"@temporalio/client": "^1.11.0",
|
"@temporalio/client": "^1.11.0",
|
||||||
"hono": "^4.7.0",
|
"hono": "^4.7.0",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
readonly port: number;
|
readonly port: number;
|
||||||
|
readonly mcpPort: number;
|
||||||
readonly temporalAddress: string;
|
readonly temporalAddress: string;
|
||||||
readonly apiKey: string;
|
readonly apiKey: string;
|
||||||
readonly k8sNamespace: string;
|
readonly k8sNamespace: string;
|
||||||
@@ -28,6 +29,7 @@ export function loadConfig(): Config {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
port: Number(process.env.PORT) || 3000,
|
port: Number(process.env.PORT) || 3000,
|
||||||
|
mcpPort: Number(process.env.MCP_PORT) || 3100,
|
||||||
temporalAddress: process.env.TEMPORAL_ADDRESS || 'hightower-temporal:7233',
|
temporalAddress: process.env.TEMPORAL_ADDRESS || 'hightower-temporal:7233',
|
||||||
apiKey,
|
apiKey,
|
||||||
k8sNamespace: process.env.K8S_NAMESPACE || 'hightower',
|
k8sNamespace: process.env.K8S_NAMESPACE || 'hightower',
|
||||||
|
|||||||
+10
-2
@@ -8,6 +8,7 @@ import * as k8s from '@kubernetes/client-node';
|
|||||||
import { createApp } from './app.js';
|
import { createApp } from './app.js';
|
||||||
import { loadConfig } from './config.js';
|
import { loadConfig } from './config.js';
|
||||||
import { connectTemporal, disconnectTemporal } from './services/temporal-client.js';
|
import { connectTemporal, disconnectTemporal } from './services/temporal-client.js';
|
||||||
|
import { startMcpServer } from './mcp/server.js';
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
// 1. Load configuration
|
// 1. Load configuration
|
||||||
@@ -34,14 +35,21 @@ async function main(): Promise<void> {
|
|||||||
coreApi,
|
coreApi,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5. Start server
|
// 5. Start MCP server (runs alongside the Hono API on a separate port)
|
||||||
|
const mcpServer = await startMcpServer(
|
||||||
|
{ config, temporalClient: temporal.client, batchApi, coreApi },
|
||||||
|
config.mcpPort,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. Start Hono server
|
||||||
const server = serve({ fetch: app.fetch, port: config.port }, (info) => {
|
const server = serve({ fetch: app.fetch, port: config.port }, (info) => {
|
||||||
console.log(`Shannon API server listening on port ${info.port}`);
|
console.log(`Shannon API server listening on port ${info.port}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 6. Graceful shutdown
|
// 7. Graceful shutdown
|
||||||
const shutdown = async (): Promise<void> => {
|
const shutdown = async (): Promise<void> => {
|
||||||
console.log('Shutting down...');
|
console.log('Shutting down...');
|
||||||
|
mcpServer.close();
|
||||||
server.close();
|
server.close();
|
||||||
await disconnectTemporal(temporal);
|
await disconnectTemporal(temporal);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
/**
|
||||||
|
* MCP server for Hightower scan management.
|
||||||
|
* Exposes scan-manager tools via the Model Context Protocol over HTTP.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import http from 'node:http';
|
||||||
|
import type * as k8s from '@kubernetes/client-node';
|
||||||
|
import type { Client } from '@temporalio/client';
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { Config } from '../config.js';
|
||||||
|
import {
|
||||||
|
cancelScan,
|
||||||
|
getReport,
|
||||||
|
getScan,
|
||||||
|
listScans,
|
||||||
|
startScan,
|
||||||
|
} from '../services/scan-manager.js';
|
||||||
|
import type { CreateScanInput } from '../types/api.js';
|
||||||
|
|
||||||
|
export interface McpServerDeps {
|
||||||
|
readonly config: Config;
|
||||||
|
readonly temporalClient: Client;
|
||||||
|
readonly batchApi: k8s.BatchV1Api;
|
||||||
|
readonly coreApi: k8s.CoreV1Api;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMcpServer(deps: McpServerDeps): McpServer {
|
||||||
|
const server = new McpServer(
|
||||||
|
{ name: 'hightower', version: '1.0.0' },
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
tools: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// === Tool: start_scan ===
|
||||||
|
server.registerTool(
|
||||||
|
'start_scan',
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
'Start a new penetration test scan. Returns the scan ID and initial status.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
targetUrl: z.string().describe('Target URL to scan (e.g., https://example.com)'),
|
||||||
|
gitUrl: z.string().describe(
|
||||||
|
'Git URL of the repository to analyze (e.g., https://github.com/user/repo)',
|
||||||
|
),
|
||||||
|
workspace: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Optional workspace name. Must match /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/. Defaults to auto-generated from target URL.',
|
||||||
|
),
|
||||||
|
gitRef: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Optional Git branch/tag/commit to checkout before scanning.'),
|
||||||
|
pipelineTesting: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'If true, runs in minimal testing mode with fast retries (10s). Use for development.',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ targetUrl, gitUrl, workspace, gitRef, pipelineTesting }) => {
|
||||||
|
const input: CreateScanInput = {
|
||||||
|
targetUrl,
|
||||||
|
gitUrl,
|
||||||
|
workspace,
|
||||||
|
...(gitRef !== undefined && { gitRef }),
|
||||||
|
...(pipelineTesting !== undefined && { pipelineTesting }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await startScan(deps.config, deps.batchApi, input);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: JSON.stringify(result, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// === Tool: get_scan ===
|
||||||
|
server.registerTool(
|
||||||
|
'get_scan',
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
'Get the status, progress, and results of a running or completed scan.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
scanId: z.string().describe(
|
||||||
|
"The scan ID returned from start_scan (e.g., hightower-worker-abc123)",
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ scanId }) => {
|
||||||
|
const result = await getScan(deps.config, deps.temporalClient, scanId);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: 'text' as const, text: `Scan '${scanId}' not found.` },
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: JSON.stringify(result, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// === Tool: list_scans ===
|
||||||
|
server.registerTool(
|
||||||
|
'list_scans',
|
||||||
|
{
|
||||||
|
description: 'List all running and historical scans.',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const results = await listScans(
|
||||||
|
deps.config,
|
||||||
|
deps.temporalClient,
|
||||||
|
deps.batchApi,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: JSON.stringify(results, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// === Tool: cancel_scan ===
|
||||||
|
server.registerTool(
|
||||||
|
'cancel_scan',
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
'Cancel a running scan by terminating its Kubernetes Job and Temporal workflow.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
scanId: z.string().describe('The scan ID to cancel.'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ scanId }) => {
|
||||||
|
await cancelScan(
|
||||||
|
deps.config,
|
||||||
|
deps.temporalClient,
|
||||||
|
deps.batchApi,
|
||||||
|
scanId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `Scan '${scanId}' cancellation requested.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// === Tool: get_report ===
|
||||||
|
server.registerTool(
|
||||||
|
'get_report',
|
||||||
|
{
|
||||||
|
description: 'Get the final security report for a completed scan.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
scanId: z.string().describe("The scan ID to get the report for."),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ scanId }) => {
|
||||||
|
const report = await getReport(deps.config, scanId);
|
||||||
|
|
||||||
|
if (!report) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `Report for scan '${scanId}' not found.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: report }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startMcpServer(
|
||||||
|
deps: McpServerDeps,
|
||||||
|
port: number,
|
||||||
|
): Promise<http.Server> {
|
||||||
|
const mcpServer = createMcpServer(deps);
|
||||||
|
const transport = new StreamableHTTPServerTransport({
|
||||||
|
sessionIdGenerator: () => crypto.randomUUID(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cast to Transport — the SDK's Transport interface requires onclose: () => void
|
||||||
|
// but StreamableHTTPServerTransport allows undefined (handled internally).
|
||||||
|
await mcpServer.connect(transport as never);
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
transport.handleRequest(req, res, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise<http.Server>((resolve, reject) => {
|
||||||
|
server.on('error', reject);
|
||||||
|
server.listen(port, () => {
|
||||||
|
console.log(`MCP server listening on port ${port}`);
|
||||||
|
resolve(server);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Generated
+566
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user