mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): harden copilot mcp host deployment
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
# `@qinglong/cluster-admin`
|
||||
|
||||
This private QingLong 3.0 package is a short-lived cluster administration
|
||||
authority. It is intentionally separate from the resident `cluster-control`
|
||||
artifact and requires a distinct PostgreSQL role.
|
||||
This private QingLong 3.0 package owns explicit cluster operations and the
|
||||
bounded Cluster Copilot MCP product surface. Database/Kubernetes administration
|
||||
remains short-lived and requires distinct purpose-bound authority; the MCP
|
||||
subpath has only the remote API client, opens no database or Kubernetes
|
||||
authority, and is intentionally separate from resident `cluster-control`.
|
||||
|
||||
The admin role can append Identity/API Credential mutations and their security
|
||||
audit in one serializable transaction, and can perform bounded read-only audit
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@qinglong/cluster-admin",
|
||||
"version": "3.0.0-alpha.0",
|
||||
"private": true,
|
||||
"description": "QingLong 3.0 short-lived cluster administration authority",
|
||||
"description": "QingLong 3.0 cluster operations and bounded Copilot MCP surface",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=24.18.0 <25"
|
||||
|
||||
@@ -2,14 +2,46 @@
|
||||
|
||||
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
||||
|
||||
import { probeClusterCopilotClientReadiness } from '../copilot-client/client';
|
||||
import { readClusterCopilotMcpServerConfig } from './config';
|
||||
import { createQingLongClusterCopilotMcpServer } from './server';
|
||||
|
||||
const USAGE = 'Usage: ql3-copilot-mcp --config /absolute/private-config.json';
|
||||
const USAGE = [
|
||||
'Usage: ql3-copilot-mcp --config /absolute/private-config.json [--concurrency-ceiling=1..16]',
|
||||
' ql3-copilot-mcp --check --config /absolute/private-config.json [--concurrency-ceiling=1..16]',
|
||||
].join('\n');
|
||||
|
||||
function configArgument(argv: readonly string[]): string | null {
|
||||
if (argv.length !== 2 || argv[0] !== '--config' || !argv[1]) return null;
|
||||
return argv[1];
|
||||
interface ClusterCopilotMcpCliArguments {
|
||||
readonly check: boolean;
|
||||
readonly configFile: string;
|
||||
readonly concurrencyCeiling: number;
|
||||
}
|
||||
|
||||
function configArgument(
|
||||
argv: readonly string[],
|
||||
): Readonly<ClusterCopilotMcpCliArguments> | null {
|
||||
const check = argv[0] === '--check';
|
||||
const offset = check ? 1 : 0;
|
||||
if (
|
||||
(argv.length !== offset + 2 && argv.length !== offset + 3) ||
|
||||
argv[offset] !== '--config' ||
|
||||
!argv[offset + 1]
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
let concurrencyCeiling = 16;
|
||||
if (argv.length === offset + 3) {
|
||||
const match = /^--concurrency-ceiling=([1-9]|1[0-6])$/u.exec(
|
||||
argv[offset + 2] ?? '',
|
||||
);
|
||||
if (!match) return null;
|
||||
concurrencyCeiling = Number(match[1]);
|
||||
}
|
||||
return Object.freeze({
|
||||
check,
|
||||
configFile: argv[offset + 1]!,
|
||||
concurrencyCeiling,
|
||||
});
|
||||
}
|
||||
|
||||
function fact(event: 'process_failed' | 'transport_error'): string {
|
||||
@@ -26,8 +58,8 @@ async function main(argv: readonly string[]): Promise<void> {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const configFile = configArgument(argv);
|
||||
if (configFile === null) {
|
||||
const command = configArgument(argv);
|
||||
if (command === null) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'QL3_CLUSTER_COPILOT_MCP_CLI_USAGE_INVALID',
|
||||
@@ -46,7 +78,33 @@ async function main(argv: readonly string[]): Promise<void> {
|
||||
};
|
||||
|
||||
try {
|
||||
const config = readClusterCopilotMcpServerConfig(configFile);
|
||||
const config = readClusterCopilotMcpServerConfig(command.configFile);
|
||||
if (config.maxConcurrentRequests > command.concurrencyCeiling) {
|
||||
throw new TypeError('Cluster Copilot MCP concurrency ceiling exceeded');
|
||||
}
|
||||
if (command.check) {
|
||||
const readiness = await probeClusterCopilotClientReadiness(
|
||||
config.clientConfigFile,
|
||||
);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-cluster-copilot-mcp',
|
||||
event: 'preflight_checked',
|
||||
transport: readiness.transport,
|
||||
ready: readiness.ready,
|
||||
configuration: 'valid',
|
||||
credential: 'valid',
|
||||
maxConcurrentRequests: config.maxConcurrentRequests,
|
||||
concurrencyCeiling: command.concurrencyCeiling,
|
||||
requestMethod: 'GET',
|
||||
requestPath: '/readyz',
|
||||
mutation: false,
|
||||
})}\n`,
|
||||
);
|
||||
if (!readiness.ready) process.exitCode = 69;
|
||||
return;
|
||||
}
|
||||
handle = serveStdio(
|
||||
() => createQingLongClusterCopilotMcpServer({ config }),
|
||||
{
|
||||
|
||||
@@ -40,6 +40,12 @@ export const QINGLONG3_CLUSTER_PRODUCT_COMMANDS: readonly QingLong3ClusterProduc
|
||||
target: 'copilot-client/cli.js',
|
||||
description: 'diagnose, inspect, read or cancel Runs through the API',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'copilot-mcp',
|
||||
binary: 'ql3-copilot-mcp',
|
||||
target: 'copilot-mcp/cli.js',
|
||||
description: 'serve the bounded Cluster Copilot MCP over stdio',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'package',
|
||||
binary: 'ql3-plugin-package-client',
|
||||
@@ -177,7 +183,7 @@ export function qingLong3ClusterProductHelp(): string {
|
||||
return [
|
||||
'Usage: ql3-cluster-admin <command> [arguments]',
|
||||
'',
|
||||
'Remote client commands:',
|
||||
'Cluster product commands:',
|
||||
commands,
|
||||
'',
|
||||
'Local operator commands:',
|
||||
@@ -185,7 +191,8 @@ export function qingLong3ClusterProductHelp(): string {
|
||||
' context probe --context=/absolute/operator-context.json',
|
||||
'',
|
||||
'Use `ql3-cluster-admin <command> --help` for command-specific usage.',
|
||||
'Use `--context=/absolute/operator-context.json` to inject only stable client paths.',
|
||||
'Use `--context=/absolute/operator-context.json` only with remote client commands.',
|
||||
'Keep the MCP config explicit; it contains stable paths to a separately rotated credential.',
|
||||
'Command and short-lived assertion files always remain explicit per invocation.',
|
||||
'Server, migration, recovery, executor and key-custody authorities remain isolated.',
|
||||
].join('\n');
|
||||
|
||||
@@ -44,7 +44,7 @@ function jsonResponse(response, statusCode, requestId, body) {
|
||||
response.writeHead(statusCode, {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'content-length': String(bytes.length),
|
||||
'x-request-id': requestId,
|
||||
...(requestId === undefined ? {} : { 'x-request-id': requestId }),
|
||||
});
|
||||
response.end(bytes);
|
||||
}
|
||||
@@ -124,6 +124,7 @@ async function fixture(t) {
|
||||
);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const requests = [];
|
||||
const readiness = { value: 'ready' };
|
||||
const server = createServer(
|
||||
{
|
||||
key: fs.readFileSync(path.join(tlsFixture, 'server-key.pem')),
|
||||
@@ -144,6 +145,15 @@ async function fixture(t) {
|
||||
peerCertificate: request.socket.getPeerCertificate(),
|
||||
body: chunks.length === 0 ? null : JSON.parse(Buffer.concat(chunks)),
|
||||
});
|
||||
if (request.url === '/readyz') {
|
||||
jsonResponse(
|
||||
response,
|
||||
readiness.value === 'ready' ? 200 : 503,
|
||||
undefined,
|
||||
{ status: readiness.value },
|
||||
);
|
||||
return;
|
||||
}
|
||||
jsonResponse(
|
||||
response,
|
||||
request.method === 'POST' && !request.url.endsWith('/cancellation') ? 201 : 200,
|
||||
@@ -157,12 +167,17 @@ async function fixture(t) {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
t.after(
|
||||
() =>
|
||||
new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
}),
|
||||
);
|
||||
let closed = false;
|
||||
const close = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (closed) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
t.after(close);
|
||||
const caFile = privateFile(
|
||||
directory,
|
||||
'ca.pem',
|
||||
@@ -190,7 +205,7 @@ async function fixture(t) {
|
||||
maxConcurrentRequests: 2,
|
||||
}),
|
||||
);
|
||||
return { requests, credentialFile, serverConfigFile };
|
||||
return { requests, readiness, close, credentialFile, serverConfigFile };
|
||||
}
|
||||
|
||||
function startClient(t, configFile) {
|
||||
@@ -271,7 +286,10 @@ function runCli(args) {
|
||||
}
|
||||
|
||||
test('stdio CLI exposes deterministic help and low-sensitive startup failures', async () => {
|
||||
const usage = 'Usage: ql3-copilot-mcp --config /absolute/private-config.json';
|
||||
const usage = [
|
||||
'Usage: ql3-copilot-mcp --config /absolute/private-config.json [--concurrency-ceiling=1..16]',
|
||||
' ql3-copilot-mcp --check --config /absolute/private-config.json [--concurrency-ceiling=1..16]',
|
||||
].join('\n');
|
||||
assert.deepEqual(await runCli(['--help']), {
|
||||
status: 0,
|
||||
signal: null,
|
||||
@@ -298,6 +316,87 @@ test('stdio CLI exposes deterministic help and low-sensitive startup failures',
|
||||
assert.doesNotMatch(failed.stderr, /secret-config-name/);
|
||||
});
|
||||
|
||||
test('preflight validates mounted authority and probes readiness without authentication', async (t) => {
|
||||
const value = await fixture(t);
|
||||
const checked = await runCli([
|
||||
'--check',
|
||||
'--config',
|
||||
value.serverConfigFile,
|
||||
'--concurrency-ceiling=4',
|
||||
]);
|
||||
assert.equal(checked.status, 0);
|
||||
assert.equal(checked.signal, null);
|
||||
assert.equal(checked.stderr, '');
|
||||
assert.deepEqual(JSON.parse(checked.stdout), {
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-cluster-copilot-mcp',
|
||||
event: 'preflight_checked',
|
||||
transport: 'https',
|
||||
ready: true,
|
||||
configuration: 'valid',
|
||||
credential: 'valid',
|
||||
maxConcurrentRequests: 2,
|
||||
concurrencyCeiling: 4,
|
||||
requestMethod: 'GET',
|
||||
requestPath: '/readyz',
|
||||
mutation: false,
|
||||
});
|
||||
assert.equal(value.requests.length, 1);
|
||||
assert.deepEqual(value.requests[0], {
|
||||
method: 'GET',
|
||||
path: '/readyz',
|
||||
authorization: undefined,
|
||||
requestId: undefined,
|
||||
tls: 'TLSv1.3',
|
||||
peerCertificate: {},
|
||||
body: null,
|
||||
});
|
||||
|
||||
value.readiness.value = 'not_ready';
|
||||
const notReady = await runCli([
|
||||
'--check',
|
||||
'--config',
|
||||
value.serverConfigFile,
|
||||
'--concurrency-ceiling=4',
|
||||
]);
|
||||
assert.equal(notReady.status, 69);
|
||||
assert.equal(notReady.stderr, '');
|
||||
assert.equal(JSON.parse(notReady.stdout).ready, false);
|
||||
assert.equal(value.requests.length, 2);
|
||||
|
||||
await value.close();
|
||||
const unavailable = await runCli([
|
||||
'--check',
|
||||
'--config',
|
||||
value.serverConfigFile,
|
||||
'--concurrency-ceiling=4',
|
||||
]);
|
||||
assert.equal(unavailable.status, 1);
|
||||
assert.equal(unavailable.stdout, '');
|
||||
assert.deepEqual(JSON.parse(unavailable.stderr), {
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-cluster-copilot-mcp',
|
||||
level: 'error',
|
||||
event: 'process_failed',
|
||||
});
|
||||
|
||||
const overCeiling = await runCli([
|
||||
'--check',
|
||||
'--config',
|
||||
value.serverConfigFile,
|
||||
'--concurrency-ceiling=1',
|
||||
]);
|
||||
assert.equal(overCeiling.status, 1);
|
||||
assert.equal(overCeiling.stdout, '');
|
||||
assert.deepEqual(JSON.parse(overCeiling.stderr), {
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-cluster-copilot-mcp',
|
||||
level: 'error',
|
||||
event: 'process_failed',
|
||||
});
|
||||
assert.equal(value.requests.length, 2);
|
||||
});
|
||||
|
||||
test('stdio MCP uses direct TLS client, rotates credentials and labels untrusted output', async (t) => {
|
||||
const value = await fixture(t);
|
||||
const connected = startClient(t, value.serverConfigFile);
|
||||
|
||||
@@ -331,9 +331,9 @@ function validContextFixture(t) {
|
||||
};
|
||||
}
|
||||
|
||||
test('catalog exposes only reviewed remote clients from the same package', () => {
|
||||
test('catalog exposes only reviewed product entrypoints from the same package', () => {
|
||||
assert.equal(manifest.bin['ql3-cluster-admin'], 'dist/product-cli/cli.js');
|
||||
assert.equal(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length, 8);
|
||||
assert.equal(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length, 9);
|
||||
assert.equal(
|
||||
new Set(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.map(({ name }) => name)).size,
|
||||
QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length,
|
||||
@@ -349,7 +349,11 @@ test('catalog exposes only reviewed remote clients from the same package', () =>
|
||||
fs.lstatSync(path.join(packageRoot, 'dist', command.target)).isFile(),
|
||||
true,
|
||||
);
|
||||
assert.equal(command.binary.includes('-client'), true);
|
||||
assert.equal(
|
||||
command.binary.includes('-client') ||
|
||||
command.binary === 'ql3-copilot-mcp',
|
||||
true,
|
||||
);
|
||||
}
|
||||
for (const forbidden of [
|
||||
'ql3-cluster-migrate',
|
||||
@@ -375,6 +379,7 @@ test('help and version are bounded installation-derived product facts', () => {
|
||||
assert.match(help, /^Usage: ql3-cluster-admin <command> \[arguments\]/);
|
||||
assert.match(help, /\n run\s+retry or stop Runs/);
|
||||
assert.match(help, /\n copilot\s+diagnose, inspect, read or cancel Runs/);
|
||||
assert.match(help, /\n copilot-mcp\s+serve the bounded Cluster Copilot MCP/);
|
||||
assert.match(help, /Server, migration, recovery, executor and key-custody/);
|
||||
assert.equal(help.includes('plugin-package-manage'), false);
|
||||
assert.equal(
|
||||
|
||||
Reference in New Issue
Block a user