FEAT - OPS MCP: инструмент редактирования комментариев

This commit is contained in:
DCCONSTRUCTIONS
2026-08-06 17:59:02 +03:00
parent b30f1c3abe
commit 0f3bf9d8aa
9 changed files with 211 additions and 5 deletions
+1
View File
@@ -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`
+20 -1
View File
@@ -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`.
+1
View File
@@ -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": {
@@ -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" } });
});
+32
View File
@@ -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);
+17
View File
@@ -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 };
+16 -2
View File
@@ -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<string> {
}
async function requestJson(
method: "GET" | "POST",
method: "GET" | "POST" | "PATCH",
url: string,
headers?: Record<string, string>,
payload?: unknown
+20 -2
View File
@@ -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),
},
+16
View File
@@ -178,6 +178,22 @@ export class TaskerClient {
});
}
async updateComment(
session: AgentSessionRecord,
issueId: string,
commentId: string,
input: CommentInput
): Promise<unknown> {
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<unknown> {
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/labels`, {
method: "PUT",