FEAT - GATEWAY: support Tasker labels and agent metadata

This commit is contained in:
DCCONSTRUCTIONS 2026-05-15 14:25:36 +03:00
parent 19d5f18bf5
commit f52a8345a1
5 changed files with 141 additions and 11 deletions

View File

@ -23,6 +23,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
internalAccessToken: config.NODEDC_INTERNAL_ACCESS_TOKEN,
});
const app = Fastify({
bodyLimit: 10 * 1024 * 1024,
logger: {
level: config.LOG_LEVEL,
},

View File

@ -73,10 +73,16 @@ const structuredBlocksJsonSchema = {
properties: {
id: { type: "string" },
type: { const: "text" },
title: { type: "string" },
body: { type: "string" },
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"],
required: ["id", "type", "title", "body"],
additionalProperties: false,
},
{
@ -84,7 +90,10 @@ const structuredBlocksJsonSchema = {
properties: {
id: { type: "string" },
type: { const: "checker" },
title: { type: "string" },
title: {
type: "string",
description: "Visible checklist title. Put headings here, not inside item text.",
},
items: {
type: "array",
items: {
@ -99,7 +108,7 @@ const structuredBlocksJsonSchema = {
},
},
},
required: ["id", "type"],
required: ["id", "type", "title", "items"],
additionalProperties: false,
},
],
@ -236,10 +245,42 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
},
annotations: { destructiveHint: false, idempotentHint: false },
},
{
name: "tasker_ensure_labels",
title: "Ensure Project Labels",
description: "Create missing labels in a granted project and return label ids for subsequent issue 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: "Set Issue Labels",
description: "Replace issue labels with existing labels from the granted project.",
description: "Replace issue labels with existing or ensured labels from the granted project.",
requiredScopes: ["issue:label"],
inputSchema: {
...projectAndIssueInputSchema,
@ -308,6 +349,20 @@ const moveIssueArgsSchema = issueArgsSchema.extend({
const commentArgsSchema = issueArgsSchema.extend({
body: z.string().min(1).max(20000),
});
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([]),
});
@ -454,6 +509,11 @@ 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_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);
@ -580,6 +640,7 @@ function summarizeToolArguments(args: unknown): Record<string, unknown> {
project_id: args.project_id,
issue_id: args.issue_id,
state_id: args.state_id,
labels_count: Array.isArray(args.labels) ? args.labels.length : undefined,
has_structured_blocks: Array.isArray(args.structured_blocks),
};
}
@ -605,12 +666,19 @@ function buildAgentInstructions(session: AgentSessionRecord): Record<string, unk
card_structure: [
"Keep the main issue description concise and conceptual.",
"Use structured text blocks for current architecture, planned architecture, and implementation notes.",
"Use checker blocks for short verifiable phase items.",
"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.",
"Do not create projects, labels, states, workspace invites, or workspace settings changes.",
"Do not create projects, 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.",
],

View File

@ -16,7 +16,7 @@ type AgentRouteDeps = {
internalAccessToken?: string;
};
const MAX_AGENT_AVATAR_URL_LENGTH = 400_000;
const MAX_AGENT_AVATAR_URL_LENGTH = 2_000_000;
function isAllowedAvatarUrl(value: string): boolean {
if (value.startsWith("data:image/")) {
@ -678,14 +678,23 @@ function buildAgentsMd(session: AgentSessionRecord, endpoint: string): string {
"- Never delete or archive Tasker cards, comments, labels, projects, states, members, or workspaces.",
"- Do not call raw Tasker APIs. Use only the NODE.DC MCP tools.",
"- Only assign existing project members returned by project context.",
"- If a needed label is missing, call `tasker_ensure_labels` first and then use returned ids with `tasker_set_issue_labels`.",
"",
"## Card Writing",
"",
"- Keep card titles concise and operational.",
"- Put current architecture, planned architecture, implementation notes, and validation into structured text blocks.",
"- Put short verifiable work items into checker blocks.",
"- Put each structured block heading into the block `title` field. Do not put markdown headings like `## Status` inside block body.",
"- Do not put headings inside block body. Wrong: body starts with `## Текущая архитектура`. Correct: `title: \"Текущая архитектура\"`, body contains only content.",
"- Put short verifiable work items into checker blocks with explicit titles.",
"- After code work, update the related card with factual files touched and validation performed.",
"",
"## Labels",
"",
"- Before assigning a new marker/label, check project context labels.",
"- If the label does not exist, call `tasker_ensure_labels` with the granted `project_id`.",
"- Use only label ids returned by project context or `tasker_ensure_labels` when calling `tasker_set_issue_labels`.",
"",
"## Effective Grants",
"",
grants || "- No grants available.",

View File

@ -34,6 +34,22 @@ export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDe
return result.structuredContent;
});
app.post("/api/v1/tools/projects/:projectId/labels/ensure", async (request) => {
const session = await authenticateAgent(request, deps);
const params = request.params as { projectId: string };
const result = await executeMcpTool(
session,
"tasker_ensure_labels",
{
...requestBodyRecord(request.body),
project_id: params.projectId,
},
deps,
toolOptions(request)
);
return result.structuredContent;
});
app.get("/api/v1/tools/issues", async (request) => {
const session = await authenticateAgent(request, deps);
const query = request.query as { project_id?: string; workspace_slug?: string; query?: string };

View File

@ -83,6 +83,15 @@ export type SetLabelsInput = {
label_ids: string[];
};
export type EnsureLabelsInput = {
project_id: string;
workspace_slug?: string | null;
labels: Array<{
name: string;
color?: string;
}>;
};
export type AssignIssueInput = {
project_id: string;
workspace_slug?: string | null;
@ -177,6 +186,14 @@ export class TaskerClient {
});
}
async ensureLabels(session: AgentSessionRecord, input: EnsureLabelsInput): Promise<unknown> {
return this.request(`/api/internal/nodedc/agent/projects/${encodeURIComponent(input.project_id)}/labels/ensure`, {
method: "POST",
session,
body: input,
});
}
async assignIssue(session: AgentSessionRecord, issueId: string, input: AssignIssueInput): Promise<unknown> {
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/assignees`, {
method: "PUT",
@ -217,6 +234,7 @@ export class TaskerClient {
}
): Promise<Response> {
try {
const requestBody = attachAgentMetadata(input.session, input.body);
return await fetch(new URL(path, this.config.baseUrl), {
method: input.method,
headers: {
@ -226,7 +244,7 @@ export class TaskerClient {
"X-NODEDC-Agent-Owner-User-Id": input.session.agent.ownerUserId,
"X-NODEDC-Agent-Token-Id": input.session.token.id,
},
body: input.body === undefined ? undefined : JSON.stringify(input.body),
body: requestBody === undefined ? undefined : JSON.stringify(requestBody),
});
} catch (error) {
throw new TaskerAdapterUnavailableError(error);
@ -234,6 +252,24 @@ export class TaskerClient {
}
}
function attachAgentMetadata(session: AgentSessionRecord, body: unknown): unknown {
if (body === undefined || !isPlainRecord(body)) {
return body;
}
return {
...body,
_agent: {
display_name: session.agent.displayName,
avatar_url: session.agent.avatarUrl,
},
};
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function readResponsePayload(response: Response): Promise<unknown> {
const text = await response.text();