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
221 changes: 221 additions & 0 deletions .scratch/remote-mcp-server/PRD.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@elysiajs/openapi": "^1.4.15",
"@hookform/resolvers": "5.5.7",
"@kubernetes/client-node": "^1.4.0",
"@modelcontextprotocol/server": "^2.3.0",
"@next-auth/prisma-adapter": "^1.0.7",
"@prisma/adapter-better-sqlite3": "^7.1.0",
"@prisma/client": "7.1.0",
Expand Down
117 changes: 117 additions & 0 deletions src/__tests__/integration/server/mcp/mcp-handler.integration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// @vitest-environment node

import mockNextJsCaching from '@/__tests__/nextjs-cache.utils';
mockNextJsCaching();

vi.mock('@/server/adapter/kubernetes-api.adapter', () => ({ default: {} }));

import { createPrismaTestContext } from '@/__tests__/prisma-test.utils';
import { mockPathUtilsForTests } from '@/__tests__/path-test.utils';
import { v1Api } from '@/server/api/v1/api-index';
import { resolveQuickStackAuthInfo } from '@/server/mcp/mcp-auth';
import { createQuickStackMcpHandler } from '@/server/mcp/mcp-handler';
import restApiKeyService from '@/server/services/rest-api-key.service';
import userGroupService from '@/server/services/user-group.service';
import userService from '@/server/services/user.service';
import { PathUtils } from '@/server/utils/path.utils';

function jsonRpcRequest(options: {
method: string;
params?: Record<string, unknown>;
apiKey: string;
toolName?: string;
}) {
const { method, params, apiKey, toolName } = options;
const headers: Record<string, string> = {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
'mcp-protocol-version': '2026-07-28',
'mcp-method': method,
authorization: `Bearer ${apiKey}`,
};
if (toolName) {
headers['mcp-name'] = toolName;
}
return new Request('http://quickstack.test/api/mcp', {
method: 'POST',
headers,
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method,
params: {
...(params ?? {}),
_meta: {
'io.modelcontextprotocol/protocolVersion': '2026-07-28',
'io.modelcontextprotocol/clientCapabilities': {},
'io.modelcontextprotocol/clientInfo': { name: 'integration', version: '1.0.0' },
},
},
}),
});
}

describe('QuickStack MCP integration', () => {
createPrismaTestContext('mcp-handler');
const { originalInternalDataRoot, originalTempDataRoot } = mockPathUtilsForTests();

beforeEach(() => {
process.env.NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET ?? 'test-nextauth-secret';
});

afterAll(() => {
if (originalInternalDataRoot) {
Object.defineProperty(PathUtils, 'internalDataRoot', originalInternalDataRoot);
}
if (originalTempDataRoot) {
Object.defineProperty(PathUtils, 'tempDataRoot', originalTempDataRoot);
}
vi.restoreAllMocks();
});

async function createAdminApiKey() {
const adminRole = await userGroupService.getOrCreateAdminRole();
const user = await userService.registerUser('[email protected]', 'test-password', adminRole.id);
return restApiKeyService.create(user.id, 'mcp-integration-test');
}

it('derives operations from the real REST API and hides binary routes', async () => {
const apiKey = await createAdminApiKey();
const handler = createQuickStackMcpHandler({ app: v1Api, resolveAuthInfo: resolveQuickStackAuthInfo });

const response = await handler.fetch(jsonRpcRequest({
method: 'tools/call',
toolName: 'search_operations',
apiKey,
params: { name: 'search_operations', arguments: { detail: 'full' } },
}));
const body = await response.json();
const operations = body.result.structuredContent.operations as Array<{ operationId: string; supported: boolean }>;
const byId = new Map(operations.map((operation) => [operation.operationId, operation]));

expect(response.status).toBe(200);
expect(operations.length).toBeGreaterThan(15);
expect(byId.has('listProjects')).toBe(true);
expect(byId.has('listApps')).toBe(true);
expect(byId.has('saveAgent')).toBe(true);
expect(byId.get('readAgentSandboxFile')?.supported).toBe(false);
expect(byId.get('writeAgentSandboxFile')?.supported).toBe(false);
}, 120_000);

it('executes a real REST operation through the MCP handler', async () => {
const apiKey = await createAdminApiKey();
const handler = createQuickStackMcpHandler({ app: v1Api, resolveAuthInfo: resolveQuickStackAuthInfo });

const response = await handler.fetch(jsonRpcRequest({
method: 'tools/call',
toolName: 'execute_read_operation',
apiKey,
params: { name: 'execute_read_operation', arguments: { operationId: 'listProjects' } },
}));
const body = await response.json();

expect(response.status).toBe(200);
expect(body.result.isError).not.toBe(true);
expect(body.result.structuredContent.result).toEqual([]);
}, 120_000);
});
34 changes: 34 additions & 0 deletions src/app/api/mcp/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { v1Api } from '@/server/api/v1/api-index';
import { resolveQuickStackAuthInfo } from '@/server/mcp/mcp-auth';
import { createQuickStackMcpHandler } from '@/server/mcp/mcp-handler';
import paramService, { ParamService } from '@/server/services/param.service';

