From 0f3bf9d8aacfb445d33ea02162e0707957646890 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Thu, 6 Aug 2026 17:59:02 +0300 Subject: [PATCH] =?UTF-8?q?FEAT=20-=20OPS=20MCP:=20=D0=B8=D0=BD=D1=81?= =?UTF-8?q?=D1=82=D1=80=D1=83=D0=BC=D0=B5=D0=BD=D1=82=20=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D0=B5=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + docs/MCP_TOOLS_CONTRACT.md | 21 ++++- package.json | 1 + src/mcp/tool-runtime.comment-update.test.ts | 88 +++++++++++++++++++++ src/mcp/tool-runtime.ts | 32 ++++++++ src/routes/tools.ts | 17 ++++ src/scripts/smoke-e2e.ts | 18 ++++- src/scripts/smoke-mcp-e2e.ts | 22 +++++- src/tasker/client.ts | 16 ++++ 9 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 src/mcp/tool-runtime.comment-update.test.ts diff --git a/README.md b/README.md index dd07327..c457e5c 100644 --- a/README.md +++ b/README.md @@ -204,5 +204,6 @@ Current Tasker internal adapter contract expected by Gateway: - `PATCH /api/internal/nodedc/agent/issues/:issueId` - `POST /api/internal/nodedc/agent/issues/:issueId/move` - `POST /api/internal/nodedc/agent/issues/:issueId/comments` +- `PATCH /api/internal/nodedc/agent/issues/:issueId/comments/:commentId` - `PUT /api/internal/nodedc/agent/issues/:issueId/labels` - `PUT /api/internal/nodedc/agent/issues/:issueId/assignees` diff --git a/docs/MCP_TOOLS_CONTRACT.md b/docs/MCP_TOOLS_CONTRACT.md index a443619..1654245 100644 --- a/docs/MCP_TOOLS_CONTRACT.md +++ b/docs/MCP_TOOLS_CONTRACT.md @@ -241,9 +241,28 @@ issue:comment Allowed fields: ```text -comment_html +body ``` +### `tasker_update_comment` + +Updates an existing comment authored by the same Codex agent on the issue. + +Required scope: + +```text +issue:comment +``` + +Allowed fields: + +```text +comment_id +body +``` + +The comment must belong to the granted issue and its actor must match the authenticated agent. User-authored and other-agent comments cannot be rewritten. + ### `tasker_update_structured_blocks` Replaces or patches NODE.DC structured blocks in `detail_layout`. diff --git a/package.json b/package.json index 25eedbd..baba964 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "smoke:gateway": "tsx src/scripts/smoke-gateway.ts", "smoke:mcp:e2e": "tsx src/scripts/smoke-mcp-e2e.ts", "smoke:mcp": "tsx src/scripts/smoke-mcp.ts", + "test:comment-update": "tsx --test src/mcp/tool-runtime.comment-update.test.ts", "start": "node dist/server.js" }, "dependencies": { diff --git a/src/mcp/tool-runtime.comment-update.test.ts b/src/mcp/tool-runtime.comment-update.test.ts new file mode 100644 index 0000000..84df074 --- /dev/null +++ b/src/mcp/tool-runtime.comment-update.test.ts @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { AgentsRepository, AgentSessionRecord } from "../repositories/agents.js"; +import { TaskerClient } from "../tasker/client.js"; +import { executeMcpTool, getToolsForSession } from "./tool-runtime.js"; + +const session: AgentSessionRecord = { + agent: { + id: "agent-1", + ownerUserId: "owner-1", + ownerEmail: "owner@example.test", + displayName: "Codex Agent", + avatarUrl: null, + status: "active", + createdAt: "2026-08-06T00:00:00.000Z", + updatedAt: "2026-08-06T00:00:00.000Z", + }, + token: { + id: "token-1", + agentId: "agent-1", + name: "test", + status: "active", + grantScope: "agent", + tokenSuffix: "test", + expiresAt: null, + lastUsedAt: null, + createdAt: "2026-08-06T00:00:00.000Z", + }, + grants: [ + { + id: "grant-1", + agentId: "agent-1", + workspaceSlug: "nodedc", + projectId: "project-1", + scopes: ["issue:comment"], + mode: "voluntary", + createdByUserId: "owner-1", + createdAt: "2026-08-06T00:00:00.000Z", + updatedAt: "2026-08-06T00:00:00.000Z", + }, + ], + grantSource: "agent", +}; + +test("tasker_update_comment is exposed and forwards the owned comment identity", async () => { + const tool = getToolsForSession(session).find((candidate) => candidate.name === "tasker_update_comment"); + assert.ok(tool); + assert.deepEqual(tool.inputSchema.required, ["issue_id", "project_id", "comment_id", "body"]); + + const forwarded: unknown[] = []; + const taskerClient = new TaskerClient({ baseUrl: "http://tasker.test", internalAccessToken: "test" }); + taskerClient.updateComment = async (...args) => { + forwarded.push(args); + return { ok: true, comment: { id: "comment-1", body: "updated" } }; + }; + + const agentsRepository = { + claimIdempotencyKey: async () => ({ status: "claimed" as const }), + completeIdempotencyKey: async () => undefined, + releaseIdempotencyKey: async () => undefined, + createAuditEvent: async () => undefined, + } as unknown as AgentsRepository; + + const result = await executeMcpTool( + session, + "tasker_update_comment", + { + issue_id: "issue-1", + project_id: "project-1", + workspace_slug: "nodedc", + comment_id: "comment-1", + body: "updated", + idempotency_key: "comment-update-test-1", + }, + { agentsRepository, taskerClient } + ); + + assert.deepEqual(forwarded, [ + [ + session, + "issue-1", + "comment-1", + { project_id: "project-1", workspace_slug: "nodedc", body: "updated" }, + ], + ]); + assert.deepEqual(result.structuredContent, { ok: true, comment: { id: "comment-1", body: "updated" } }); +}); diff --git a/src/mcp/tool-runtime.ts b/src/mcp/tool-runtime.ts index cfe1ae5..1c904fe 100644 --- a/src/mcp/tool-runtime.ts +++ b/src/mcp/tool-runtime.ts @@ -252,6 +252,24 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [ }, annotations: { destructiveHint: false, idempotentHint: false }, }, + { + name: "tasker_update_comment", + title: "NODE.DC Ops: Update Comment", + description: + "Direct NODE.DC Ops MCP write tool. Update an existing comment authored by this Codex agent on a granted Tasker/Ops card.", + requiredScopes: ["issue:comment"], + inputSchema: { + ...projectAndIssueInputSchema, + properties: { + ...projectAndIssueInputSchema.properties, + comment_id: { type: "string" }, + body: { type: "string" }, + idempotency_key: { type: "string" }, + }, + required: ["issue_id", "project_id", "comment_id", "body"], + }, + annotations: { destructiveHint: false, idempotentHint: false }, + }, { name: "tasker_ensure_labels", title: "NODE.DC Ops: Ensure Project Labels", @@ -357,6 +375,9 @@ const moveIssueArgsSchema = issueArgsSchema.extend({ const commentArgsSchema = issueArgsSchema.extend({ body: z.string().min(1).max(20000), }); +const updateCommentArgsSchema = commentArgsSchema.extend({ + comment_id: z.string().min(1), +}); const ensureLabelsArgsSchema = projectArgsSchema.extend({ labels: z .array( @@ -517,6 +538,17 @@ async function executeMcpToolOnce( requireToolAccess(session, "issue:comment", input.project_id, input.workspace_slug); return asToolResult(await deps.taskerClient.appendComment(session, input.issue_id, input)); } + case "tasker_update_comment": { + const input = updateCommentArgsSchema.parse(args); + requireToolAccess(session, "issue:comment", input.project_id, input.workspace_slug); + return asToolResult( + await deps.taskerClient.updateComment(session, input.issue_id, input.comment_id, { + project_id: input.project_id, + workspace_slug: input.workspace_slug, + body: input.body, + }) + ); + } case "tasker_ensure_labels": { const input = ensureLabelsArgsSchema.parse(args); requireToolAccess(session, "issue:label", input.project_id, input.workspace_slug); diff --git a/src/routes/tools.ts b/src/routes/tools.ts index fcd67fa..fdf5093 100644 --- a/src/routes/tools.ts +++ b/src/routes/tools.ts @@ -111,6 +111,23 @@ export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDe return result.structuredContent; }); + app.patch("/api/v1/tools/issues/:issueId/comments/:commentId", async (request) => { + const session = await authenticateAgent(request, deps); + const params = request.params as { issueId: string; commentId: string }; + const result = await executeMcpTool( + session, + "tasker_update_comment", + { + ...requestBodyRecord(request.body), + issue_id: params.issueId, + comment_id: params.commentId, + }, + deps, + toolOptions(request) + ); + return result.structuredContent; + }); + app.put("/api/v1/tools/issues/:issueId/labels", async (request) => { const session = await authenticateAgent(request, deps); const params = request.params as { issueId: string }; diff --git a/src/scripts/smoke-e2e.ts b/src/scripts/smoke-e2e.ts index 7fd3f67..70b765e 100644 --- a/src/scripts/smoke-e2e.ts +++ b/src/scripts/smoke-e2e.ts @@ -85,11 +85,23 @@ try { const issueId = issue.issue.id as string; assert(replayedIssue.issue.id === issueId, "idempotent REST create returns the original issue"); - await requestJson("POST", `/api/v1/tools/issues/${issueId}/comments`, { ...authHeaders, "Idempotency-Key": `rest-comment-${suffix}` }, { + const appendedComment = await requestJson("POST", `/api/v1/tools/issues/${issueId}/comments`, { ...authHeaders, "Idempotency-Key": `rest-comment-${suffix}` }, { project_id: projectId, workspace_slug: workspaceSlug, body: "Smoke comment from Agent Gateway.", }); + const commentId = appendedComment.comment.id as string; + const updatedComment = await requestJson( + "PATCH", + `/api/v1/tools/issues/${issueId}/comments/${commentId}`, + { ...authHeaders, "Idempotency-Key": `rest-comment-update-${suffix}` }, + { + project_id: projectId, + workspace_slug: workspaceSlug, + body: "Smoke comment updated through Agent Gateway.", + } + ); + assert(updatedComment.comment.id === commentId, "REST comment update preserves comment identity"); const states = Array.isArray(context.states) ? context.states : []; const targetState = states.find((state) => typeof state?.id === "string"); @@ -115,6 +127,8 @@ try { project_id: projectId, visible_projects: Array.isArray(projects.projects) ? projects.projects.length : null, issue_id: issueId, + comment_id: commentId, + comment_updated: true, idempotent_replay: "passed", moved: Boolean(targetState), }, @@ -166,7 +180,7 @@ async function createToken(agentId: string): Promise { } async function requestJson( - method: "GET" | "POST", + method: "GET" | "POST" | "PATCH", url: string, headers?: Record, payload?: unknown diff --git a/src/scripts/smoke-mcp-e2e.ts b/src/scripts/smoke-mcp-e2e.ts index c720db3..7defa77 100644 --- a/src/scripts/smoke-mcp-e2e.ts +++ b/src/scripts/smoke-mcp-e2e.ts @@ -56,6 +56,7 @@ try { const toolsList = await mcpRequest(2, "tools/list", {}, headers); const toolNames = toolsList.result.tools.map((tool: { name: string }) => tool.name); assert(toolNames.includes("tasker_create_issue"), "create issue tool is listed with full grant"); + assert(toolNames.includes("tasker_update_comment"), "comment update tool is listed with full grant"); const projects = await callTool(3, "tasker_list_projects", {}, headers); const context = await callTool( @@ -106,7 +107,7 @@ try { const issueId = createIssue.structuredContent.issue.id as string; assert(replayedCreateIssue.structuredContent.issue.id === issueId, "idempotent MCP create returns the original issue"); - await callTool( + const appendedComment = await callTool( 7, "tasker_append_comment", { @@ -118,12 +119,27 @@ try { }, headers ); + const commentId = appendedComment.structuredContent.comment.id as string; + const updatedComment = await callTool( + 8, + "tasker_update_comment", + { + issue_id: issueId, + project_id: projectId, + workspace_slug: workspaceSlug, + comment_id: commentId, + body: "MCP e2e smoke comment updated through Agent Gateway.", + idempotency_key: `mcp-comment-update-${suffix}`, + }, + headers + ); + assert(updatedComment.structuredContent.comment.id === commentId, "MCP comment update preserves comment identity"); const states = Array.isArray(context.structuredContent.states) ? context.structuredContent.states : []; const targetState = states.find((state: any) => typeof state?.id === "string"); if (targetState) { await callTool( - 8, + 9, "tasker_move_issue", { issue_id: issueId, @@ -146,6 +162,8 @@ try { project_id: projectId, visible_projects: Array.isArray(projects.structuredContent.projects) ? projects.structuredContent.projects.length : null, issue_id: issueId, + comment_id: commentId, + comment_updated: true, idempotent_replay: "passed", moved: Boolean(targetState), }, diff --git a/src/tasker/client.ts b/src/tasker/client.ts index 41e733c..54144bf 100644 --- a/src/tasker/client.ts +++ b/src/tasker/client.ts @@ -178,6 +178,22 @@ export class TaskerClient { }); } + async updateComment( + session: AgentSessionRecord, + issueId: string, + commentId: string, + input: CommentInput + ): Promise { + return this.request( + `/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/comments/${encodeURIComponent(commentId)}`, + { + method: "PATCH", + session, + body: input, + } + ); + } + async setLabels(session: AgentSessionRecord, issueId: string, input: SetLabelsInput): Promise { return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/labels`, { method: "PUT",