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`
|
- `PATCH /api/internal/nodedc/agent/issues/:issueId`
|
||||||
- `POST /api/internal/nodedc/agent/issues/:issueId/move`
|
- `POST /api/internal/nodedc/agent/issues/:issueId/move`
|
||||||
- `POST /api/internal/nodedc/agent/issues/:issueId/comments`
|
- `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/labels`
|
||||||
- `PUT /api/internal/nodedc/agent/issues/:issueId/assignees`
|
- `PUT /api/internal/nodedc/agent/issues/:issueId/assignees`
|
||||||
|
|||||||
@@ -241,9 +241,28 @@ issue:comment
|
|||||||
Allowed fields:
|
Allowed fields:
|
||||||
|
|
||||||
```text
|
```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`
|
### `tasker_update_structured_blocks`
|
||||||
|
|
||||||
Replaces or patches NODE.DC structured blocks in `detail_layout`.
|
Replaces or patches NODE.DC structured blocks in `detail_layout`.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"smoke:gateway": "tsx src/scripts/smoke-gateway.ts",
|
"smoke:gateway": "tsx src/scripts/smoke-gateway.ts",
|
||||||
"smoke:mcp:e2e": "tsx src/scripts/smoke-mcp-e2e.ts",
|
"smoke:mcp:e2e": "tsx src/scripts/smoke-mcp-e2e.ts",
|
||||||
"smoke:mcp": "tsx src/scripts/smoke-mcp.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"
|
"start": "node dist/server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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 },
|
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",
|
name: "tasker_ensure_labels",
|
||||||
title: "NODE.DC Ops: Ensure Project Labels",
|
title: "NODE.DC Ops: Ensure Project Labels",
|
||||||
@@ -357,6 +375,9 @@ const moveIssueArgsSchema = issueArgsSchema.extend({
|
|||||||
const commentArgsSchema = issueArgsSchema.extend({
|
const commentArgsSchema = issueArgsSchema.extend({
|
||||||
body: z.string().min(1).max(20000),
|
body: z.string().min(1).max(20000),
|
||||||
});
|
});
|
||||||
|
const updateCommentArgsSchema = commentArgsSchema.extend({
|
||||||
|
comment_id: z.string().min(1),
|
||||||
|
});
|
||||||
const ensureLabelsArgsSchema = projectArgsSchema.extend({
|
const ensureLabelsArgsSchema = projectArgsSchema.extend({
|
||||||
labels: z
|
labels: z
|
||||||
.array(
|
.array(
|
||||||
@@ -517,6 +538,17 @@ async function executeMcpToolOnce(
|
|||||||
requireToolAccess(session, "issue:comment", input.project_id, input.workspace_slug);
|
requireToolAccess(session, "issue:comment", input.project_id, input.workspace_slug);
|
||||||
return asToolResult(await deps.taskerClient.appendComment(session, input.issue_id, input));
|
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": {
|
case "tasker_ensure_labels": {
|
||||||
const input = ensureLabelsArgsSchema.parse(args);
|
const input = ensureLabelsArgsSchema.parse(args);
|
||||||
requireToolAccess(session, "issue:label", input.project_id, input.workspace_slug);
|
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;
|
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) => {
|
app.put("/api/v1/tools/issues/:issueId/labels", async (request) => {
|
||||||
const session = await authenticateAgent(request, deps);
|
const session = await authenticateAgent(request, deps);
|
||||||
const params = request.params as { issueId: string };
|
const params = request.params as { issueId: string };
|
||||||
|
|||||||
@@ -85,11 +85,23 @@ try {
|
|||||||
const issueId = issue.issue.id as string;
|
const issueId = issue.issue.id as string;
|
||||||
assert(replayedIssue.issue.id === issueId, "idempotent REST create returns the original issue");
|
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,
|
project_id: projectId,
|
||||||
workspace_slug: workspaceSlug,
|
workspace_slug: workspaceSlug,
|
||||||
body: "Smoke comment from Agent Gateway.",
|
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 states = Array.isArray(context.states) ? context.states : [];
|
||||||
const targetState = states.find((state) => typeof state?.id === "string");
|
const targetState = states.find((state) => typeof state?.id === "string");
|
||||||
@@ -115,6 +127,8 @@ try {
|
|||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
visible_projects: Array.isArray(projects.projects) ? projects.projects.length : null,
|
visible_projects: Array.isArray(projects.projects) ? projects.projects.length : null,
|
||||||
issue_id: issueId,
|
issue_id: issueId,
|
||||||
|
comment_id: commentId,
|
||||||
|
comment_updated: true,
|
||||||
idempotent_replay: "passed",
|
idempotent_replay: "passed",
|
||||||
moved: Boolean(targetState),
|
moved: Boolean(targetState),
|
||||||
},
|
},
|
||||||
@@ -166,7 +180,7 @@ async function createToken(agentId: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function requestJson(
|
async function requestJson(
|
||||||
method: "GET" | "POST",
|
method: "GET" | "POST" | "PATCH",
|
||||||
url: string,
|
url: string,
|
||||||
headers?: Record<string, string>,
|
headers?: Record<string, string>,
|
||||||
payload?: unknown
|
payload?: unknown
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ try {
|
|||||||
const toolsList = await mcpRequest(2, "tools/list", {}, headers);
|
const toolsList = await mcpRequest(2, "tools/list", {}, headers);
|
||||||
const toolNames = toolsList.result.tools.map((tool: { name: string }) => tool.name);
|
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_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 projects = await callTool(3, "tasker_list_projects", {}, headers);
|
||||||
const context = await callTool(
|
const context = await callTool(
|
||||||
@@ -106,7 +107,7 @@ try {
|
|||||||
const issueId = createIssue.structuredContent.issue.id as string;
|
const issueId = createIssue.structuredContent.issue.id as string;
|
||||||
assert(replayedCreateIssue.structuredContent.issue.id === issueId, "idempotent MCP create returns the original issue");
|
assert(replayedCreateIssue.structuredContent.issue.id === issueId, "idempotent MCP create returns the original issue");
|
||||||
|
|
||||||
await callTool(
|
const appendedComment = await callTool(
|
||||||
7,
|
7,
|
||||||
"tasker_append_comment",
|
"tasker_append_comment",
|
||||||
{
|
{
|
||||||
@@ -118,12 +119,27 @@ try {
|
|||||||
},
|
},
|
||||||
headers
|
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 states = Array.isArray(context.structuredContent.states) ? context.structuredContent.states : [];
|
||||||
const targetState = states.find((state: any) => typeof state?.id === "string");
|
const targetState = states.find((state: any) => typeof state?.id === "string");
|
||||||
if (targetState) {
|
if (targetState) {
|
||||||
await callTool(
|
await callTool(
|
||||||
8,
|
9,
|
||||||
"tasker_move_issue",
|
"tasker_move_issue",
|
||||||
{
|
{
|
||||||
issue_id: issueId,
|
issue_id: issueId,
|
||||||
@@ -146,6 +162,8 @@ try {
|
|||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
visible_projects: Array.isArray(projects.structuredContent.projects) ? projects.structuredContent.projects.length : null,
|
visible_projects: Array.isArray(projects.structuredContent.projects) ? projects.structuredContent.projects.length : null,
|
||||||
issue_id: issueId,
|
issue_id: issueId,
|
||||||
|
comment_id: commentId,
|
||||||
|
comment_updated: true,
|
||||||
idempotent_replay: "passed",
|
idempotent_replay: "passed",
|
||||||
moved: Boolean(targetState),
|
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> {
|
async setLabels(session: AgentSessionRecord, issueId: string, input: SetLabelsInput): Promise<unknown> {
|
||||||
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/labels`, {
|
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/labels`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
|
|||||||
Reference in New Issue
Block a user