NODEDC_TASKMANAGER_CODEXAPI/src/mcp/tool-runtime.ts

689 lines
23 KiB
TypeScript

import { createHash } from "node:crypto";
import { z } from "zod";
import type { AgentScope } from "../domain/scopes.js";
import { structuredBlocksSchema } from "../domain/structured-blocks.js";
import type { AgentsRepository, AgentSessionRecord } from "../repositories/agents.js";
import { ForbiddenError, requireProjectGrant, requireScope } from "../security/authorization.js";
import type { TaskerClient } from "../tasker/client.js";
type JsonSchema = Record<string, unknown>;
export type McpToolRuntimeDefinition = {
name: string;
title: string;
description: string;
requiredScopes: AgentScope[];
inputSchema: JsonSchema;
annotations?: Record<string, unknown>;
};
export type McpToolResult = {
content: Array<{
type: "text";
text: string;
}>;
structuredContent?: unknown;
isError?: boolean;
};
type ExecuteToolDeps = {
agentsRepository?: AgentsRepository | null;
taskerClient: TaskerClient;
};
type ExecuteToolOptions = {
source?: "mcp" | "rest";
idempotencyKey?: string | null;
};
const emptyInputSchema = {
type: "object",
additionalProperties: false,
};
const projectInputSchema = {
type: "object",
properties: {
project_id: { type: "string" },
workspace_slug: { type: "string" },
},
required: ["project_id"],
additionalProperties: false,
};
const projectAndIssueInputSchema = {
type: "object",
properties: {
issue_id: { type: "string" },
project_id: { type: "string" },
workspace_slug: { type: "string" },
},
required: ["issue_id", "project_id"],
additionalProperties: false,
};
const structuredBlocksJsonSchema = {
type: "array",
items: {
oneOf: [
{
type: "object",
properties: {
id: { type: "string" },
type: { const: "text" },
title: {
type: "string",
description: "Visible block title. Put headings here, not inside body markdown.",
},
body: {
type: "string",
description: "Plain block body without a leading markdown heading.",
},
},
required: ["id", "type", "title", "body"],
additionalProperties: false,
},
{
type: "object",
properties: {
id: { type: "string" },
type: { const: "checker" },
title: {
type: "string",
description: "Visible checklist title. Put headings here, not inside item text.",
},
items: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string" },
text: { type: "string" },
checked: { type: "boolean" },
},
required: ["id", "text"],
additionalProperties: false,
},
},
},
required: ["id", "type", "title", "items"],
additionalProperties: false,
},
],
},
};
export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
{
name: "tasker_get_agent_instructions",
title: "Get Agent Instructions",
description: "Return effective NODE.DC Tasker card-writing rules, grants, scopes, and mode expectations.",
requiredScopes: ["workspace:read"],
inputSchema: emptyInputSchema,
annotations: { readOnlyHint: true },
},
{
name: "tasker_list_projects",
title: "List Granted Projects",
description: "List Tasker projects granted to the current agent.",
requiredScopes: ["project:read"],
inputSchema: emptyInputSchema,
annotations: { readOnlyHint: true },
},
{
name: "tasker_get_project_context",
title: "Get Project Context",
description: "Return states, labels, members, and card-writing context for one granted project.",
requiredScopes: ["project:read"],
inputSchema: projectInputSchema,
annotations: { readOnlyHint: true },
},
{
name: "tasker_search_issues",
title: "Search Issues",
description: "Search work items inside one granted Tasker project.",
requiredScopes: ["issue:read"],
inputSchema: {
...projectInputSchema,
properties: {
...projectInputSchema.properties,
query: { type: "string" },
},
},
annotations: { readOnlyHint: true },
},
{
name: "tasker_create_issue",
title: "Create Issue",
description: "Create a Tasker card with optional NODE.DC structured text/checker blocks.",
requiredScopes: ["issue:create"],
inputSchema: {
type: "object",
properties: {
project_id: { type: "string" },
workspace_slug: { type: "string" },
title: { type: "string" },
description: { type: "string" },
priority: { type: "string", enum: ["none", "low", "medium", "high", "urgent"] },
structured_blocks: structuredBlocksJsonSchema,
idempotency_key: { type: "string" },
},
required: ["project_id", "title"],
additionalProperties: false,
},
annotations: { destructiveHint: false, idempotentHint: false },
},
{
name: "tasker_update_issue",
title: "Update Issue",
description: "Patch allowed issue fields without delete, archive, or project transfer.",
requiredScopes: ["issue:update"],
inputSchema: {
type: "object",
properties: {
issue_id: { type: "string" },
project_id: { type: "string" },
workspace_slug: { type: "string" },
title: { type: "string" },
description: { type: "string" },
priority: { type: "string", enum: ["none", "low", "medium", "high", "urgent"] },
structured_blocks: structuredBlocksJsonSchema,
idempotency_key: { type: "string" },
},
required: ["issue_id", "project_id"],
additionalProperties: false,
},
annotations: { destructiveHint: false, idempotentHint: false },
},
{
name: "tasker_update_structured_blocks",
title: "Update Structured Blocks",
description: "Replace NODE.DC structured text/checker blocks in an issue detail layout.",
requiredScopes: ["issue:update", "issue:structured_blocks:write"],
inputSchema: {
...projectAndIssueInputSchema,
properties: {
...projectAndIssueInputSchema.properties,
structured_blocks: structuredBlocksJsonSchema,
idempotency_key: { type: "string" },
},
required: ["issue_id", "project_id", "structured_blocks"],
},
annotations: { destructiveHint: false, idempotentHint: false },
},
{
name: "tasker_move_issue",
title: "Move Issue",
description: "Move an issue to an existing state in the same granted project.",
requiredScopes: ["issue:move"],
inputSchema: {
...projectAndIssueInputSchema,
properties: {
...projectAndIssueInputSchema.properties,
state_id: { type: "string" },
idempotency_key: { type: "string" },
},
required: ["issue_id", "project_id", "state_id"],
},
annotations: { destructiveHint: false, idempotentHint: false },
},
{
name: "tasker_append_comment",
title: "Append Comment",
description: "Append a comment to a granted issue.",
requiredScopes: ["issue:comment"],
inputSchema: {
...projectAndIssueInputSchema,
properties: {
...projectAndIssueInputSchema.properties,
body: { type: "string" },
idempotency_key: { type: "string" },
},
required: ["issue_id", "project_id", "body"],
},
annotations: { destructiveHint: false, idempotentHint: false },
},
{
name: "tasker_ensure_labels",
title: "Ensure Project Labels",
description: "Create missing labels in a granted project and return label ids for subsequent issue labeling.",
requiredScopes: ["issue:label"],
inputSchema: {
...projectInputSchema,
properties: {
...projectInputSchema.properties,
labels: {
type: "array",
minItems: 1,
maxItems: 50,
items: {
type: "object",
properties: {
name: { type: "string" },
color: {
type: "string",
description: "Optional hex color in #RRGGBB format.",
},
},
required: ["name"],
additionalProperties: false,
},
},
idempotency_key: { type: "string" },
},
required: ["project_id", "labels"],
},
annotations: { destructiveHint: false, idempotentHint: true },
},
{
name: "tasker_set_issue_labels",
title: "Set Issue Labels",
description: "Replace issue labels with existing or ensured labels from the granted project.",
requiredScopes: ["issue:label"],
inputSchema: {
...projectAndIssueInputSchema,
properties: {
...projectAndIssueInputSchema.properties,
label_ids: { type: "array", items: { type: "string" } },
idempotency_key: { type: "string" },
},
required: ["issue_id", "project_id", "label_ids"],
},
annotations: { destructiveHint: false, idempotentHint: true },
},
{
name: "tasker_assign_issue",
title: "Assign Issue",
description: "Replace issue assignees with existing members of the granted project.",
requiredScopes: ["issue:assign"],
inputSchema: {
...projectAndIssueInputSchema,
properties: {
...projectAndIssueInputSchema.properties,
member_ids: { type: "array", items: { type: "string" } },
idempotency_key: { type: "string" },
},
required: ["issue_id", "project_id", "member_ids"],
},
annotations: { destructiveHint: false, idempotentHint: true },
},
];
const emptyArgsSchema = z.object({}).default({});
const projectArgsSchema = z.object({
project_id: z.string().min(1),
workspace_slug: z.string().min(1).nullish(),
});
const searchIssuesArgsSchema = projectArgsSchema.extend({
query: z.string().min(1).optional(),
});
const prioritySchema = z.enum(["none", "low", "medium", "high", "urgent"]);
const createIssueArgsSchema = z.object({
project_id: z.string().min(1),
workspace_slug: z.string().min(1).nullish(),
title: z.string().min(1).max(500),
description: z.string().max(20000).optional(),
priority: prioritySchema.optional(),
structured_blocks: structuredBlocksSchema.optional(),
idempotency_key: z.string().optional(),
});
const issueArgsSchema = z.object({
issue_id: z.string().min(1),
project_id: z.string().min(1),
workspace_slug: z.string().min(1).nullish(),
});
const updateIssueArgsSchema = issueArgsSchema.extend({
title: z.string().min(1).max(500).optional(),
description: z.string().max(20000).optional(),
priority: prioritySchema.optional(),
structured_blocks: structuredBlocksSchema.optional(),
});
const structuredBlocksArgsSchema = issueArgsSchema.extend({
structured_blocks: structuredBlocksSchema,
});
const moveIssueArgsSchema = issueArgsSchema.extend({
state_id: z.string().min(1),
});
const commentArgsSchema = issueArgsSchema.extend({
body: z.string().min(1).max(20000),
});
const ensureLabelsArgsSchema = projectArgsSchema.extend({
labels: z
.array(
z.object({
name: z.string().min(1).max(255),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.optional(),
})
)
.min(1)
.max(50),
});
const labelsArgsSchema = issueArgsSchema.extend({
label_ids: z.array(z.string().min(1)).default([]),
});
const assigneesArgsSchema = issueArgsSchema.extend({
member_ids: z.array(z.string().min(1)).default([]),
});
export function getToolsForSession(session: AgentSessionRecord): McpToolRuntimeDefinition[] {
return mcpRuntimeTools.filter((tool) => tool.requiredScopes.every((scope) => hasScope(session, scope)));
}
export async function executeMcpTool(
session: AgentSessionRecord,
name: string,
rawArguments: unknown,
deps: ExecuteToolDeps,
options: ExecuteToolOptions = {}
): Promise<McpToolResult> {
const tool = mcpRuntimeTools.find((candidate) => candidate.name === name);
if (!tool) {
throw new Error(`Unknown MCP tool: ${name}`);
}
const { args, idempotencyKey } = prepareToolArguments(rawArguments, options.idempotencyKey);
const isWriteTool = tool.annotations?.readOnlyHint !== true;
if (!isWriteTool) {
return executeMcpToolOnce(session, name, args, deps);
}
if (!deps.agentsRepository) {
throw new ToolExecutionInputError("idempotency_unavailable", "Agent Gateway persistence is required for write tools.", 503);
}
if (!idempotencyKey) {
throw new ToolExecutionInputError("idempotency_key_required", "Write tools require an idempotency key.", 400);
}
const requestHash = hashToolRequest(name, args);
const claim = await deps.agentsRepository.claimIdempotencyKey(session.agent.id, idempotencyKey, requestHash);
if (claim.status === "replay") {
await deps.agentsRepository.createAuditEvent(session.agent.id, "agent.tool.replayed", session.agent.ownerUserId, {
source: options.source ?? "mcp",
toolName: name,
idempotencyKey,
});
return claim.responseBody as McpToolResult;
}
if (claim.status === "conflict") {
throw new ToolExecutionInputError("idempotency_key_conflict", "Idempotency key was already used with different arguments.", 409);
}
if (claim.status === "in_progress") {
throw new ToolExecutionInputError("idempotency_key_in_progress", "Idempotency key is currently processing.", 409, {
lockedUntil: claim.lockedUntil,
});
}
try {
const result = await executeMcpToolOnce(session, name, args, deps);
await deps.agentsRepository.completeIdempotencyKey(session.agent.id, idempotencyKey, result);
await deps.agentsRepository.createAuditEvent(session.agent.id, "agent.tool.executed", session.agent.ownerUserId, {
source: options.source ?? "mcp",
toolName: name,
idempotencyKey,
arguments: summarizeToolArguments(args),
});
return result;
} catch (error) {
await deps.agentsRepository.releaseIdempotencyKey(session.agent.id, idempotencyKey);
await deps.agentsRepository.createAuditEvent(session.agent.id, "agent.tool.failed", session.agent.ownerUserId, {
source: options.source ?? "mcp",
toolName: name,
idempotencyKey,
error: error instanceof Error ? error.name : "unknown_error",
message: error instanceof Error ? error.message : "Unknown tool execution error.",
});
throw error;
}
}
async function executeMcpToolOnce(
session: AgentSessionRecord,
name: string,
args: unknown,
deps: ExecuteToolDeps
): Promise<McpToolResult> {
switch (name) {
case "tasker_get_agent_instructions":
emptyArgsSchema.parse(args);
requireScope(session, "workspace:read");
return asToolResult(buildAgentInstructions(session));
case "tasker_list_projects":
emptyArgsSchema.parse(args);
requireScope(session, "project:read");
return asToolResult(await deps.taskerClient.listGrantedProjects(session));
case "tasker_get_project_context": {
const input = projectArgsSchema.parse(args);
requireScope(session, "project:read");
requireProjectGrant(session, { projectId: input.project_id, workspaceSlug: input.workspace_slug });
return asToolResult(await deps.taskerClient.getProjectContext(session, input.project_id, input.workspace_slug));
}
case "tasker_search_issues": {
const input = searchIssuesArgsSchema.parse(args);
requireToolAccess(session, "issue:read", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.listIssues(session, input.project_id, input.workspace_slug, input.query));
}
case "tasker_create_issue": {
const input = createIssueArgsSchema.parse(args);
requireToolAccess(session, "issue:create", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.createIssue(session, input));
}
case "tasker_update_issue": {
const input = updateIssueArgsSchema.parse(args);
if (input.structured_blocks) {
requireProjectScopes(session, input.project_id, input.workspace_slug, ["issue:update", "issue:structured_blocks:write"]);
} else {
requireToolAccess(session, "issue:update", input.project_id, input.workspace_slug);
}
return asToolResult(await deps.taskerClient.updateIssue(session, input.issue_id, input));
}
case "tasker_update_structured_blocks": {
const input = structuredBlocksArgsSchema.parse(args);
requireProjectScopes(session, input.project_id, input.workspace_slug, ["issue:update", "issue:structured_blocks:write"]);
return asToolResult(
await deps.taskerClient.updateIssue(session, input.issue_id, {
project_id: input.project_id,
workspace_slug: input.workspace_slug,
structured_blocks: input.structured_blocks,
})
);
}
case "tasker_move_issue": {
const input = moveIssueArgsSchema.parse(args);
requireToolAccess(session, "issue:move", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.moveIssue(session, input.issue_id, input));
}
case "tasker_append_comment": {
const input = commentArgsSchema.parse(args);
requireToolAccess(session, "issue:comment", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.appendComment(session, input.issue_id, input));
}
case "tasker_ensure_labels": {
const input = ensureLabelsArgsSchema.parse(args);
requireToolAccess(session, "issue:label", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.ensureLabels(session, input));
}
case "tasker_set_issue_labels": {
const input = labelsArgsSchema.parse(args);
requireToolAccess(session, "issue:label", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.setLabels(session, input.issue_id, input));
}
case "tasker_assign_issue": {
const input = assigneesArgsSchema.parse(args);
requireToolAccess(session, "issue:assign", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.assignIssue(session, input.issue_id, input));
}
default:
throw new Error(`Unknown MCP tool: ${name}`);
}
}
function hasScope(session: AgentSessionRecord, scope: AgentScope): boolean {
return session.grants.some((grant) => grant.scopes.includes(scope));
}
function requireToolAccess(session: AgentSessionRecord, scope: AgentScope, projectId: string, workspaceSlug?: string | null): void {
requireProjectScopes(session, projectId, workspaceSlug, [scope]);
}
function requireProjectScopes(
session: AgentSessionRecord,
projectId: string,
workspaceSlug: string | null | undefined,
scopes: AgentScope[]
): void {
for (const scope of scopes) {
requireScope(session, scope);
}
const grant = requireProjectGrant(session, { projectId, workspaceSlug });
for (const scope of scopes) {
if (!grant.scopes.includes(scope)) {
throw new ForbiddenError(`Grant for project does not include required scope: ${scope}.`);
}
}
}
function asToolResult(payload: unknown): McpToolResult {
return {
content: [
{
type: "text",
text: JSON.stringify(payload, null, 2),
},
],
structuredContent: payload,
isError: false,
};
}
export class ToolExecutionInputError extends Error {
constructor(
readonly code: string,
message: string,
readonly httpStatus: number,
readonly details?: Record<string, unknown>
) {
super(message);
this.name = "ToolExecutionInputError";
}
}
function prepareToolArguments(rawArguments: unknown, headerIdempotencyKey?: string | null): { args: unknown; idempotencyKey: string | null } {
if (!isPlainRecord(rawArguments)) {
return {
args: rawArguments ?? {},
idempotencyKey: normalizeIdempotencyKey(headerIdempotencyKey),
};
}
const { idempotency_key: bodyIdempotencyKey, ...args } = rawArguments;
return {
args,
idempotencyKey: normalizeIdempotencyKey(headerIdempotencyKey ?? (typeof bodyIdempotencyKey === "string" ? bodyIdempotencyKey : null)),
};
}
function normalizeIdempotencyKey(value?: string | null): string | null {
const normalized = value?.trim();
if (!normalized) {
return null;
}
if (normalized.length > 200) {
throw new ToolExecutionInputError("idempotency_key_invalid", "Idempotency key must be 200 characters or fewer.", 400);
}
return normalized;
}
function hashToolRequest(name: string, args: unknown): string {
return createHash("sha256").update(stableStringify({ name, args })).digest("hex");
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
}
if (isPlainRecord(value)) {
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
.join(",")}}`;
}
return JSON.stringify(value);
}
function summarizeToolArguments(args: unknown): Record<string, unknown> {
if (!isPlainRecord(args)) {
return {};
}
return {
workspace_slug: args.workspace_slug,
project_id: args.project_id,
issue_id: args.issue_id,
state_id: args.state_id,
labels_count: Array.isArray(args.labels) ? args.labels.length : undefined,
has_structured_blocks: Array.isArray(args.structured_blocks),
};
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function buildAgentInstructions(session: AgentSessionRecord): Record<string, unknown> {
return {
agent: {
id: session.agent.id,
display_name: session.agent.displayName,
owner_user_id: session.agent.ownerUserId,
},
grants: session.grants.map((grant) => ({
workspace_slug: grant.workspaceSlug,
project_id: grant.projectId,
mode: grant.mode,
scopes: grant.scopes,
})),
rules: {
card_structure: [
"Keep the main issue description concise and conceptual.",
"Use structured text blocks for current architecture, planned architecture, and implementation notes.",
"Every structured text block must use its title field for the heading; do not duplicate markdown headings like ## Status inside the body.",
"Wrong structured text block: title omitted and body starts with ## Текущая архитектура. Correct: title is Текущая архитектура and body contains only the section content.",
"Use checker blocks with explicit titles for short verifiable phase items.",
"After implementation, add a factual implementation block with files touched and validation performed.",
],
labels: [
"Use tasker_ensure_labels before setting a label that is not already present in project context.",
"Use tasker_set_issue_labels with label ids returned by project context or tasker_ensure_labels.",
],
hard_limits: [
"Do not delete or archive issues.",
"Do not create projects, states, workspace invites, or workspace settings changes.",
"Only create labels through tasker_ensure_labels inside granted projects.",
"Only assign existing project members.",
"Only use projects and workspaces present in effective grants.",
],
reporting_mode: "If a grant has mode=reporting, keep issue status and comments up to date without pretending to enforce unmanaged local Codex execution.",
},
};
}