FEAT - OPS MCP: инструмент редактирования комментариев
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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" } });
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user