feat(ops): add workspace MCP deployment overlays
This commit is contained in:
+849
@@ -0,0 +1,849 @@
|
||||
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: "NODE.DC Ops: Get Agent Instructions",
|
||||
description:
|
||||
"Direct NODE.DC Ops MCP tool. Return effective Tasker card-writing rules, grants, scopes, and mode expectations. Use this before any Ops card read/write workflow.",
|
||||
requiredScopes: ["workspace:read"],
|
||||
inputSchema: emptyInputSchema,
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: "tasker_list_projects",
|
||||
title: "NODE.DC Ops: List Granted Projects",
|
||||
description:
|
||||
"Direct NODE.DC Ops MCP tool. List Tasker/Ops projects granted to the current Codex agent. Use this instead of Codex Apps workspace widgets.",
|
||||
requiredScopes: ["project:read"],
|
||||
inputSchema: emptyInputSchema,
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: "tasker_get_project_context",
|
||||
title: "NODE.DC Ops: Get Project Context",
|
||||
description:
|
||||
"Direct NODE.DC Ops MCP tool. Return states, labels, members, and card-writing context for one granted Tasker/Ops project.",
|
||||
requiredScopes: ["project:read"],
|
||||
inputSchema: projectInputSchema,
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: "tasker_create_project",
|
||||
title: "NODE.DC Ops: Create Project",
|
||||
description:
|
||||
"Direct NODE.DC Ops MCP write tool. Create a new Tasker project inside an explicitly granted workspace and extend the current agent grant to the created project.",
|
||||
requiredScopes: ["project:create"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
workspace_slug: { type: "string" },
|
||||
name: { type: "string" },
|
||||
identifier: {
|
||||
type: "string",
|
||||
description: "Optional 1-12 character Tasker project identifier. A unique identifier is generated when omitted.",
|
||||
},
|
||||
description: { type: "string" },
|
||||
idempotency_key: { type: "string" },
|
||||
},
|
||||
required: ["workspace_slug", "name"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
annotations: { destructiveHint: false, idempotentHint: false },
|
||||
},
|
||||
{
|
||||
name: "tasker_search_issues",
|
||||
title: "NODE.DC Ops: Search Cards",
|
||||
description:
|
||||
"Direct NODE.DC Ops MCP tool. Search Tasker/Ops cards and work items inside one granted project. Use this for 'show cards', 'find cards', and project context requests.",
|
||||
requiredScopes: ["issue:read"],
|
||||
inputSchema: {
|
||||
...projectInputSchema,
|
||||
properties: {
|
||||
...projectInputSchema.properties,
|
||||
query: { type: "string" },
|
||||
},
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: "tasker_get_issue",
|
||||
title: "NODE.DC Ops: Get Card",
|
||||
description:
|
||||
"Direct NODE.DC Ops MCP tool. Return one granted Tasker/Ops card with structured blocks, labels, assignees, full comment bodies, and attachment metadata.",
|
||||
requiredScopes: ["issue:read"],
|
||||
inputSchema: projectAndIssueInputSchema,
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: "tasker_create_issue",
|
||||
title: "NODE.DC Ops: Create Card",
|
||||
description:
|
||||
"Direct NODE.DC Ops MCP write tool. Create a Tasker/Ops 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: "NODE.DC Ops: Update Card",
|
||||
description:
|
||||
"Direct NODE.DC Ops MCP write tool. Patch allowed Tasker/Ops card 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: "NODE.DC Ops: Update Structured Blocks",
|
||||
description:
|
||||
"Direct NODE.DC Ops MCP write tool. Replace NODE.DC structured text/checker blocks in a Tasker/Ops card 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: "NODE.DC Ops: Move Card",
|
||||
description: "Direct NODE.DC Ops MCP write tool. Move a Tasker/Ops card 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: "NODE.DC Ops: Append Comment",
|
||||
description: "Direct NODE.DC Ops MCP write tool. Append a comment to a granted Tasker/Ops card.",
|
||||
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_attach_file",
|
||||
title: "NODE.DC Ops: Attach File",
|
||||
description:
|
||||
"Direct NODE.DC Ops MCP write tool. Attach a base64-encoded local file to a granted card through Tasker's existing storage, quota, and deduplication path.",
|
||||
requiredScopes: ["issue:attachment:write"],
|
||||
inputSchema: {
|
||||
...projectAndIssueInputSchema,
|
||||
properties: {
|
||||
...projectAndIssueInputSchema.properties,
|
||||
file_name: { type: "string", description: "File name only; paths are rejected." },
|
||||
mime_type: { type: "string" },
|
||||
content_base64: {
|
||||
type: "string",
|
||||
maxLength: 7000000,
|
||||
contentEncoding: "base64",
|
||||
description: "Base64 file content. Raw decoded size must not exceed 5 MiB.",
|
||||
},
|
||||
idempotency_key: { type: "string" },
|
||||
},
|
||||
required: ["issue_id", "project_id", "file_name", "mime_type", "content_base64"],
|
||||
},
|
||||
annotations: { destructiveHint: false, idempotentHint: false },
|
||||
},
|
||||
{
|
||||
name: "tasker_ensure_labels",
|
||||
title: "NODE.DC Ops: Ensure Project Labels",
|
||||
description:
|
||||
"Direct NODE.DC Ops MCP write tool. Create missing labels in a granted project and return label ids for subsequent card 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: "NODE.DC Ops: Set Card Labels",
|
||||
description: "Direct NODE.DC Ops MCP write tool. Replace card 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: "NODE.DC Ops: Assign Card",
|
||||
description: "Direct NODE.DC Ops MCP write tool. Replace card 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 createProjectArgsSchema = z.object({
|
||||
workspace_slug: z.string().min(1).max(255),
|
||||
name: z.string().min(1).max(255),
|
||||
identifier: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(12)
|
||||
.regex(/^[A-Za-z0-9_]+$/)
|
||||
.optional(),
|
||||
description: z.string().max(20_000).optional(),
|
||||
});
|
||||
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 attachFileArgsSchema = issueArgsSchema
|
||||
.extend({
|
||||
file_name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.refine((value) => !/[\\/\0]/.test(value), "file_name must not contain a path"),
|
||||
mime_type: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(255)
|
||||
.regex(/^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+$/),
|
||||
content_base64: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(7_000_000)
|
||||
.regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/),
|
||||
})
|
||||
.superRefine((input, context) => {
|
||||
if (Buffer.byteLength(input.content_base64, "base64") > 5 * 1024 * 1024) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["content_base64"],
|
||||
message: "Decoded attachment must not exceed 5 MiB.",
|
||||
});
|
||||
}
|
||||
});
|
||||
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_create_project": {
|
||||
const input = createProjectArgsSchema.parse(args);
|
||||
if (session.grantSource !== "agent") {
|
||||
throw new ForbiddenError("Project creation requires an agent-scoped local token.");
|
||||
}
|
||||
const sourceGrant = requireWorkspaceScope(session, "project:create", input.workspace_slug);
|
||||
if (!deps.agentsRepository) {
|
||||
throw new ToolExecutionInputError("agent_repository_unavailable", "Agent Gateway persistence is required.", 503);
|
||||
}
|
||||
const payload = await deps.taskerClient.createProject(session, input);
|
||||
const createdProject = extractCreatedProject(payload);
|
||||
await deps.agentsRepository.upsertGrant(session.agent.id, {
|
||||
workspaceSlug: input.workspace_slug,
|
||||
projectId: createdProject.id,
|
||||
scopes: sourceGrant.scopes,
|
||||
mode: sourceGrant.mode,
|
||||
createdByUserId: session.agent.ownerUserId,
|
||||
});
|
||||
return asToolResult(payload);
|
||||
}
|
||||
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_get_issue": {
|
||||
const input = issueArgsSchema.parse(args);
|
||||
requireToolAccess(session, "issue:read", input.project_id, input.workspace_slug);
|
||||
return asToolResult(await deps.taskerClient.getIssue(session, input.issue_id, input));
|
||||
}
|
||||
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_attach_file": {
|
||||
const input = attachFileArgsSchema.parse(args);
|
||||
requireToolAccess(session, "issue:attachment:write", input.project_id, input.workspace_slug);
|
||||
return asToolResult(await deps.taskerClient.attachFile(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 requireWorkspaceScope(session: AgentSessionRecord, scope: AgentScope, workspaceSlug: string) {
|
||||
requireScope(session, scope);
|
||||
const grant = session.grants.find(
|
||||
(candidate) => candidate.workspaceSlug === workspaceSlug && candidate.scopes.includes(scope)
|
||||
);
|
||||
if (!grant) {
|
||||
throw new ForbiddenError(`Grant for workspace does not include required scope: ${scope}.`);
|
||||
}
|
||||
return grant;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function extractCreatedProject(payload: unknown): { id: string } {
|
||||
if (!isPlainRecord(payload) || !isPlainRecord(payload.project) || typeof payload.project.id !== "string") {
|
||||
throw new ToolExecutionInputError(
|
||||
"tasker_project_response_invalid",
|
||||
"Tasker did not return the created project id.",
|
||||
502
|
||||
);
|
||||
}
|
||||
return { id: payload.project.id };
|
||||
}
|
||||
|
||||
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,
|
||||
project_name: args.name,
|
||||
file_name: args.file_name,
|
||||
attachment_bytes:
|
||||
typeof args.content_base64 === "string" ? Buffer.byteLength(args.content_base64, "base64") : undefined,
|
||||
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,
|
||||
})),
|
||||
grant_source: session.grantSource,
|
||||
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.",
|
||||
"Create projects only through tasker_create_project when the effective workspace grant includes project:create.",
|
||||
"Attach files only through tasker_attach_file; never upload from arbitrary URLs or server paths.",
|
||||
"Do not create 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.",
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user