diff --git a/mcp/packages/server/src/PenpotMcpServer.ts b/mcp/packages/server/src/PenpotMcpServer.ts index 473596aebd..44b058b358 100644 --- a/mcp/packages/server/src/PenpotMcpServer.ts +++ b/mcp/packages/server/src/PenpotMcpServer.ts @@ -15,6 +15,7 @@ import { CljsReplTool } from "./tools/CljsReplTool"; import { ImportPenpotFileTool } from "./tools/ImportPenpotFileTool"; import { CljsCompilerOutputTool } from "./tools/CljsCompilerOutputTool"; import { CljCheckParentheses } from "./tools/CljCheckParentheses"; +import { ReadTaigaIssueTool } from "./tools/ReadTaigaIssueTool"; import { NreplClient } from "./NreplClient"; import { ReplServer } from "./ReplServer"; import { ApiDocs } from "./ApiDocs"; @@ -198,6 +199,7 @@ export class PenpotMcpServer { toolInstances.push(new ImportPenpotFileTool(this, nreplClient)); toolInstances.push(new CljsCompilerOutputTool(this, nreplClient)); toolInstances.push(new CljCheckParentheses(this)); + toolInstances.push(new ReadTaigaIssueTool(this)); } return toolInstances.map((instance) => { diff --git a/mcp/packages/server/src/tools/ReadTaigaIssueTool.ts b/mcp/packages/server/src/tools/ReadTaigaIssueTool.ts new file mode 100644 index 0000000000..29e8bea460 --- /dev/null +++ b/mcp/packages/server/src/tools/ReadTaigaIssueTool.ts @@ -0,0 +1,163 @@ +import { z } from "zod"; +import { Tool } from "../Tool"; +import "reflect-metadata"; +import type { ToolResponse } from "../ToolResponse"; +import { TextResponse } from "../ToolResponse"; +import { PenpotMcpServer } from "../PenpotMcpServer"; + +/** + * Arguments for the {@link ReadTaigaIssueTool}. + */ +export class ReadTaigaIssueArgs { + static schema = { + issueNumber: z + .number() + .int() + .positive() + .describe( + "The Penpot issue number as it appears in Taiga URLs, " + + "e.g. 14177 for https://tree.taiga.io/project/penpot/issue/14177" + ), + }; + + /** + * The Penpot issue number as it appears in Taiga issue URLs + * (e.g. 14177 for https://tree.taiga.io/project/penpot/issue/14177). + */ + issueNumber!: number; +} + +/** + * Represents a file attachment on a Taiga issue. + */ +interface TaigaAttachment { + filename: string; + size: number; + url: string; +} + +/** + * Represents a comment on a Taiga issue. + */ +interface TaigaComment { + username: string; + comment: string; +} + +/** + * The resolved issue data returned by the tool. + */ +interface TaigaIssueData { + subject: string; + description: string; + status: string; + attachments: TaigaAttachment[]; + comments: TaigaComment[]; +} + +/** + * Tool for reading Penpot issues from the Taiga project tracker. + * + * Resolves a Penpot issue number to its internal Taiga ID and retrieves the issue's + * subject, description, status, attachments, and comments via the Taiga REST API. + */ +export class ReadTaigaIssueTool extends Tool { + private static readonly TAIGA_API_BASE = "https://api.taiga.io/api/v1"; + + constructor(mcpServer: PenpotMcpServer) { + super(mcpServer, ReadTaigaIssueArgs.schema); + } + + public getToolName(): string { + return "read_taiga_issue"; + } + + public getToolDescription(): string { + return "Reads a Penpot issue from the Taiga project tracker, returning its subject, description, status, attachments, and comments."; + } + + protected async executeCore(args: ReadTaigaIssueArgs): Promise { + const { projectId, issueId } = await this.resolveIssue(args.issueNumber); + const issueData = await this.fetchIssueData(projectId, issueId); + return new TextResponse(JSON.stringify(issueData, null, 2)); + } + + /** + * Resolves a Penpot issue number to the internal Taiga project and issue IDs. + */ + private async resolveIssue(issueNumber: number): Promise<{ projectId: number; issueId: number }> { + const url = `${ReadTaigaIssueTool.TAIGA_API_BASE}/resolver?project=penpot&issue=${issueNumber}`; + const data = await this.fetchJson(url); + return { projectId: data.project, issueId: data.issue }; + } + + /** + * Fetches the full issue data including details, attachments, and comments. + */ + private async fetchIssueData(projectId: number, issueId: number): Promise { + // fetch issue details, attachments, and history in parallel + const [details, attachments, comments] = await Promise.all([ + this.fetchIssueDetails(issueId), + this.fetchAttachments(projectId, issueId), + this.fetchComments(issueId), + ]); + + return { + subject: details.subject, + description: details.description ?? "", + status: details.status_extra_info?.name ?? "Unknown", + attachments, + comments, + }; + } + + /** + * Fetches the core issue details from the Taiga API. + */ + private async fetchIssueDetails(issueId: number): Promise { + const url = `${ReadTaigaIssueTool.TAIGA_API_BASE}/issues/${issueId}`; + return this.fetchJson(url); + } + + /** + * Fetches the attachments for an issue. + */ + private async fetchAttachments(projectId: number, issueId: number): Promise { + const url = `${ReadTaigaIssueTool.TAIGA_API_BASE}/issues/attachments?project=${projectId}&object_id=${issueId}`; + const data: any[] = await this.fetchJson(url); + return data.map((a) => ({ + filename: a.name, + size: a.size, + url: a.url, + })); + } + + /** + * Fetches comments from the issue history. + * + * History entries that have a non-empty `comment` field are treated as comments. + */ + private async fetchComments(issueId: number): Promise { + const url = `${ReadTaigaIssueTool.TAIGA_API_BASE}/history/issue/${issueId}`; + const history: any[] = await this.fetchJson(url); + return history + .filter((entry) => entry.comment && entry.comment.trim().length > 0) + .map((entry) => ({ + username: entry.user?.username ?? "unknown", + comment: entry.comment, + })); + } + + /** + * Performs a GET request and returns the parsed JSON response. + * + * @throws Error if the HTTP response status is not OK + */ + private async fetchJson(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Taiga API request failed: ${response.status} ${response.statusText} (${url})`); + } + return response.json(); + } +}