export const dynamic = 'force-dynamic';

const mcpHandler = createQuickStackMcpHandler({
app: v1Api,
resolveAuthInfo: resolveQuickStackAuthInfo,
});

async function handleRequest(request: Request): Promise<Response> {
const [canaryEnabled, enabled] = await Promise.all([
paramService.getBoolean(ParamService.USE_CANARY_CHANNEL),
paramService.getBoolean(ParamService.MCP_SERVER_ENABLED),
]);

if (!canaryEnabled || !enabled) {
return new Response(
JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32002, message: 'MCP server is disabled.' } }),
{
status: 404,
headers: { 'content-type': 'application/json' },
}
);
}

return mcpHandler.fetch(request);
}

export const POST = handleRequest;
export const GET = handleRequest;
export const DELETE = handleRequest;
65 changes: 65 additions & 0 deletions src/app/api/mcp/route.unit.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
const routeMocks = vi.hoisted(() => ({
getBoolean: vi.fn(),
mcpFetch: vi.fn(),
}));

vi.mock('@/server/services/param.service', () => ({
default: { getBoolean: routeMocks.getBoolean },
ParamService: { MCP_SERVER_ENABLED: 'mcpServerEnabled', USE_CANARY_CHANNEL: 'useCanaryChannel' },
}));

vi.mock('@/server/api/v1/api-index', () => ({ v1Api: {} }));

vi.mock('@/server/mcp/mcp-auth', () => ({ resolveQuickStackAuthInfo: vi.fn() }));

vi.mock('@/server/mcp/mcp-handler', () => ({
createQuickStackMcpHandler: () => ({ fetch: routeMocks.mcpFetch }),
}));

import { POST } from './route';

function setParams(values: { canary: boolean; mcp: boolean }) {
routeMocks.getBoolean.mockImplementation(async (name: string) => {
if (name === 'useCanaryChannel') {
return values.canary;
}
if (name === 'mcpServerEnabled') {
return values.mcp;
}
return undefined;
});
}

describe('MCP route enablement', () => {
beforeEach(() => {
vi.resetAllMocks();
});

it('rejects requests when the MCP server is disabled', async () => {
setParams({ canary: true, mcp: false });

const response = await POST(new Request('http://localhost/api/mcp', { method: 'POST' }));

expect(response.status).toBe(404);
expect(routeMocks.mcpFetch).not.toHaveBeenCalled();
});

it('rejects requests when the canary channel is disabled', async () => {
setParams({ canary: false, mcp: true });

const response = await POST(new Request('http://localhost/api/mcp', { method: 'POST' }));

expect(response.status).toBe(404);
expect(routeMocks.mcpFetch).not.toHaveBeenCalled();
});

it('delegates to the MCP handler when canary and the MCP server are enabled', async () => {
setParams({ canary: true, mcp: true });
routeMocks.mcpFetch.mockResolvedValue(new Response('handled', { status: 200 }));

const response = await POST(new Request('http://localhost/api/mcp', { method: 'POST' }));

expect(response.status).toBe(200);
expect(routeMocks.mcpFetch).toHaveBeenCalledTimes(1);
});
});
10 changes: 10 additions & 0 deletions src/app/settings/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,16 @@ export const setOpenApiSpecEnabled = async (enabled: boolean) =>
return new SuccessActionResult(undefined, `Turned ${enabled ? 'on' : 'off'} OpenAPI spec access.`);
});

export const setMcpServerEnabled = async (enabled: boolean) =>
simpleAction(async () => {
await getAdminUserSession();
await paramService.save({
name: ParamService.MCP_SERVER_ENABLED,
value: enabled ? 'true' : 'false'
});
return new SuccessActionResult(undefined, `Turned ${enabled ? 'on' : 'off'} the MCP server.`);
});

export const setRegistryStorageLocation = async (prevState: any, inputData: RegistryStorageLocationSettingsModel) =>
saveFormAction(inputData, registryStorageLocationSettingsZodModel, async (validatedData) => {
await getAdminUserSession();
Expand Down
Loading
Loading