diff --git a/src/app.ts b/src/app.ts index 9ccc21e..2ebea49 100644 --- a/src/app.ts +++ b/src/app.ts @@ -23,6 +23,7 @@ export async function buildApp(config: AppConfig): Promise { internalAccessToken: config.NODEDC_INTERNAL_ACCESS_TOKEN, }); const app = Fastify({ + bodyLimit: 10 * 1024 * 1024, logger: { level: config.LOG_LEVEL, }, diff --git a/src/mcp/tool-runtime.ts b/src/mcp/tool-runtime.ts index a3e1034..d79cb6a 100644 --- a/src/mcp/tool-runtime.ts +++ b/src/mcp/tool-runtime.ts @@ -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 { 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 { + 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 }; diff --git a/src/tasker/client.ts b/src/tasker/client.ts index ac24d70..41e733c 100644 --- a/src/tasker/client.ts +++ b/src/tasker/client.ts @@ -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 { + 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 { return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/assignees`, { method: "PUT", @@ -217,6 +234,7 @@ export class TaskerClient { } ): Promise { 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + async function readResponsePayload(response: Response): Promise { const text = await response.text();