Merge pull request 'feat: external MCP server support + HA MCP PoC' (#4) from feat/external-mcp-servers into main
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
26
examples/ha-mcp.yaml
Normal file
26
examples/ha-mcp.yaml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
servers:
|
||||||
|
- name: ha-mcp
|
||||||
|
description: "Home Assistant MCP - smart home control via MCP"
|
||||||
|
dockerImage: "ghcr.io/homeassistant-ai/ha-mcp:2.4"
|
||||||
|
transport: STREAMABLE_HTTP
|
||||||
|
containerPort: 3000
|
||||||
|
# For mcpd-managed containers:
|
||||||
|
command:
|
||||||
|
- python
|
||||||
|
- "-c"
|
||||||
|
- "from ha_mcp.server import HomeAssistantSmartMCPServer; s = HomeAssistantSmartMCPServer(); s.mcp.run(transport='sse', host='0.0.0.0', port=3000)"
|
||||||
|
# For connecting to an already-running instance (host.containers.internal for container-to-host):
|
||||||
|
externalUrl: "http://host.containers.internal:8086/mcp"
|
||||||
|
envTemplate:
|
||||||
|
- name: HOMEASSISTANT_URL
|
||||||
|
description: "Home Assistant instance URL (e.g. https://ha.example.com)"
|
||||||
|
- name: HOMEASSISTANT_TOKEN
|
||||||
|
description: "Home Assistant long-lived access token"
|
||||||
|
isSecret: true
|
||||||
|
|
||||||
|
profiles:
|
||||||
|
- name: production
|
||||||
|
server: ha-mcp
|
||||||
|
envOverrides:
|
||||||
|
HOMEASSISTANT_URL: "https://ha.itaz.eu"
|
||||||
|
HOMEASSISTANT_TOKEN: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIyNjFlZTRhOWI2MGM0YTllOGJkNTIxN2Q3YmVmZDkzNSIsImlhdCI6MTc3MDA3NjYzOCwiZXhwIjoyMDg1NDM2NjM4fQ.17mAQxIrCBrQx3ogqAUetwEt-cngRmJiH-e7sLt-3FY"
|
||||||
@@ -11,6 +11,9 @@ const ServerSpecSchema = z.object({
|
|||||||
dockerImage: z.string().optional(),
|
dockerImage: z.string().optional(),
|
||||||
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
|
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
|
||||||
repositoryUrl: z.string().url().optional(),
|
repositoryUrl: z.string().url().optional(),
|
||||||
|
externalUrl: z.string().url().optional(),
|
||||||
|
command: z.array(z.string()).optional(),
|
||||||
|
containerPort: z.number().int().min(1).max(65535).optional(),
|
||||||
envTemplate: z.array(z.object({
|
envTemplate: z.array(z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
description: z.string().default(''),
|
description: z.string().default(''),
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Transport" AS ENUM ('STDIO', 'SSE', 'STREAMABLE_HTTP');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "InstanceStatus" AS ENUM ('STARTING', 'RUNNING', 'STOPPING', 'STOPPED', 'ERROR');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"role" "Role" NOT NULL DEFAULT 'USER',
|
||||||
|
"version" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Session" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "McpServer" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL DEFAULT '',
|
||||||
|
"packageName" TEXT,
|
||||||
|
"dockerImage" TEXT,
|
||||||
|
"transport" "Transport" NOT NULL DEFAULT 'STDIO',
|
||||||
|
"repositoryUrl" TEXT,
|
||||||
|
"externalUrl" TEXT,
|
||||||
|
"command" JSONB,
|
||||||
|
"containerPort" INTEGER,
|
||||||
|
"envTemplate" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
"version" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "McpServer_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "McpProfile" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"serverId" TEXT NOT NULL,
|
||||||
|
"permissions" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
"envOverrides" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"version" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "McpProfile_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Project" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL DEFAULT '',
|
||||||
|
"ownerId" TEXT NOT NULL,
|
||||||
|
"version" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ProjectMcpProfile" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"profileId" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ProjectMcpProfile_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "McpInstance" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"serverId" TEXT NOT NULL,
|
||||||
|
"containerId" TEXT,
|
||||||
|
"status" "InstanceStatus" NOT NULL DEFAULT 'STOPPED',
|
||||||
|
"port" INTEGER,
|
||||||
|
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"version" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "McpInstance_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AuditLog" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"action" TEXT NOT NULL,
|
||||||
|
"resource" TEXT NOT NULL,
|
||||||
|
"resourceId" TEXT,
|
||||||
|
"details" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "User_email_idx" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Session_token_idx" ON "Session"("token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "McpServer_name_key" ON "McpServer"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "McpServer_name_idx" ON "McpServer"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "McpProfile_serverId_idx" ON "McpProfile"("serverId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "McpProfile_name_serverId_key" ON "McpProfile"("name", "serverId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Project_name_key" ON "Project"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Project_name_idx" ON "Project"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Project_ownerId_idx" ON "Project"("ownerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProjectMcpProfile_projectId_idx" ON "ProjectMcpProfile"("projectId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProjectMcpProfile_profileId_idx" ON "ProjectMcpProfile"("profileId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ProjectMcpProfile_projectId_profileId_key" ON "ProjectMcpProfile"("projectId", "profileId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "McpInstance_serverId_idx" ON "McpInstance"("serverId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "McpInstance_status_idx" ON "McpInstance"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AuditLog_userId_idx" ON "AuditLog"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AuditLog_action_idx" ON "AuditLog"("action");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AuditLog_resource_idx" ON "AuditLog"("resource");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "McpProfile" ADD CONSTRAINT "McpProfile_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "McpServer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Project" ADD CONSTRAINT "Project_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ProjectMcpProfile" ADD CONSTRAINT "ProjectMcpProfile_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ProjectMcpProfile" ADD CONSTRAINT "ProjectMcpProfile_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "McpProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "McpInstance" ADD CONSTRAINT "McpInstance_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "McpServer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
3
src/db/prisma/migrations/migration_lock.toml
Normal file
3
src/db/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
@@ -57,6 +57,9 @@ model McpServer {
|
|||||||
dockerImage String?
|
dockerImage String?
|
||||||
transport Transport @default(STDIO)
|
transport Transport @default(STDIO)
|
||||||
repositoryUrl String?
|
repositoryUrl String?
|
||||||
|
externalUrl String?
|
||||||
|
command Json?
|
||||||
|
containerPort Int?
|
||||||
envTemplate Json @default("[]")
|
envTemplate Json @default("[]")
|
||||||
version Int @default(1)
|
version Int @default(1)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ async function main(): Promise<void> {
|
|||||||
const backupService = new BackupService(serverRepo, profileRepo, projectRepo);
|
const backupService = new BackupService(serverRepo, profileRepo, projectRepo);
|
||||||
const restoreService = new RestoreService(serverRepo, profileRepo, projectRepo);
|
const restoreService = new RestoreService(serverRepo, profileRepo, projectRepo);
|
||||||
const authService = new AuthService(prisma);
|
const authService = new AuthService(prisma);
|
||||||
const mcpProxyService = new McpProxyService(instanceRepo);
|
const mcpProxyService = new McpProxyService(instanceRepo, serverRepo);
|
||||||
|
|
||||||
// Server
|
// Server
|
||||||
const app = await createServer(config, {
|
const app = await createServer(config, {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PrismaClient, McpServer } from '@prisma/client';
|
import { type PrismaClient, type McpServer, Prisma } from '@prisma/client';
|
||||||
import type { IMcpServerRepository } from './interfaces.js';
|
import type { IMcpServerRepository } from './interfaces.js';
|
||||||
import type { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js';
|
import type { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js';
|
||||||
|
|
||||||
@@ -26,6 +26,9 @@ export class McpServerRepository implements IMcpServerRepository {
|
|||||||
dockerImage: data.dockerImage ?? null,
|
dockerImage: data.dockerImage ?? null,
|
||||||
transport: data.transport,
|
transport: data.transport,
|
||||||
repositoryUrl: data.repositoryUrl ?? null,
|
repositoryUrl: data.repositoryUrl ?? null,
|
||||||
|
externalUrl: data.externalUrl ?? null,
|
||||||
|
command: data.command ?? Prisma.DbNull,
|
||||||
|
containerPort: data.containerPort ?? null,
|
||||||
envTemplate: data.envTemplate,
|
envTemplate: data.envTemplate,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -38,6 +41,9 @@ export class McpServerRepository implements IMcpServerRepository {
|
|||||||
if (data.dockerImage !== undefined) updateData['dockerImage'] = data.dockerImage;
|
if (data.dockerImage !== undefined) updateData['dockerImage'] = data.dockerImage;
|
||||||
if (data.transport !== undefined) updateData['transport'] = data.transport;
|
if (data.transport !== undefined) updateData['transport'] = data.transport;
|
||||||
if (data.repositoryUrl !== undefined) updateData['repositoryUrl'] = data.repositoryUrl;
|
if (data.repositoryUrl !== undefined) updateData['repositoryUrl'] = data.repositoryUrl;
|
||||||
|
if (data.externalUrl !== undefined) updateData['externalUrl'] = data.externalUrl;
|
||||||
|
if (data.command !== undefined) updateData['command'] = data.command;
|
||||||
|
if (data.containerPort !== undefined) updateData['containerPort'] = data.containerPort;
|
||||||
if (data.envTemplate !== undefined) updateData['envTemplate'] = data.envTemplate;
|
if (data.envTemplate !== undefined) updateData['envTemplate'] = data.envTemplate;
|
||||||
|
|
||||||
return this.prisma.mcpServer.update({ where: { id }, data: updateData });
|
return this.prisma.mcpServer.update({ where: { id }, data: updateData });
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export class DockerContainerManager implements McpOrchestrator {
|
|||||||
? Object.entries(spec.env).map(([k, v]) => `${k}=${v}`)
|
? Object.entries(spec.env).map(([k, v]) => `${k}=${v}`)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const container = await this.docker.createContainer({
|
const createOpts: Docker.ContainerCreateOptions = {
|
||||||
Image: spec.image,
|
Image: spec.image,
|
||||||
name: spec.name,
|
name: spec.name,
|
||||||
Env: envArr,
|
Env: envArr,
|
||||||
@@ -86,7 +86,12 @@ export class DockerContainerManager implements McpOrchestrator {
|
|||||||
NanoCpus: nanoCpus,
|
NanoCpus: nanoCpus,
|
||||||
NetworkMode: spec.network ?? 'bridge',
|
NetworkMode: spec.network ?? 'bridge',
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
if (spec.command) {
|
||||||
|
createOpts.Cmd = spec.command;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = await this.docker.createContainer(createOpts);
|
||||||
|
|
||||||
await container.start();
|
await container.start();
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ export class InstanceService {
|
|||||||
const server = await this.serverRepo.findById(serverId);
|
const server = await this.serverRepo.findById(serverId);
|
||||||
if (!server) throw new NotFoundError(`McpServer '${serverId}' not found`);
|
if (!server) throw new NotFoundError(`McpServer '${serverId}' not found`);
|
||||||
|
|
||||||
|
// External servers don't need container management
|
||||||
|
if (server.externalUrl) {
|
||||||
|
return this.instanceRepo.create({
|
||||||
|
serverId,
|
||||||
|
status: 'RUNNING',
|
||||||
|
metadata: { external: true, url: server.externalUrl },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const image = server.dockerImage ?? server.packageName ?? server.name;
|
const image = server.dockerImage ?? server.packageName ?? server.name;
|
||||||
|
|
||||||
// Create DB record first in STARTING state
|
// Create DB record first in STARTING state
|
||||||
@@ -51,7 +60,11 @@ export class InstanceService {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (server.transport === 'SSE' || server.transport === 'STREAMABLE_HTTP') {
|
if (server.transport === 'SSE' || server.transport === 'STREAMABLE_HTTP') {
|
||||||
spec.containerPort = 3000;
|
spec.containerPort = server.containerPort ?? 3000;
|
||||||
|
}
|
||||||
|
const command = server.command as string[] | null;
|
||||||
|
if (command) {
|
||||||
|
spec.command = command;
|
||||||
}
|
}
|
||||||
if (opts?.env) {
|
if (opts?.env) {
|
||||||
spec.env = opts.env;
|
spec.env = opts.env;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { McpInstance } from '@prisma/client';
|
import type { McpInstance } from '@prisma/client';
|
||||||
import type { IMcpInstanceRepository } from '../repositories/interfaces.js';
|
import type { IMcpInstanceRepository, IMcpServerRepository } from '../repositories/interfaces.js';
|
||||||
import { NotFoundError } from './mcp-server.service.js';
|
import { NotFoundError } from './mcp-server.service.js';
|
||||||
import { InvalidStateError } from './instance.service.js';
|
import { InvalidStateError } from './instance.service.js';
|
||||||
|
|
||||||
@@ -16,11 +16,39 @@ export interface McpProxyResponse {
|
|||||||
error?: { code: number; message: string; data?: unknown };
|
error?: { code: number; message: string; data?: unknown };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a streamable-http SSE response body to extract the JSON-RPC payload.
|
||||||
|
* Streamable-http returns `event: message\ndata: {...}\n\n` format.
|
||||||
|
*/
|
||||||
|
function parseStreamableResponse(body: string): McpProxyResponse {
|
||||||
|
for (const line of body.split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed.startsWith('data: ')) {
|
||||||
|
return JSON.parse(trimmed.slice(6)) as McpProxyResponse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If body is plain JSON (no SSE framing), parse directly
|
||||||
|
return JSON.parse(body) as McpProxyResponse;
|
||||||
|
}
|
||||||
|
|
||||||
export class McpProxyService {
|
export class McpProxyService {
|
||||||
constructor(private readonly instanceRepo: IMcpInstanceRepository) {}
|
/** Session IDs per server for streamable-http protocol */
|
||||||
|
private sessions = new Map<string, string>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly instanceRepo: IMcpInstanceRepository,
|
||||||
|
private readonly serverRepo: IMcpServerRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
async execute(request: McpProxyRequest): Promise<McpProxyResponse> {
|
async execute(request: McpProxyRequest): Promise<McpProxyResponse> {
|
||||||
// Find a running instance for this server
|
const server = await this.serverRepo.findById(request.serverId);
|
||||||
|
|
||||||
|
// External server: proxy directly to externalUrl
|
||||||
|
if (server?.externalUrl) {
|
||||||
|
return this.sendToExternal(server.id, server.externalUrl, request.method, request.params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Managed server: find running instance
|
||||||
const instances = await this.instanceRepo.findAll(request.serverId);
|
const instances = await this.instanceRepo.findAll(request.serverId);
|
||||||
const running = instances.find((i) => i.status === 'RUNNING');
|
const running = instances.find((i) => i.status === 'RUNNING');
|
||||||
|
|
||||||
@@ -37,6 +65,116 @@ export class McpProxyService {
|
|||||||
return this.sendJsonRpc(running, request.method, request.params);
|
return this.sendJsonRpc(running, request.method, request.params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a JSON-RPC request to an external MCP server.
|
||||||
|
* Handles streamable-http protocol (session management + SSE response parsing).
|
||||||
|
*/
|
||||||
|
private async sendToExternal(
|
||||||
|
serverId: string,
|
||||||
|
url: string,
|
||||||
|
method: string,
|
||||||
|
params?: Record<string, unknown>,
|
||||||
|
): Promise<McpProxyResponse> {
|
||||||
|
// Ensure we have a session (initialize on first call)
|
||||||
|
if (!this.sessions.has(serverId)) {
|
||||||
|
await this.initSession(serverId, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = this.sessions.get(serverId);
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method,
|
||||||
|
};
|
||||||
|
if (params !== undefined) {
|
||||||
|
body.params = params;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json, text/event-stream',
|
||||||
|
};
|
||||||
|
if (sessionId) {
|
||||||
|
headers['Mcp-Session-Id'] = sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Session expired? Clear and retry once
|
||||||
|
if (response.status === 400 || response.status === 404) {
|
||||||
|
this.sessions.delete(serverId);
|
||||||
|
return this.sendToExternal(serverId, url, method, params);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
error: {
|
||||||
|
code: -32000,
|
||||||
|
message: `External MCP server returned HTTP ${response.status}: ${response.statusText}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
return parseStreamableResponse(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize a streamable-http session with an external server.
|
||||||
|
* Sends `initialize` and `notifications/initialized`, caches the session ID.
|
||||||
|
*/
|
||||||
|
private async initSession(serverId: string, url: string): Promise<void> {
|
||||||
|
const initBody = {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'initialize',
|
||||||
|
params: {
|
||||||
|
protocolVersion: '2025-03-26',
|
||||||
|
capabilities: {},
|
||||||
|
clientInfo: { name: 'mcpctl', version: '0.1.0' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json, text/event-stream',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(initBody),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to initialize session: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = response.headers.get('mcp-session-id');
|
||||||
|
if (sessionId) {
|
||||||
|
this.sessions.set(serverId, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send notifications/initialized
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json, text/event-stream',
|
||||||
|
};
|
||||||
|
if (sessionId) {
|
||||||
|
headers['Mcp-Session-Id'] = sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async sendJsonRpc(
|
private async sendJsonRpc(
|
||||||
instance: McpInstance,
|
instance: McpInstance,
|
||||||
method: string,
|
method: string,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export interface ContainerSpec {
|
|||||||
image: string;
|
image: string;
|
||||||
/** Human-readable name (used as container name prefix) */
|
/** Human-readable name (used as container name prefix) */
|
||||||
name: string;
|
name: string;
|
||||||
|
/** Custom command to run (overrides image CMD) */
|
||||||
|
command?: string[];
|
||||||
/** Environment variables */
|
/** Environment variables */
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
/** Host port to bind (null = auto-assign) */
|
/** Host port to bind (null = auto-assign) */
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export const CreateMcpServerSchema = z.object({
|
|||||||
dockerImage: z.string().max(200).optional(),
|
dockerImage: z.string().max(200).optional(),
|
||||||
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
|
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
|
||||||
repositoryUrl: z.string().url().optional(),
|
repositoryUrl: z.string().url().optional(),
|
||||||
|
externalUrl: z.string().url().optional(),
|
||||||
|
command: z.array(z.string()).optional(),
|
||||||
|
containerPort: z.number().int().min(1).max(65535).optional(),
|
||||||
envTemplate: z.array(EnvTemplateEntrySchema).default([]),
|
envTemplate: z.array(EnvTemplateEntrySchema).default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -23,6 +26,9 @@ export const UpdateMcpServerSchema = z.object({
|
|||||||
dockerImage: z.string().max(200).nullable().optional(),
|
dockerImage: z.string().max(200).nullable().optional(),
|
||||||
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).optional(),
|
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).optional(),
|
||||||
repositoryUrl: z.string().url().nullable().optional(),
|
repositoryUrl: z.string().url().nullable().optional(),
|
||||||
|
externalUrl: z.string().url().nullable().optional(),
|
||||||
|
command: z.array(z.string()).nullable().optional(),
|
||||||
|
containerPort: z.number().int().min(1).max(65535).nullable().optional(),
|
||||||
envTemplate: z.array(EnvTemplateEntrySchema).optional(),
|
envTemplate: z.array(EnvTemplateEntrySchema).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
753
src/mcpd/tests/mcp-server-flow.test.ts
Normal file
753
src/mcpd/tests/mcp-server-flow.test.ts
Normal file
@@ -0,0 +1,753 @@
|
|||||||
|
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import http from 'node:http';
|
||||||
|
import { McpServerService } from '../src/services/mcp-server.service.js';
|
||||||
|
import { InstanceService } from '../src/services/instance.service.js';
|
||||||
|
import { McpProxyService } from '../src/services/mcp-proxy-service.js';
|
||||||
|
import { AuditLogService } from '../src/services/audit-log.service.js';
|
||||||
|
import { errorHandler } from '../src/middleware/error-handler.js';
|
||||||
|
import { registerMcpServerRoutes } from '../src/routes/mcp-servers.js';
|
||||||
|
import { registerInstanceRoutes } from '../src/routes/instances.js';
|
||||||
|
import { registerMcpProxyRoutes } from '../src/routes/mcp-proxy.js';
|
||||||
|
import type {
|
||||||
|
IMcpServerRepository,
|
||||||
|
IMcpInstanceRepository,
|
||||||
|
IAuditLogRepository,
|
||||||
|
} from '../src/repositories/interfaces.js';
|
||||||
|
import type { McpOrchestrator } from '../src/services/orchestrator.js';
|
||||||
|
import type { McpServer, McpInstance, InstanceStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// In-memory repository implementations (stateful mocks)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createInMemoryServerRepo(): IMcpServerRepository {
|
||||||
|
const servers = new Map<string, McpServer>();
|
||||||
|
let nextId = 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
findAll: vi.fn(async () => [...servers.values()]),
|
||||||
|
findById: vi.fn(async (id: string) => servers.get(id) ?? null),
|
||||||
|
findByName: vi.fn(async (name: string) => [...servers.values()].find((s) => s.name === name) ?? null),
|
||||||
|
create: vi.fn(async (data) => {
|
||||||
|
const id = `srv-${nextId++}`;
|
||||||
|
const server = {
|
||||||
|
id,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description ?? '',
|
||||||
|
packageName: data.packageName ?? null,
|
||||||
|
dockerImage: data.dockerImage ?? null,
|
||||||
|
transport: data.transport ?? 'STDIO',
|
||||||
|
repositoryUrl: data.repositoryUrl ?? null,
|
||||||
|
externalUrl: data.externalUrl ?? null,
|
||||||
|
command: data.command ?? null,
|
||||||
|
containerPort: data.containerPort ?? null,
|
||||||
|
envTemplate: data.envTemplate ?? [],
|
||||||
|
version: 1,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
} as McpServer;
|
||||||
|
servers.set(id, server);
|
||||||
|
return server;
|
||||||
|
}),
|
||||||
|
update: vi.fn(async (id: string, data) => {
|
||||||
|
const existing = servers.get(id);
|
||||||
|
if (!existing) throw new Error(`Server ${id} not found`);
|
||||||
|
const updated = { ...existing, ...data, updatedAt: new Date() } as McpServer;
|
||||||
|
servers.set(id, updated);
|
||||||
|
return updated;
|
||||||
|
}),
|
||||||
|
delete: vi.fn(async (id: string) => {
|
||||||
|
servers.delete(id);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInMemoryInstanceRepo(): IMcpInstanceRepository {
|
||||||
|
const instances = new Map<string, McpInstance>();
|
||||||
|
let nextId = 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
findAll: vi.fn(async (serverId?: string) => {
|
||||||
|
const all = [...instances.values()];
|
||||||
|
return serverId ? all.filter((i) => i.serverId === serverId) : all;
|
||||||
|
}),
|
||||||
|
findById: vi.fn(async (id: string) => instances.get(id) ?? null),
|
||||||
|
findByContainerId: vi.fn(async (containerId: string) =>
|
||||||
|
[...instances.values()].find((i) => i.containerId === containerId) ?? null,
|
||||||
|
),
|
||||||
|
create: vi.fn(async (data) => {
|
||||||
|
const id = `inst-${nextId++}`;
|
||||||
|
const instance = {
|
||||||
|
id,
|
||||||
|
serverId: data.serverId,
|
||||||
|
containerId: data.containerId ?? null,
|
||||||
|
status: (data.status ?? 'STOPPED') as InstanceStatus,
|
||||||
|
port: data.port ?? null,
|
||||||
|
metadata: data.metadata ?? {},
|
||||||
|
version: 1,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
} as McpInstance;
|
||||||
|
instances.set(id, instance);
|
||||||
|
return instance;
|
||||||
|
}),
|
||||||
|
updateStatus: vi.fn(async (id: string, status: InstanceStatus, fields?) => {
|
||||||
|
const existing = instances.get(id);
|
||||||
|
if (!existing) throw new Error(`Instance ${id} not found`);
|
||||||
|
const updated = {
|
||||||
|
...existing,
|
||||||
|
status,
|
||||||
|
...(fields?.containerId !== undefined ? { containerId: fields.containerId } : {}),
|
||||||
|
...(fields?.port !== undefined ? { port: fields.port } : {}),
|
||||||
|
...(fields?.metadata !== undefined ? { metadata: fields.metadata } : {}),
|
||||||
|
version: existing.version + 1,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
} as McpInstance;
|
||||||
|
instances.set(id, updated);
|
||||||
|
return updated;
|
||||||
|
}),
|
||||||
|
delete: vi.fn(async (id: string) => {
|
||||||
|
instances.delete(id);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInMemoryAuditLogRepo(): IAuditLogRepository {
|
||||||
|
const logs: Array<{ id: string; userId: string; action: string; resource: string; resourceId: string | null; details: Record<string, unknown>; createdAt: Date }> = [];
|
||||||
|
let nextId = 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
findAll: vi.fn(async () => logs as never[]),
|
||||||
|
findById: vi.fn(async (id: string) => (logs.find((l) => l.id === id) as never) ?? null),
|
||||||
|
create: vi.fn(async (data) => {
|
||||||
|
const log = {
|
||||||
|
id: `log-${nextId++}`,
|
||||||
|
userId: data.userId,
|
||||||
|
action: data.action,
|
||||||
|
resource: data.resource,
|
||||||
|
resourceId: data.resourceId ?? null,
|
||||||
|
details: data.details ?? {},
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
logs.push(log);
|
||||||
|
return log as never;
|
||||||
|
}),
|
||||||
|
count: vi.fn(async () => logs.length),
|
||||||
|
deleteOlderThan: vi.fn(async () => 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockOrchestrator(): McpOrchestrator {
|
||||||
|
let containerPort = 40000;
|
||||||
|
return {
|
||||||
|
ping: vi.fn(async () => true),
|
||||||
|
pullImage: vi.fn(async () => {}),
|
||||||
|
createContainer: vi.fn(async (spec) => ({
|
||||||
|
containerId: `ctr-${spec.name}`,
|
||||||
|
name: spec.name,
|
||||||
|
state: 'running' as const,
|
||||||
|
port: spec.containerPort ?? ++containerPort,
|
||||||
|
createdAt: new Date(),
|
||||||
|
})),
|
||||||
|
stopContainer: vi.fn(async () => {}),
|
||||||
|
removeContainer: vi.fn(async () => {}),
|
||||||
|
inspectContainer: vi.fn(async (id) => ({
|
||||||
|
containerId: id,
|
||||||
|
name: 'test',
|
||||||
|
state: 'running' as const,
|
||||||
|
createdAt: new Date(),
|
||||||
|
})),
|
||||||
|
getContainerLogs: vi.fn(async () => ({ stdout: '', stderr: '' })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fake MCP server (streamable-http)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createFakeMcpServer(): { server: http.Server; getPort: () => number; requests: Array<{ method: string; body: unknown }> } {
|
||||||
|
const requests: Array<{ method: string; body: unknown }> = [];
|
||||||
|
let sessionCounter = 0;
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (chunk) => (body += chunk));
|
||||||
|
req.on('end', () => {
|
||||||
|
let parsed: { method?: string; id?: number; params?: unknown } = {};
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(body);
|
||||||
|
} catch {
|
||||||
|
// notifications may not have id
|
||||||
|
}
|
||||||
|
|
||||||
|
requests.push({ method: parsed.method ?? 'unknown', body: parsed });
|
||||||
|
|
||||||
|
if (parsed.method === 'initialize') {
|
||||||
|
const sessionId = `session-${++sessionCounter}`;
|
||||||
|
const response = {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: parsed.id,
|
||||||
|
result: {
|
||||||
|
protocolVersion: '2025-03-26',
|
||||||
|
capabilities: { tools: {} },
|
||||||
|
serverInfo: { name: 'fake-mcp', version: '1.0.0' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Mcp-Session-Id': sessionId,
|
||||||
|
});
|
||||||
|
res.end(`event: message\ndata: ${JSON.stringify(response)}\n\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.method === 'notifications/initialized') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.method === 'tools/list') {
|
||||||
|
const response = {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: parsed.id,
|
||||||
|
result: {
|
||||||
|
tools: [
|
||||||
|
{ name: 'ha_get_overview', description: 'Get Home Assistant overview', inputSchema: { type: 'object', properties: {} } },
|
||||||
|
{ name: 'ha_search_entities', description: 'Search HA entities', inputSchema: { type: 'object', properties: { query: { type: 'string' } } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
||||||
|
res.end(`event: message\ndata: ${JSON.stringify(response)}\n\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.method === 'tools/call') {
|
||||||
|
const toolName = (parsed.params as { name?: string })?.name;
|
||||||
|
const response = {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: parsed.id,
|
||||||
|
result: {
|
||||||
|
content: [{ type: 'text', text: `Result from ${toolName}` }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
||||||
|
res.end(`event: message\ndata: ${JSON.stringify(response)}\n\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: echo back
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ jsonrpc: '2.0', id: parsed.id, result: {} }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let port = 0;
|
||||||
|
return {
|
||||||
|
server,
|
||||||
|
getPort: () => port,
|
||||||
|
requests,
|
||||||
|
...{
|
||||||
|
listen: () =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
server.listen(0, () => {
|
||||||
|
const addr = server.address();
|
||||||
|
if (addr && typeof addr === 'object') port = addr.port;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
|
||||||
|
},
|
||||||
|
} as ReturnType<typeof createFakeMcpServer> & { listen: () => Promise<void>; close: () => Promise<void> };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test app builder
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function buildTestApp(deps: {
|
||||||
|
serverRepo: IMcpServerRepository;
|
||||||
|
instanceRepo: IMcpInstanceRepository;
|
||||||
|
auditLogRepo: IAuditLogRepository;
|
||||||
|
orchestrator: McpOrchestrator;
|
||||||
|
}): Promise<FastifyInstance> {
|
||||||
|
const app = Fastify({ logger: false });
|
||||||
|
app.setErrorHandler(errorHandler);
|
||||||
|
|
||||||
|
const serverService = new McpServerService(deps.serverRepo);
|
||||||
|
const instanceService = new InstanceService(deps.instanceRepo, deps.serverRepo, deps.orchestrator);
|
||||||
|
const proxyService = new McpProxyService(deps.instanceRepo, deps.serverRepo);
|
||||||
|
const auditLogService = new AuditLogService(deps.auditLogRepo);
|
||||||
|
|
||||||
|
registerMcpServerRoutes(app, serverService);
|
||||||
|
registerInstanceRoutes(app, instanceService);
|
||||||
|
registerMcpProxyRoutes(app, {
|
||||||
|
mcpProxyService: proxyService,
|
||||||
|
auditLogService,
|
||||||
|
authDeps: {
|
||||||
|
findSession: async () => ({ userId: 'test-user', expiresAt: new Date(Date.now() + 3600_000) }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.ready();
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('MCP server full flow', () => {
|
||||||
|
let fakeMcp: ReturnType<typeof createFakeMcpServer> & { listen: () => Promise<void>; close: () => Promise<void> };
|
||||||
|
let fakeMcpPort: number;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
fakeMcp = createFakeMcpServer() as typeof fakeMcp;
|
||||||
|
await fakeMcp.listen();
|
||||||
|
fakeMcpPort = fakeMcp.getPort();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await fakeMcp.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('external server flow (externalUrl)', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let serverRepo: IMcpServerRepository;
|
||||||
|
let instanceRepo: IMcpInstanceRepository;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
serverRepo = createInMemoryServerRepo();
|
||||||
|
instanceRepo = createInMemoryInstanceRepo();
|
||||||
|
app = await buildTestApp({
|
||||||
|
serverRepo,
|
||||||
|
instanceRepo,
|
||||||
|
auditLogRepo: createInMemoryAuditLogRepo(),
|
||||||
|
orchestrator: createMockOrchestrator(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers server, starts virtual instance, and proxies tools/list', async () => {
|
||||||
|
// 1. Register external MCP server
|
||||||
|
const createRes = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/servers',
|
||||||
|
payload: {
|
||||||
|
name: 'ha-mcp',
|
||||||
|
description: 'Home Assistant MCP',
|
||||||
|
transport: 'STREAMABLE_HTTP',
|
||||||
|
externalUrl: `http://localhost:${fakeMcpPort}`,
|
||||||
|
containerPort: 3000,
|
||||||
|
envTemplate: [
|
||||||
|
{ name: 'HOMEASSISTANT_TOKEN', description: 'HA token', isSecret: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createRes.statusCode).toBe(201);
|
||||||
|
const server = createRes.json<{ id: string; name: string; externalUrl: string }>();
|
||||||
|
expect(server.name).toBe('ha-mcp');
|
||||||
|
expect(server.externalUrl).toBe(`http://localhost:${fakeMcpPort}`);
|
||||||
|
|
||||||
|
// 2. Verify server is listed
|
||||||
|
const listRes = await app.inject({ method: 'GET', url: '/api/v1/servers' });
|
||||||
|
expect(listRes.statusCode).toBe(200);
|
||||||
|
const servers = listRes.json<Array<{ name: string }>>();
|
||||||
|
expect(servers).toHaveLength(1);
|
||||||
|
expect(servers[0]!.name).toBe('ha-mcp');
|
||||||
|
|
||||||
|
// 3. Start a virtual instance (external server — no Docker)
|
||||||
|
const startRes = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/instances',
|
||||||
|
payload: { serverId: server.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(startRes.statusCode).toBe(201);
|
||||||
|
const instance = startRes.json<{ id: string; status: string; containerId: string | null }>();
|
||||||
|
expect(instance.status).toBe('RUNNING');
|
||||||
|
expect(instance.containerId).toBeNull();
|
||||||
|
|
||||||
|
// 4. Proxy tools/list to the fake MCP server
|
||||||
|
const proxyRes = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/mcp/proxy',
|
||||||
|
headers: { authorization: 'Bearer test-token' },
|
||||||
|
payload: {
|
||||||
|
serverId: server.id,
|
||||||
|
method: 'tools/list',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(proxyRes.statusCode).toBe(200);
|
||||||
|
const proxyBody = proxyRes.json<{ jsonrpc: string; result: { tools: Array<{ name: string }> } }>();
|
||||||
|
expect(proxyBody.jsonrpc).toBe('2.0');
|
||||||
|
expect(proxyBody.result.tools).toHaveLength(2);
|
||||||
|
expect(proxyBody.result.tools.map((t) => t.name)).toContain('ha_get_overview');
|
||||||
|
expect(proxyBody.result.tools.map((t) => t.name)).toContain('ha_search_entities');
|
||||||
|
|
||||||
|
// 5. Verify the fake server received the protocol handshake + tools/list
|
||||||
|
const methods = fakeMcp.requests.map((r) => r.method);
|
||||||
|
expect(methods).toContain('initialize');
|
||||||
|
expect(methods).toContain('notifications/initialized');
|
||||||
|
expect(methods).toContain('tools/list');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proxies tools/call with parameters', async () => {
|
||||||
|
// Register + start
|
||||||
|
const createRes = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/servers',
|
||||||
|
payload: {
|
||||||
|
name: 'ha-mcp-call',
|
||||||
|
description: 'HA MCP for call test',
|
||||||
|
transport: 'STREAMABLE_HTTP',
|
||||||
|
externalUrl: `http://localhost:${fakeMcpPort}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const server = createRes.json<{ id: string }>();
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/instances',
|
||||||
|
payload: { serverId: server.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Proxy tools/call
|
||||||
|
const proxyRes = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/mcp/proxy',
|
||||||
|
headers: { authorization: 'Bearer test-token' },
|
||||||
|
payload: {
|
||||||
|
serverId: server.id,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'ha_get_overview' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(proxyRes.statusCode).toBe(200);
|
||||||
|
const body = proxyRes.json<{ result: { content: Array<{ text: string }> } }>();
|
||||||
|
expect(body.result.content[0]!.text).toBe('Result from ha_get_overview');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('managed server flow (Docker)', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let orchestrator: ReturnType<typeof createMockOrchestrator>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
orchestrator = createMockOrchestrator();
|
||||||
|
app = await buildTestApp({
|
||||||
|
serverRepo: createInMemoryServerRepo(),
|
||||||
|
instanceRepo: createInMemoryInstanceRepo(),
|
||||||
|
auditLogRepo: createInMemoryAuditLogRepo(),
|
||||||
|
orchestrator,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers server with dockerImage, starts container, and creates instance', async () => {
|
||||||
|
// 1. Register managed server
|
||||||
|
const createRes = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/servers',
|
||||||
|
payload: {
|
||||||
|
name: 'ha-mcp-docker',
|
||||||
|
description: 'HA MCP managed by Docker',
|
||||||
|
dockerImage: 'ghcr.io/homeassistant-ai/ha-mcp:2.4',
|
||||||
|
transport: 'STREAMABLE_HTTP',
|
||||||
|
containerPort: 3000,
|
||||||
|
command: ['python', '-c', 'print("hello")'],
|
||||||
|
envTemplate: [
|
||||||
|
{ name: 'HOMEASSISTANT_URL', description: 'HA URL' },
|
||||||
|
{ name: 'HOMEASSISTANT_TOKEN', description: 'HA token', isSecret: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createRes.statusCode).toBe(201);
|
||||||
|
const server = createRes.json<{ id: string; name: string; dockerImage: string; command: string[] }>();
|
||||||
|
expect(server.name).toBe('ha-mcp-docker');
|
||||||
|
expect(server.dockerImage).toBe('ghcr.io/homeassistant-ai/ha-mcp:2.4');
|
||||||
|
expect(server.command).toEqual(['python', '-c', 'print("hello")']);
|
||||||
|
|
||||||
|
// 2. Start container instance with env
|
||||||
|
const startRes = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/instances',
|
||||||
|
payload: {
|
||||||
|
serverId: server.id,
|
||||||
|
env: { HOMEASSISTANT_URL: 'https://ha.example.com', HOMEASSISTANT_TOKEN: 'secret' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(startRes.statusCode).toBe(201);
|
||||||
|
const instance = startRes.json<{ id: string; status: string; containerId: string }>();
|
||||||
|
expect(instance.status).toBe('RUNNING');
|
||||||
|
expect(instance.containerId).toBeTruthy();
|
||||||
|
|
||||||
|
// 3. Verify orchestrator was called with correct spec
|
||||||
|
expect(orchestrator.createContainer).toHaveBeenCalledTimes(1);
|
||||||
|
const spec = vi.mocked(orchestrator.createContainer).mock.calls[0]![0];
|
||||||
|
expect(spec.image).toBe('ghcr.io/homeassistant-ai/ha-mcp:2.4');
|
||||||
|
expect(spec.containerPort).toBe(3000);
|
||||||
|
expect(spec.command).toEqual(['python', '-c', 'print("hello")']);
|
||||||
|
expect(spec.env).toEqual({
|
||||||
|
HOMEASSISTANT_URL: 'https://ha.example.com',
|
||||||
|
HOMEASSISTANT_TOKEN: 'secret',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks instance as ERROR when Docker fails', async () => {
|
||||||
|
vi.mocked(orchestrator.createContainer).mockRejectedValueOnce(new Error('Docker socket unavailable'));
|
||||||
|
|
||||||
|
const createRes = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/servers',
|
||||||
|
payload: {
|
||||||
|
name: 'failing-server',
|
||||||
|
description: 'Will fail to start',
|
||||||
|
dockerImage: 'some-image:latest',
|
||||||
|
transport: 'STDIO',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const server = createRes.json<{ id: string }>();
|
||||||
|
|
||||||
|
const startRes = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/instances',
|
||||||
|
payload: { serverId: server.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(startRes.statusCode).toBe(201);
|
||||||
|
const instance = startRes.json<{ id: string; status: string }>();
|
||||||
|
expect(instance.status).toBe('ERROR');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('full lifecycle', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let orchestrator: ReturnType<typeof createMockOrchestrator>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
orchestrator = createMockOrchestrator();
|
||||||
|
app = await buildTestApp({
|
||||||
|
serverRepo: createInMemoryServerRepo(),
|
||||||
|
instanceRepo: createInMemoryInstanceRepo(),
|
||||||
|
auditLogRepo: createInMemoryAuditLogRepo(),
|
||||||
|
orchestrator,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('register → start → list → stop → remove', async () => {
|
||||||
|
// Register
|
||||||
|
const createRes = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/servers',
|
||||||
|
payload: {
|
||||||
|
name: 'lifecycle-test',
|
||||||
|
description: 'Full lifecycle',
|
||||||
|
dockerImage: 'test:latest',
|
||||||
|
transport: 'SSE',
|
||||||
|
containerPort: 8080,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(createRes.statusCode).toBe(201);
|
||||||
|
const server = createRes.json<{ id: string }>();
|
||||||
|
|
||||||
|
// Start
|
||||||
|
const startRes = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/instances',
|
||||||
|
payload: { serverId: server.id },
|
||||||
|
});
|
||||||
|
expect(startRes.statusCode).toBe(201);
|
||||||
|
const instance = startRes.json<{ id: string; status: string }>();
|
||||||
|
expect(instance.status).toBe('RUNNING');
|
||||||
|
|
||||||
|
// List instances
|
||||||
|
const listRes = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/v1/instances?serverId=${server.id}`,
|
||||||
|
});
|
||||||
|
expect(listRes.statusCode).toBe(200);
|
||||||
|
const instances = listRes.json<Array<{ id: string }>>();
|
||||||
|
expect(instances).toHaveLength(1);
|
||||||
|
|
||||||
|
// Stop
|
||||||
|
const stopRes = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/v1/instances/${instance.id}/stop`,
|
||||||
|
});
|
||||||
|
expect(stopRes.statusCode).toBe(200);
|
||||||
|
expect(stopRes.json<{ status: string }>().status).toBe('STOPPED');
|
||||||
|
|
||||||
|
// Remove
|
||||||
|
const removeRes = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `/api/v1/instances/${instance.id}`,
|
||||||
|
});
|
||||||
|
expect(removeRes.statusCode).toBe(204);
|
||||||
|
|
||||||
|
// Verify instance is gone
|
||||||
|
const listAfter = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/v1/instances?serverId=${server.id}`,
|
||||||
|
});
|
||||||
|
expect(listAfter.json<unknown[]>()).toHaveLength(0);
|
||||||
|
|
||||||
|
// Delete server
|
||||||
|
const deleteRes = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `/api/v1/servers/${server.id}`,
|
||||||
|
});
|
||||||
|
expect(deleteRes.statusCode).toBe(204);
|
||||||
|
|
||||||
|
// Verify server is gone
|
||||||
|
const serversAfter = await app.inject({ method: 'GET', url: '/api/v1/servers' });
|
||||||
|
expect(serversAfter.json<unknown[]>()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('external server lifecycle: register → start → proxy → stop → cleanup', async () => {
|
||||||
|
// Register external
|
||||||
|
const createRes = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/servers',
|
||||||
|
payload: {
|
||||||
|
name: 'external-lifecycle',
|
||||||
|
transport: 'STREAMABLE_HTTP',
|
||||||
|
externalUrl: `http://localhost:${fakeMcpPort}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const server = createRes.json<{ id: string }>();
|
||||||
|
|
||||||
|
// Start (virtual instance)
|
||||||
|
const startRes = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/instances',
|
||||||
|
payload: { serverId: server.id },
|
||||||
|
});
|
||||||
|
const instance = startRes.json<{ id: string; status: string; containerId: string | null }>();
|
||||||
|
expect(instance.status).toBe('RUNNING');
|
||||||
|
expect(instance.containerId).toBeNull();
|
||||||
|
|
||||||
|
// Proxy tools/list
|
||||||
|
const proxyRes = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/mcp/proxy',
|
||||||
|
headers: { authorization: 'Bearer test-token' },
|
||||||
|
payload: { serverId: server.id, method: 'tools/list' },
|
||||||
|
});
|
||||||
|
expect(proxyRes.statusCode).toBe(200);
|
||||||
|
expect(proxyRes.json<{ result: { tools: unknown[] } }>().result.tools.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Stop (no container to stop)
|
||||||
|
const stopRes = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/v1/instances/${instance.id}/stop`,
|
||||||
|
});
|
||||||
|
expect(stopRes.statusCode).toBe(200);
|
||||||
|
expect(stopRes.json<{ status: string }>().status).toBe('STOPPED');
|
||||||
|
|
||||||
|
// Docker orchestrator should NOT have been called
|
||||||
|
expect(orchestrator.createContainer).not.toHaveBeenCalled();
|
||||||
|
expect(orchestrator.stopContainer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('proxy authentication', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
app = await buildTestApp({
|
||||||
|
serverRepo: createInMemoryServerRepo(),
|
||||||
|
instanceRepo: createInMemoryInstanceRepo(),
|
||||||
|
auditLogRepo: createInMemoryAuditLogRepo(),
|
||||||
|
orchestrator: createMockOrchestrator(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects proxy calls without auth header', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/mcp/proxy',
|
||||||
|
payload: { serverId: 'srv-1', method: 'tools/list' },
|
||||||
|
});
|
||||||
|
// Auth middleware rejects with 401 (no Bearer token)
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('server update flow', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
app = await buildTestApp({
|
||||||
|
serverRepo: createInMemoryServerRepo(),
|
||||||
|
instanceRepo: createInMemoryInstanceRepo(),
|
||||||
|
auditLogRepo: createInMemoryAuditLogRepo(),
|
||||||
|
orchestrator: createMockOrchestrator(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates and updates server fields', async () => {
|
||||||
|
// Create
|
||||||
|
const createRes = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/servers',
|
||||||
|
payload: {
|
||||||
|
name: 'updatable',
|
||||||
|
description: 'Original desc',
|
||||||
|
transport: 'STDIO',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const server = createRes.json<{ id: string; description: string }>();
|
||||||
|
expect(server.description).toBe('Original desc');
|
||||||
|
|
||||||
|
// Update
|
||||||
|
const updateRes = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/v1/servers/${server.id}`,
|
||||||
|
payload: {
|
||||||
|
description: 'Updated desc',
|
||||||
|
externalUrl: `http://localhost:${fakeMcpPort}`,
|
||||||
|
transport: 'STREAMABLE_HTTP',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(updateRes.statusCode).toBe(200);
|
||||||
|
const updated = updateRes.json<{ description: string; externalUrl: string; transport: string }>();
|
||||||
|
expect(updated.description).toBe('Updated desc');
|
||||||
|
expect(updated.externalUrl).toBe(`http://localhost:${fakeMcpPort}`);
|
||||||
|
expect(updated.transport).toBe('STREAMABLE_HTTP');
|
||||||
|
|
||||||
|
// Fetch to verify persistence
|
||||||
|
const getRes = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/v1/servers/${server.id}`,
|
||||||
|
});
|
||||||
|
expect(getRes.json<{ description: string }>().description).toBe('Updated desc');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user