FEAT - GATEWAY: support Tasker labels and agent metadata
This commit is contained in:
parent
19d5f18bf5
commit
f52a8345a1
|
|
@ -23,6 +23,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
||||||
internalAccessToken: config.NODEDC_INTERNAL_ACCESS_TOKEN,
|
internalAccessToken: config.NODEDC_INTERNAL_ACCESS_TOKEN,
|
||||||
});
|
});
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
|
bodyLimit: 10 * 1024 * 1024,
|
||||||
logger: {
|
logger: {
|
||||||
level: config.LOG_LEVEL,
|
level: config.LOG_LEVEL,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -73,10 +73,16 @@ const structuredBlocksJsonSchema = {
|
||||||
properties: {
|
properties: {
|
||||||
id: { type: "string" },
|
id: { type: "string" },
|
||||||
type: { const: "text" },
|
type: { const: "text" },
|
||||||
title: { type: "string" },
|
title: {
|
||||||
body: { type: "string" },
|
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,
|
additionalProperties: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -84,7 +90,10 @@ const structuredBlocksJsonSchema = {
|
||||||
properties: {
|
properties: {
|
||||||
id: { type: "string" },
|
id: { type: "string" },
|
||||||
type: { const: "checker" },
|
type: { const: "checker" },
|
||||||
title: { type: "string" },
|
title: {
|
||||||
|
type: "string",
|
||||||
|
description: "Visible checklist title. Put headings here, not inside item text.",
|
||||||
|
},
|
||||||
items: {
|
items: {
|
||||||
type: "array",
|
type: "array",
|
||||||
items: {
|
items: {
|
||||||
|
|
@ -99,7 +108,7 @@ const structuredBlocksJsonSchema = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: ["id", "type"],
|
required: ["id", "type", "title", "items"],
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -236,10 +245,42 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
|
||||||
},
|
},
|
||||||
annotations: { destructiveHint: false, idempotentHint: false },
|
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",
|
name: "tasker_set_issue_labels",
|
||||||
title: "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"],
|
requiredScopes: ["issue:label"],
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
...projectAndIssueInputSchema,
|
...projectAndIssueInputSchema,
|
||||||
|
|
@ -308,6 +349,20 @@ 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 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({
|
const labelsArgsSchema = issueArgsSchema.extend({
|
||||||
label_ids: z.array(z.string().min(1)).default([]),
|
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);
|
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_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": {
|
case "tasker_set_issue_labels": {
|
||||||
const input = labelsArgsSchema.parse(args);
|
const input = labelsArgsSchema.parse(args);
|
||||||
requireToolAccess(session, "issue:label", input.project_id, input.workspace_slug);
|
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,
|
project_id: args.project_id,
|
||||||
issue_id: args.issue_id,
|
issue_id: args.issue_id,
|
||||||
state_id: args.state_id,
|
state_id: args.state_id,
|
||||||
|
labels_count: Array.isArray(args.labels) ? args.labels.length : undefined,
|
||||||
has_structured_blocks: Array.isArray(args.structured_blocks),
|
has_structured_blocks: Array.isArray(args.structured_blocks),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -605,12 +666,19 @@ function buildAgentInstructions(session: AgentSessionRecord): Record<string, unk
|
||||||
card_structure: [
|
card_structure: [
|
||||||
"Keep the main issue description concise and conceptual.",
|
"Keep the main issue description concise and conceptual.",
|
||||||
"Use structured text blocks for current architecture, planned architecture, and implementation notes.",
|
"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.",
|
"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: [
|
hard_limits: [
|
||||||
"Do not delete or archive issues.",
|
"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 assign existing project members.",
|
||||||
"Only use projects and workspaces present in effective grants.",
|
"Only use projects and workspaces present in effective grants.",
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ type AgentRouteDeps = {
|
||||||
internalAccessToken?: string;
|
internalAccessToken?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_AGENT_AVATAR_URL_LENGTH = 400_000;
|
const MAX_AGENT_AVATAR_URL_LENGTH = 2_000_000;
|
||||||
|
|
||||||
function isAllowedAvatarUrl(value: string): boolean {
|
function isAllowedAvatarUrl(value: string): boolean {
|
||||||
if (value.startsWith("data:image/")) {
|
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.",
|
"- 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.",
|
"- Do not call raw Tasker APIs. Use only the NODE.DC MCP tools.",
|
||||||
"- Only assign existing project members returned by project context.",
|
"- 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",
|
"## Card Writing",
|
||||||
"",
|
"",
|
||||||
"- Keep card titles concise and operational.",
|
"- Keep card titles concise and operational.",
|
||||||
"- Put current architecture, planned architecture, implementation notes, and validation into structured text blocks.",
|
"- 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.",
|
"- 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",
|
"## Effective Grants",
|
||||||
"",
|
"",
|
||||||
grants || "- No grants available.",
|
grants || "- No grants available.",
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,22 @@ export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDe
|
||||||
return result.structuredContent;
|
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) => {
|
app.get("/api/v1/tools/issues", async (request) => {
|
||||||
const session = await authenticateAgent(request, deps);
|
const session = await authenticateAgent(request, deps);
|
||||||
const query = request.query as { project_id?: string; workspace_slug?: string; query?: string };
|
const query = request.query as { project_id?: string; workspace_slug?: string; query?: string };
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,15 @@ export type SetLabelsInput = {
|
||||||
label_ids: string[];
|
label_ids: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type EnsureLabelsInput = {
|
||||||
|
project_id: string;
|
||||||
|
workspace_slug?: string | null;
|
||||||
|
labels: Array<{
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
export type AssignIssueInput = {
|
export type AssignIssueInput = {
|
||||||
project_id: string;
|
project_id: string;
|
||||||
workspace_slug?: string | null;
|
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> {
|
async assignIssue(session: AgentSessionRecord, issueId: string, input: AssignIssueInput): Promise<unknown> {
|
||||||
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/assignees`, {
|
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/assignees`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
|
|
@ -217,6 +234,7 @@ export class TaskerClient {
|
||||||
}
|
}
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
|
const requestBody = attachAgentMetadata(input.session, input.body);
|
||||||
return await fetch(new URL(path, this.config.baseUrl), {
|
return await fetch(new URL(path, this.config.baseUrl), {
|
||||||
method: input.method,
|
method: input.method,
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -226,7 +244,7 @@ export class TaskerClient {
|
||||||
"X-NODEDC-Agent-Owner-User-Id": input.session.agent.ownerUserId,
|
"X-NODEDC-Agent-Owner-User-Id": input.session.agent.ownerUserId,
|
||||||
"X-NODEDC-Agent-Token-Id": input.session.token.id,
|
"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) {
|
} catch (error) {
|
||||||
throw new TaskerAdapterUnavailableError(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> {
|
async function readResponsePayload(response: Response): Promise<unknown> {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue