mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 02:27:44 +08:00
550 lines
17 KiB
TypeScript
550 lines
17 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
|
|
import type {
|
|
ApprovalRequestDetailSource,
|
|
ApprovalRequestSource,
|
|
} from '@qinglong/runtime-core/approval-discovery';
|
|
import {
|
|
BUILTIN_APPROVAL_GET_TOOL,
|
|
BUILTIN_APPROVAL_GET_TOOL_DEFINITION,
|
|
executeBuiltInApprovalGetTool,
|
|
} from '../tool-projection/approvalGet';
|
|
import {
|
|
BUILTIN_APPROVAL_LIST_TOOL,
|
|
BUILTIN_APPROVAL_LIST_TOOL_DEFINITION,
|
|
executeBuiltInApprovalListTool,
|
|
} from '../tool-projection/approvalList';
|
|
import {
|
|
BUILTIN_TASK_LIST_TOOL,
|
|
BUILTIN_TASK_LIST_TOOL_DEFINITION,
|
|
executeBuiltInTaskListTool,
|
|
} from '../tool-projection/taskList';
|
|
import {
|
|
BUILTIN_TASK_GET_TOOL,
|
|
BUILTIN_TASK_GET_TOOL_DEFINITION,
|
|
executeBuiltInTaskGetTool,
|
|
} from '../tool-projection/taskGet';
|
|
import {
|
|
McpServer,
|
|
fromJsonSchema,
|
|
type CallToolResult,
|
|
type JsonSchemaType,
|
|
} from '@modelcontextprotocol/server';
|
|
import {
|
|
BUILTIN_RUN_EVENT_LIST_TOOL,
|
|
BUILTIN_RUN_EVENT_LIST_TOOL_DEFINITION,
|
|
executeBuiltInRunEventListTool,
|
|
} from '../tool-projection/runEventList';
|
|
import {
|
|
BUILTIN_RUN_LIST_TOOL,
|
|
BUILTIN_RUN_LIST_TOOL_DEFINITION,
|
|
executeBuiltInRunListTool,
|
|
} from '../tool-projection/runList';
|
|
import {
|
|
BUILTIN_RUN_STEP_LIST_TOOL,
|
|
BUILTIN_RUN_STEP_LIST_TOOL_DEFINITION,
|
|
executeBuiltInRunStepListTool,
|
|
} from '../tool-projection/runStepList';
|
|
import {
|
|
BUILTIN_RUN_READ_TOOL,
|
|
BUILTIN_RUN_READ_TOOL_DEFINITION,
|
|
executeBuiltInRunReadTool,
|
|
} from '@qinglong/runtime-core/builtin-run-read-projection';
|
|
import type { RunRepositoryReader } from '@qinglong/runtime-core/run-repository';
|
|
import type { StepRunRepository } from '@qinglong/runtime-core/step-run';
|
|
import type { ProjectRunListReader } from '@qinglong/runtime-core/project-run-list';
|
|
import type { SecurityPrincipal } from '@qinglong/runtime-core/security';
|
|
import type { TaskDefinitionSource } from '@qinglong/runtime-core/task-definition';
|
|
import type { TriggerSource } from '@qinglong/runtime-core/trigger';
|
|
import {
|
|
normalizeSecurityAuditRecord,
|
|
type SecurityAuditOutcome,
|
|
type SecurityAuditSink,
|
|
} from '@qinglong/runtime-core/security-audit';
|
|
import {
|
|
InvalidToolJsonValueError,
|
|
ToolDefinitionRegistry,
|
|
ToolPolicySnapshotConflictError,
|
|
ToolPolicyUnavailableError,
|
|
prepareToolInvocation,
|
|
type ToolDefinition,
|
|
type ToolJsonValue,
|
|
type ToolPolicyAuthorizer,
|
|
} from '@qinglong/runtime-core/tool-registry';
|
|
import {
|
|
BUILTIN_TRIGGER_LIST_TOOL,
|
|
BUILTIN_TRIGGER_LIST_TOOL_DEFINITION,
|
|
executeBuiltInTriggerListTool,
|
|
} from '../tool-projection/triggerList';
|
|
|
|
export const QINGLONG_LOCAL_MCP_SERVER = Object.freeze({
|
|
name: 'qinglong-local',
|
|
version: '3.0.0-alpha.0',
|
|
});
|
|
|
|
const MCP_OPERATION_ID = 'mcp.tool.call';
|
|
|
|
export interface AuthenticatedLocalMcpRequest {
|
|
readonly principal: Readonly<SecurityPrincipal>;
|
|
confirm(): Promise<void>;
|
|
}
|
|
|
|
export interface QingLongLocalMcpServerDependencies {
|
|
readonly projectId: string;
|
|
readonly authenticate: () => Promise<Readonly<AuthenticatedLocalMcpRequest> | null>;
|
|
readonly policy: ToolPolicyAuthorizer;
|
|
readonly audit: SecurityAuditSink;
|
|
readonly runs: LocalMcpRunReader;
|
|
readonly stepRuns: Pick<StepRunRepository, 'listByRun'>;
|
|
readonly taskDefinitions: LocalMcpTaskReader;
|
|
readonly triggers: LocalMcpTriggerReader;
|
|
readonly approvals: LocalMcpApprovalReader;
|
|
readonly now?: () => number;
|
|
readonly randomUuid?: () => string;
|
|
}
|
|
|
|
type LocalMcpRunReader = Pick<
|
|
RunRepositoryReader,
|
|
'findRunById' | 'listEvents'
|
|
> &
|
|
ProjectRunListReader;
|
|
|
|
type LocalMcpTaskReader = Pick<
|
|
TaskDefinitionSource,
|
|
'findCurrentTaskDefinition' | 'listTaskDefinitions'
|
|
>;
|
|
type LocalMcpTriggerReader = Pick<TriggerSource, 'listTriggers'>;
|
|
type LocalMcpApprovalReader = Pick<
|
|
ApprovalRequestSource,
|
|
'listApprovalRequests'
|
|
> &
|
|
Pick<ApprovalRequestDetailSource, 'getApprovalRequestDetail'>;
|
|
|
|
interface LocalMcpReadAuthority {
|
|
readonly runs: LocalMcpRunReader;
|
|
readonly stepRuns: Pick<StepRunRepository, 'listByRun'>;
|
|
readonly taskDefinitions: LocalMcpTaskReader;
|
|
readonly triggers: LocalMcpTriggerReader;
|
|
readonly approvals: LocalMcpApprovalReader;
|
|
}
|
|
|
|
export class LocalMcpAdmissionError extends Error {
|
|
readonly code: string;
|
|
|
|
constructor(code: string) {
|
|
super('Local MCP Tool admission failed');
|
|
this.name = 'LocalMcpAdmissionError';
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
interface LocalMcpReadToolDescriptor {
|
|
readonly tool: Readonly<{ name: string; version: string }>;
|
|
readonly definition: Readonly<ToolDefinition>;
|
|
readonly title: string;
|
|
readonly auditReason: string;
|
|
readonly unavailableCode: string;
|
|
execute(
|
|
authority: LocalMcpReadAuthority,
|
|
projectId: string,
|
|
input: ToolJsonValue,
|
|
): Promise<Readonly<Record<string, ToolJsonValue>>>;
|
|
}
|
|
|
|
const LOCAL_MCP_READ_TOOLS: readonly LocalMcpReadToolDescriptor[] =
|
|
Object.freeze([
|
|
Object.freeze({
|
|
tool: BUILTIN_RUN_LIST_TOOL,
|
|
definition: BUILTIN_RUN_LIST_TOOL_DEFINITION,
|
|
title: 'QingLong Runs',
|
|
auditReason: 'tool_qinglong_run_list',
|
|
unavailableCode: 'run_list_unavailable',
|
|
execute: (
|
|
authority: LocalMcpReadAuthority,
|
|
projectId: string,
|
|
input: ToolJsonValue,
|
|
) => executeBuiltInRunListTool(authority.runs, projectId, input),
|
|
}),
|
|
Object.freeze({
|
|
tool: BUILTIN_RUN_READ_TOOL,
|
|
definition: BUILTIN_RUN_READ_TOOL_DEFINITION,
|
|
title: 'QingLong Run',
|
|
auditReason: 'tool_qinglong_run_get',
|
|
unavailableCode: 'run_query_unavailable',
|
|
execute: (
|
|
authority: LocalMcpReadAuthority,
|
|
projectId: string,
|
|
input: ToolJsonValue,
|
|
) => executeBuiltInRunReadTool(authority.runs, projectId, input),
|
|
}),
|
|
Object.freeze({
|
|
tool: BUILTIN_RUN_EVENT_LIST_TOOL,
|
|
definition: BUILTIN_RUN_EVENT_LIST_TOOL_DEFINITION,
|
|
title: 'QingLong Run Events',
|
|
auditReason: 'tool_qinglong_run_events_list',
|
|
unavailableCode: 'run_event_query_unavailable',
|
|
execute: (
|
|
authority: LocalMcpReadAuthority,
|
|
projectId: string,
|
|
input: ToolJsonValue,
|
|
) => executeBuiltInRunEventListTool(authority.runs, projectId, input),
|
|
}),
|
|
Object.freeze({
|
|
tool: BUILTIN_RUN_STEP_LIST_TOOL,
|
|
definition: BUILTIN_RUN_STEP_LIST_TOOL_DEFINITION,
|
|
title: 'QingLong Run Steps',
|
|
auditReason: 'tool_qinglong_run_steps_list',
|
|
unavailableCode: 'run_step_query_unavailable',
|
|
execute: (
|
|
authority: LocalMcpReadAuthority,
|
|
projectId: string,
|
|
input: ToolJsonValue,
|
|
) =>
|
|
executeBuiltInRunStepListTool(
|
|
authority.runs,
|
|
authority.stepRuns,
|
|
projectId,
|
|
input,
|
|
),
|
|
}),
|
|
Object.freeze({
|
|
tool: BUILTIN_TASK_GET_TOOL,
|
|
definition: BUILTIN_TASK_GET_TOOL_DEFINITION,
|
|
title: 'QingLong Task',
|
|
auditReason: 'tool_qinglong_task_get',
|
|
unavailableCode: 'task_query_unavailable',
|
|
execute: (
|
|
authority: LocalMcpReadAuthority,
|
|
projectId: string,
|
|
input: ToolJsonValue,
|
|
) =>
|
|
executeBuiltInTaskGetTool(authority.taskDefinitions, projectId, input),
|
|
}),
|
|
Object.freeze({
|
|
tool: BUILTIN_TASK_LIST_TOOL,
|
|
definition: BUILTIN_TASK_LIST_TOOL_DEFINITION,
|
|
title: 'QingLong Tasks',
|
|
auditReason: 'tool_qinglong_task_list',
|
|
unavailableCode: 'task_list_unavailable',
|
|
execute: (
|
|
authority: LocalMcpReadAuthority,
|
|
projectId: string,
|
|
input: ToolJsonValue,
|
|
) =>
|
|
executeBuiltInTaskListTool(authority.taskDefinitions, projectId, input),
|
|
}),
|
|
Object.freeze({
|
|
tool: BUILTIN_TRIGGER_LIST_TOOL,
|
|
definition: BUILTIN_TRIGGER_LIST_TOOL_DEFINITION,
|
|
title: 'QingLong Triggers',
|
|
auditReason: 'tool_qinglong_trigger_list',
|
|
unavailableCode: 'trigger_list_unavailable',
|
|
execute: (
|
|
authority: LocalMcpReadAuthority,
|
|
projectId: string,
|
|
input: ToolJsonValue,
|
|
) => executeBuiltInTriggerListTool(authority.triggers, projectId, input),
|
|
}),
|
|
Object.freeze({
|
|
tool: BUILTIN_APPROVAL_LIST_TOOL,
|
|
definition: BUILTIN_APPROVAL_LIST_TOOL_DEFINITION,
|
|
title: 'QingLong Approvals',
|
|
auditReason: 'tool_qinglong_approval_list',
|
|
unavailableCode: 'approval_list_unavailable',
|
|
execute: (
|
|
authority: LocalMcpReadAuthority,
|
|
projectId: string,
|
|
input: ToolJsonValue,
|
|
) =>
|
|
executeBuiltInApprovalListTool(authority.approvals, projectId, input),
|
|
}),
|
|
Object.freeze({
|
|
tool: BUILTIN_APPROVAL_GET_TOOL,
|
|
definition: BUILTIN_APPROVAL_GET_TOOL_DEFINITION,
|
|
title: 'QingLong Approval',
|
|
auditReason: 'tool_qinglong_approval_get',
|
|
unavailableCode: 'approval_query_unavailable',
|
|
execute: (
|
|
authority: LocalMcpReadAuthority,
|
|
projectId: string,
|
|
input: ToolJsonValue,
|
|
) => executeBuiltInApprovalGetTool(authority.approvals, projectId, input),
|
|
}),
|
|
]);
|
|
|
|
function validateDependencies(
|
|
dependencies: QingLongLocalMcpServerDependencies,
|
|
): void {
|
|
if (
|
|
!dependencies ||
|
|
typeof dependencies !== 'object' ||
|
|
Array.isArray(dependencies) ||
|
|
typeof dependencies.projectId !== 'string' ||
|
|
typeof dependencies.authenticate !== 'function' ||
|
|
typeof dependencies.policy?.authorize !== 'function' ||
|
|
typeof dependencies.audit?.record !== 'function' ||
|
|
typeof dependencies.runs?.listRunsByProject !== 'function' ||
|
|
typeof dependencies.runs?.findRunById !== 'function' ||
|
|
typeof dependencies.runs?.listEvents !== 'function' ||
|
|
typeof dependencies.stepRuns?.listByRun !== 'function' ||
|
|
typeof dependencies.taskDefinitions?.findCurrentTaskDefinition !==
|
|
'function' ||
|
|
typeof dependencies.taskDefinitions?.listTaskDefinitions !== 'function' ||
|
|
typeof dependencies.triggers?.listTriggers !== 'function' ||
|
|
typeof dependencies.approvals?.listApprovalRequests !== 'function' ||
|
|
typeof dependencies.approvals?.getApprovalRequestDetail !== 'function' ||
|
|
(dependencies.now !== undefined &&
|
|
typeof dependencies.now !== 'function') ||
|
|
(dependencies.randomUuid !== undefined &&
|
|
typeof dependencies.randomUuid !== 'function')
|
|
) {
|
|
throw new TypeError('Local MCP server dependencies are invalid');
|
|
}
|
|
}
|
|
|
|
function toolError(code: string): CallToolResult {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: 'text' as const, text: JSON.stringify({ code }) }],
|
|
};
|
|
}
|
|
|
|
function timestamp(now: () => number): number {
|
|
let value: number;
|
|
try {
|
|
value = now();
|
|
} catch {
|
|
throw new LocalMcpAdmissionError('clock_unavailable');
|
|
}
|
|
if (!Number.isSafeInteger(value) || value < 0) {
|
|
throw new LocalMcpAdmissionError('clock_unavailable');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async function recordAudit(
|
|
dependencies: QingLongLocalMcpServerDependencies,
|
|
requestId: string,
|
|
outcome: SecurityAuditOutcome,
|
|
reasons: readonly string[],
|
|
principal: Readonly<SecurityPrincipal> | null,
|
|
fence: Readonly<{
|
|
projectVersion: number;
|
|
bindingVersion: number | null;
|
|
}> | null,
|
|
now: () => number,
|
|
uuid: () => string,
|
|
): Promise<void> {
|
|
try {
|
|
await dependencies.audit.record(
|
|
normalizeSecurityAuditRecord({
|
|
eventId: uuid(),
|
|
requestId,
|
|
operationId: MCP_OPERATION_ID,
|
|
projectId: dependencies.projectId,
|
|
subject: principal?.subject ?? null,
|
|
authenticationId: principal?.authenticationId ?? null,
|
|
outcome,
|
|
reasons,
|
|
fence,
|
|
occurredAtMs: timestamp(now),
|
|
}),
|
|
);
|
|
} catch {
|
|
throw new LocalMcpAdmissionError('security_audit_unavailable');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates one read-only MCP endpoint. Every Tool call re-authenticates, passes
|
|
* the shared Tool Registry and Project Policy, records durable admission,
|
|
* confirms the credential fence, and only then performs a bounded read.
|
|
*/
|
|
export function createQingLongLocalMcpServer(
|
|
dependencies: QingLongLocalMcpServerDependencies,
|
|
): McpServer {
|
|
validateDependencies(dependencies);
|
|
const now = dependencies.now ?? Date.now;
|
|
const uuid = dependencies.randomUuid ?? randomUUID;
|
|
const registry = new ToolDefinitionRegistry(
|
|
LOCAL_MCP_READ_TOOLS.map(({ definition }) => definition),
|
|
);
|
|
const server = new McpServer(QINGLONG_LOCAL_MCP_SERVER, {
|
|
capabilities: { tools: {} },
|
|
});
|
|
for (const descriptor of LOCAL_MCP_READ_TOOLS) {
|
|
const definition = registry.resolve(
|
|
descriptor.tool.name,
|
|
descriptor.tool.version,
|
|
);
|
|
const inputSchema = fromJsonSchema<Record<string, unknown>>(
|
|
definition.inputSchema as unknown as JsonSchemaType,
|
|
);
|
|
const outputSchema = fromJsonSchema<Record<string, unknown>>(
|
|
definition.outputSchema as unknown as JsonSchemaType,
|
|
);
|
|
server.registerTool(
|
|
definition.name,
|
|
{
|
|
title: descriptor.title,
|
|
description: definition.description,
|
|
inputSchema,
|
|
outputSchema,
|
|
annotations: {
|
|
readOnlyHint: true,
|
|
destructiveHint: false,
|
|
idempotentHint: true,
|
|
openWorldHint: false,
|
|
},
|
|
},
|
|
async (argumentsValue): Promise<CallToolResult> => {
|
|
const requestId = `mcp:${uuid()}`;
|
|
let authenticated: Readonly<AuthenticatedLocalMcpRequest> | null;
|
|
try {
|
|
authenticated = await dependencies.authenticate();
|
|
} catch {
|
|
try {
|
|
await recordAudit(
|
|
dependencies,
|
|
requestId,
|
|
'authentication_unavailable',
|
|
['authentication_unavailable', descriptor.auditReason],
|
|
null,
|
|
null,
|
|
now,
|
|
uuid,
|
|
);
|
|
} catch (error) {
|
|
return toolError((error as LocalMcpAdmissionError).code);
|
|
}
|
|
return toolError('authentication_unavailable');
|
|
}
|
|
if (!authenticated) {
|
|
try {
|
|
await recordAudit(
|
|
dependencies,
|
|
requestId,
|
|
'authentication_rejected',
|
|
['authentication_rejected', descriptor.auditReason],
|
|
null,
|
|
null,
|
|
now,
|
|
uuid,
|
|
);
|
|
} catch (error) {
|
|
return toolError((error as LocalMcpAdmissionError).code);
|
|
}
|
|
return toolError('authentication_required');
|
|
}
|
|
|
|
let plan;
|
|
try {
|
|
plan = await prepareToolInvocation(
|
|
registry,
|
|
{
|
|
projectId: dependencies.projectId,
|
|
principal: authenticated.principal,
|
|
nowMs: timestamp(now),
|
|
tool: descriptor.tool,
|
|
input: argumentsValue,
|
|
},
|
|
dependencies.policy,
|
|
);
|
|
} catch (error) {
|
|
const code =
|
|
error instanceof InvalidToolJsonValueError
|
|
? 'invalid_tool_input'
|
|
: error instanceof ToolPolicySnapshotConflictError
|
|
? 'policy_fence_conflict'
|
|
: error instanceof ToolPolicyUnavailableError
|
|
? 'authorization_unavailable'
|
|
: 'authorization_unavailable';
|
|
try {
|
|
await recordAudit(
|
|
dependencies,
|
|
requestId,
|
|
'authorization_unavailable',
|
|
[code, descriptor.auditReason],
|
|
authenticated.principal,
|
|
null,
|
|
now,
|
|
uuid,
|
|
);
|
|
} catch (auditError) {
|
|
return toolError((auditError as LocalMcpAdmissionError).code);
|
|
}
|
|
return toolError(code);
|
|
}
|
|
|
|
if (plan.status === 'denied' || plan.status === 'approval_required') {
|
|
const approvalRequired = plan.status === 'approval_required';
|
|
try {
|
|
await recordAudit(
|
|
dependencies,
|
|
requestId,
|
|
approvalRequired ? 'approval_required' : 'denied',
|
|
[
|
|
approvalRequired
|
|
? 'tool_approval_required'
|
|
: 'tool_invocation_denied',
|
|
descriptor.auditReason,
|
|
],
|
|
authenticated.principal,
|
|
plan.status === 'denied' ? null : plan.fence,
|
|
now,
|
|
uuid,
|
|
);
|
|
} catch (error) {
|
|
return toolError((error as LocalMcpAdmissionError).code);
|
|
}
|
|
return toolError(
|
|
approvalRequired ? 'approval_required' : 'forbidden',
|
|
);
|
|
}
|
|
|
|
try {
|
|
await recordAudit(
|
|
dependencies,
|
|
requestId,
|
|
'allowed',
|
|
['tool_invocation_allowed', descriptor.auditReason],
|
|
authenticated.principal,
|
|
plan.fence,
|
|
now,
|
|
uuid,
|
|
);
|
|
await authenticated.confirm();
|
|
const output = registry.normalizeOutput(
|
|
definition.name,
|
|
definition.version,
|
|
await descriptor.execute(
|
|
dependencies,
|
|
dependencies.projectId,
|
|
plan.input,
|
|
),
|
|
);
|
|
if (!output || typeof output !== 'object' || Array.isArray(output)) {
|
|
return toolError(descriptor.unavailableCode);
|
|
}
|
|
const structuredContent = output as Record<string, ToolJsonValue>;
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text' as const,
|
|
text: JSON.stringify(structuredContent),
|
|
},
|
|
],
|
|
structuredContent,
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof LocalMcpAdmissionError) {
|
|
return toolError(error.code);
|
|
}
|
|
return toolError(descriptor.unavailableCode);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
return server;
|
|
}
|