From d27ed2fb919b25ba46d8a1ab8bc04cef541e994f Mon Sep 17 00:00:00 2001 From: Kenneth Rios Date: Sun, 14 Sep 2025 15:43:45 -0500 Subject: [PATCH 1/2] Add tools for listing labels and users, and enhance issue handling - Introduced `linearListLabels` and `linearListUsers` tools for fetching labels and users. - Updated existing tools to include priority and project information in issue listings. - Enhanced issue creation and update functionalities to support label and priority management. - Improved type safety in GraphQL fetch calls across various tools. --- src/factory.ts | 4 ++ src/tools/linear.ts | 40 ++++++++++- src/tools/linearCreateIssue.ts | 86 +++++++++++++++++++++--- src/tools/linearGetIssue.ts | 17 ++++- src/tools/linearListIssues.ts | 24 ++++++- src/tools/linearListLabels.ts | 22 +++++++ src/tools/linearListUsers.ts | 20 ++++++ src/tools/linearUpdateIssue.ts | 117 +++++++++++++++++++++++++++++---- 8 files changed, 302 insertions(+), 28 deletions(-) create mode 100644 src/tools/linearListLabels.ts create mode 100644 src/tools/linearListUsers.ts diff --git a/src/factory.ts b/src/factory.ts index 044fa85..64478c2 100644 --- a/src/factory.ts +++ b/src/factory.ts @@ -8,6 +8,8 @@ import registerListIssues from "./tools/linearListIssues"; import registerGetIssue from "./tools/linearGetIssue"; import registerListTeams from "./tools/linearListTeams"; import registerListStates from "./tools/linearListStates"; +import registerListLabels from "./tools/linearListLabels"; +import registerListUsers from "./tools/linearListUsers"; export function registerTools(server: McpServer, env: Env): void { registerCreate(server, env); @@ -19,6 +21,8 @@ export function registerTools(server: McpServer, env: Env): void { registerGetIssue(server, env); registerListTeams(server, env); registerListStates(server, env); + registerListLabels(server, env); + registerListUsers(server, env); } diff --git a/src/tools/linear.ts b/src/tools/linear.ts index 21a006e..1c6ab40 100644 --- a/src/tools/linear.ts +++ b/src/tools/linear.ts @@ -48,10 +48,38 @@ export const GQL = { updatedAt: { gte: $updatedAfter, lte: $updatedBefore } createdAt: { gte: $createdAfter, lte: $createdBefore } } - ) { nodes { id identifier title state { id name } assignee { id name } createdAt updatedAt } } + ) { + nodes { + id + identifier + title + state { id name } + assignee { id name } + priority + project { id name } + labels { nodes { id name } } + createdAt + updatedAt + } + } }`, issueByIdentifier: ` - query ($id: String!) { issue(id: $id) { id identifier title description state { id name } assignee { id name email } team { id key name } createdAt updatedAt } }`, + query ($id: String!) { + issue(id: $id) { + id + identifier + title + description + state { id name } + assignee { id name email } + team { id key name } + priority + project { id name } + labels { nodes { id name } } + createdAt + updatedAt + } + }`, issueCreate: ` mutation ($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { id identifier title } } @@ -70,6 +98,14 @@ export const GQL = { webhookDelete: `mutation ($id: String!) { webhookDelete(id: $id) { success } }`, teamIdByKey: `query ($key: String!) { teams(filter: { key: { eq: $key } }) { nodes { id } } }`, teamsList: `query { teams(first: 50) { nodes { id key name } } }`, + issueLabelsByTeam: ` + query ($teamId: ID) { + issueLabels(filter: { team: { id: { eq: $teamId } } }) { nodes { id name } } + }`, + usersList: ` + query { + users { nodes { id name email } } + }`, }; export function isUuidLike(value: string): boolean { diff --git a/src/tools/linearCreateIssue.ts b/src/tools/linearCreateIssue.ts index 0abb79c..9a4c71b 100644 --- a/src/tools/linearCreateIssue.ts +++ b/src/tools/linearCreateIssue.ts @@ -11,25 +11,87 @@ export default function register(server: McpServer, env: Env) { description: z.string().optional(), assigneeEmail: z.string().email().optional(), projectId: z.string().optional(), + projectName: z.string().optional(), + priority: z.number().int().min(0).max(4).optional(), + labelIds: z.array(z.string()).optional(), + labelNames: z.array(z.string()).optional(), dueToday: z.boolean().optional(), - dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + dueDate: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), }, async (input) => { const teamId = await resolveTeamIdFromKey(env, input.teamKey); let assigneeId: string | undefined; if (input.assigneeEmail) { - const ud = await linearFetch(env, GQL.userByEmail, { email: input.assigneeEmail }); + const ud = await linearFetch<{ + users: { nodes: Array<{ id: string }> }; + }>(env, GQL.userByEmail, { + email: input.assigneeEmail, + }); assigneeId = ud.users.nodes[0]?.id; - if (!assigneeId) throw new Error(`Usuario no encontrado: ${input.assigneeEmail}`); + if (!assigneeId) + throw new Error(`Usuario no encontrado: ${input.assigneeEmail}`); } - const payload: Record = { + type IssueCreatePayload = { + teamId: string; + title: string; + description?: string; + assigneeId?: string; + projectId?: string; + priority?: number; + labelIds?: string[]; + dueDate?: string; + }; + let resolvedProjectId: string | undefined = input.projectId; + if (!resolvedProjectId && input.projectName) { + const pd = await linearFetch<{ + projects: { nodes: Array<{ id: string; name: string }> }; + }>(env, GQL.projectsByTeam, { teamId }); + const proj = pd.projects.nodes.find( + (p) => + p.name.trim().toLowerCase() === + input.projectName!.trim().toLowerCase(), + ); + if (!proj) + throw new Error( + `Proyecto no encontrado por nombre: ${input.projectName}`, + ); + resolvedProjectId = proj.id; + } + + let resolvedLabelIds: string[] | undefined = input.labelIds; + if ( + (!resolvedLabelIds || resolvedLabelIds.length === 0) && + input.labelNames && + input.labelNames.length > 0 + ) { + const ld = await linearFetch<{ + issueLabels: { nodes: Array<{ id: string; name: string }> }; + }>(env, GQL.issueLabelsByTeam, { teamId }); + const mapByName = new Map( + ld.issueLabels.nodes.map( + (l) => [l.name.trim().toLowerCase(), l.id] as const, + ), + ); + resolvedLabelIds = input.labelNames.map((n) => { + const id = mapByName.get(n.trim().toLowerCase()); + if (!id) throw new Error(`Label no encontrado: ${n}`); + return id; + }); + } + + const payload: IssueCreatePayload = { teamId, title: input.title, description: input.description, assigneeId, - projectId: input.projectId, + projectId: resolvedProjectId, + priority: input.priority, + labelIds: resolvedLabelIds, }; if (input.dueToday) { @@ -37,16 +99,22 @@ export default function register(server: McpServer, env: Env) { const yyyy = now.getFullYear(); const mm = String(now.getMonth() + 1).padStart(2, "0"); const dd = String(now.getDate()).padStart(2, "0"); - (payload as any).dueDate = `${yyyy}-${mm}-${dd}`; + payload.dueDate = `${yyyy}-${mm}-${dd}`; } else if (input.dueDate) { - (payload as any).dueDate = input.dueDate; + payload.dueDate = input.dueDate; } // Optional: allow setting state via name/type alias at creation if 'state' provided // Linear's IssueCreateInput supports stateId; resolve if description contains state alias not needed - const r = await linearFetch(env, GQL.issueCreate, { input: payload }); + const r = await linearFetch<{ + issueCreate: { issue: { identifier: string; title: string } }; + }>(env, GQL.issueCreate, { input: payload }); const issue = r.issueCreate.issue; - return { content: [{ type: "text", text: `Creado ${issue.identifier}: ${issue.title}` }] }; + return { + content: [ + { type: "text", text: `Creado ${issue.identifier}: ${issue.title}` }, + ], + }; }, ); } diff --git a/src/tools/linearGetIssue.ts b/src/tools/linearGetIssue.ts index 6372b30..4a3761a 100644 --- a/src/tools/linearGetIssue.ts +++ b/src/tools/linearGetIssue.ts @@ -8,10 +8,23 @@ export default function register(server: McpServer, env: Env) { { idOrKey: z.string() }, async (input) => { const id = await resolveIssueId(env, input.idOrKey); - const data = await linearFetch(env, GQL.issueByIdentifier, { id }); + const data = await linearFetch<{ + issue?: { + identifier: string; + title: string; + state?: { name?: string }; + assignee?: { name?: string }; + team?: { key?: string }; + priority?: number; + project?: { name?: string }; + labels?: { nodes?: Array<{ id: string; name: string }> }; + updatedAt?: string; + }; + }>(env, GQL.issueByIdentifier, { id }); const i = data.issue; if (!i) return { content: [{ type: "text", text: "Issue no encontrado" }] }; - const text = `${i.identifier} ${i.title}\nEstado: ${i.state?.name ?? ""}\nAsignado: ${i.assignee?.name ?? ""}\nEquipo: ${i.team?.key ?? ""}\nActualizado: ${i.updatedAt}`; + const labels = (i.labels?.nodes || []).map((l) => l.name).join(", "); + const text = `${i.identifier} ${i.title}\nEstado: ${i.state?.name ?? ""}\nAsignado: ${i.assignee?.name ?? ""}\nEquipo: ${i.team?.key ?? ""}\nPrioridad: ${i.priority ?? ""}\nProyecto: ${i.project?.name ?? ""}\nLabels: ${labels}\nActualizado: ${i.updatedAt}`; return { content: [{ type: "text", text }] }; }, ); diff --git a/src/tools/linearListIssues.ts b/src/tools/linearListIssues.ts index e1efa92..5a38720 100644 --- a/src/tools/linearListIssues.ts +++ b/src/tools/linearListIssues.ts @@ -81,7 +81,17 @@ export default function register(server: McpServer, env: Env) { }); const items = (data.issues?.nodes || []).map( - (n) => `${n.identifier} ${n.title} [${n.state?.name ?? ""}]`, + (n: { + identifier: string; + title: string; + state?: { name?: string }; + priority?: number; + project?: { name?: string }; + labels?: { nodes?: Array<{ id: string; name: string }> }; + }) => { + const labels = (n.labels?.nodes || []).map((l) => l.name).join(", "); + return `${n.identifier} ${n.title} [${n.state?.name ?? ""}] P:${n.priority ?? ""} Proy:${n.project?.name ?? ""} Labels:${labels}`; + }, ); return { content: [{ type: "text", text: items.join("\n") || "Sin resultados" }] }; }, @@ -134,7 +144,17 @@ export default function register(server: McpServer, env: Env) { updatedBefore: toDateTimeEnd(today), }); const items = (data.issues?.nodes || []).map( - (n) => `${n.identifier} ${n.title} [${n.state?.name ?? ""}]`, + (n: { + identifier: string; + title: string; + state?: { name?: string }; + priority?: number; + project?: { name?: string }; + labels?: { nodes?: Array<{ id: string; name: string }> }; + }) => { + const labels = (n.labels?.nodes || []).map((l) => l.name).join(", "); + return `${n.identifier} ${n.title} [${n.state?.name ?? ""}] P:${n.priority ?? ""} Proy:${n.project?.name ?? ""} Labels:${labels}`; + }, ); return { content: [{ type: "text", text: items.join("\n") || "Sin resultados" }] }; }, diff --git a/src/tools/linearListLabels.ts b/src/tools/linearListLabels.ts new file mode 100644 index 0000000..4081ea3 --- /dev/null +++ b/src/tools/linearListLabels.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { GQL, linearFetch, resolveTeamIdFromKey } from "./linear"; + +export default function register(server: McpServer, env: Env) { + server.tool( + "linearListLabels", + { teamKey: z.string().optional() }, + async (input) => { + let teamId: string | undefined; + if (input.teamKey) teamId = await resolveTeamIdFromKey(env, input.teamKey); + const data = await linearFetch<{ + issueLabels: { nodes: Array<{ id: string; name: string }> }; + }>(env, GQL.issueLabelsByTeam, { teamId }); + const rows = data.issueLabels?.nodes || []; + const items = rows.map((l) => `${l.id}\t${l.name}`); + return { content: [{ type: "text", text: items.join("\n") || "Sin labels" }] }; + }, + ); +} + + diff --git a/src/tools/linearListUsers.ts b/src/tools/linearListUsers.ts new file mode 100644 index 0000000..ca63280 --- /dev/null +++ b/src/tools/linearListUsers.ts @@ -0,0 +1,20 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { GQL, linearFetch } from "./linear"; + +export default function register(server: McpServer, env: Env) { + server.tool( + "linearListUsers", + {}, + async () => { + const data = await linearFetch<{ users: { nodes: Array<{ id: string; name: string; email: string }> } }>( + env, + GQL.usersList, + {}, + ); + const items = (data.users?.nodes || []).map((u) => `${u.id}\t${u.name}\t${u.email}`); + return { content: [{ type: "text", text: items.join("\n") || "Sin usuarios" }] }; + }, + ); +} + + diff --git a/src/tools/linearUpdateIssue.ts b/src/tools/linearUpdateIssue.ts index 47fd7ae..0863196 100644 --- a/src/tools/linearUpdateIssue.ts +++ b/src/tools/linearUpdateIssue.ts @@ -15,17 +15,33 @@ export default function register(server: McpServer, env: Env) { title: z.string().optional(), description: z.string().optional(), stateId: z.string().optional(), - state: z.string().optional().describe("Human-friendly status name or type"), + state: z + .string() + .optional() + .describe("Human-friendly status name or type"), assigneeEmail: z.string().email().optional(), - dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + dueDate: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), + projectId: z.string().optional(), + projectName: z.string().optional(), + priority: z.number().int().min(0).max(4).optional(), + labelIds: z.array(z.string()).optional(), + labelNames: z.array(z.string()).optional(), }, async (input) => { const issueId = await resolveIssueId(env, input.idOrKey); let assigneeId: string | undefined; if (input.assigneeEmail) { - const ud = await linearFetch(env, GQL.userByEmail, { email: input.assigneeEmail }); + const ud = await linearFetch<{ + users: { nodes: Array<{ id: string }> }; + }>(env, GQL.userByEmail, { + email: input.assigneeEmail, + }); assigneeId = ud.users.nodes[0]?.id; - if (!assigneeId) throw new Error(`Usuario no encontrado: ${input.assigneeEmail}`); + if (!assigneeId) + throw new Error(`Usuario no encontrado: ${input.assigneeEmail}`); } const update: Record = {}; @@ -33,32 +49,107 @@ export default function register(server: McpServer, env: Env) { if (input.description) update.description = input.description; if (assigneeId) update.assigneeId = assigneeId; if (input.dueDate) update.dueDate = input.dueDate; + if (input.projectId) update.projectId = input.projectId; + if (typeof input.priority === "number") update.priority = input.priority; + if (input.labelIds) update.labelIds = input.labelIds; // Resolve state: prefer explicit stateId if it is a UUID; otherwise map via name/type let desiredAlias: string | undefined = input.state; - if (input.stateId && !isUuidLike(input.stateId)) desiredAlias = input.stateId; - if (input.stateId && isUuidLike(input.stateId)) update.stateId = input.stateId; + if (input.stateId && !isUuidLike(input.stateId)) + desiredAlias = input.stateId; + if (input.stateId && isUuidLike(input.stateId)) + update.stateId = input.stateId; + + let teamIdForResolution: string | undefined; + const needTeamForState = !update.stateId && !!desiredAlias; + const needTeamForProject = !!input.projectName && !input.projectId; + const needTeamForLabels = !!( + input.labelNames && + (!input.labelIds || input.labelIds.length === 0) + ); + if (needTeamForState || needTeamForProject || needTeamForLabels) { + const issueData = await linearFetch<{ + issue?: { team?: { id?: string } }; + }>(env, GQL.issueById, { id: issueId }); + teamIdForResolution = issueData.issue?.team?.id; + if (!teamIdForResolution) + throw new Error("No se pudo determinar el team del issue"); + } if (!update.stateId && desiredAlias) { - const issueData = await linearFetch(env, GQL.issueById, { id: issueId }); + const issueData = await linearFetch<{ + issue?: { team?: { id?: string } }; + }>(env, GQL.issueById, { + id: issueId, + }); const teamId: string | undefined = issueData.issue?.team?.id; if (!teamId) throw new Error("No se pudo determinar el team del issue"); - const statesData = await linearFetch(env, GQL.teamWorkflowStates, { teamId }); - const nodes: Array<{ id: string; name: string; type?: string }> = statesData.workflowStates.nodes; + const statesData = await linearFetch<{ + workflowStates: { + nodes: Array<{ id: string; name: string; type?: string }>; + }; + }>(env, GQL.teamWorkflowStates, { + teamId, + }); + const nodes = statesData.workflowStates.nodes; const wanted = desiredAlias.trim().toLowerCase(); - const byName = nodes.find((s) => s.name.trim().toLowerCase() === wanted); - const byType = nodes.find((s) => (s.type || "").trim().toLowerCase() === wanted); + const byName = nodes.find( + (s) => s.name.trim().toLowerCase() === wanted, + ); + const byType = nodes.find( + (s) => (s.type || "").trim().toLowerCase() === wanted, + ); const stateId = (byName || byType)?.id; if (!stateId) throw new Error(`Estado no encontrado: ${desiredAlias}`); update.stateId = stateId; } - const r = await linearFetch(env, GQL.issueUpdate, { + // Resolve projectName -> projectId + const pd = await linearFetch<{ + projects: { nodes: Array<{ id: string; name: string }> }; + }>(env, GQL.projectsByTeam, { teamId: teamIdForResolution! }); + const proj = pd.projects.nodes.find( + (p) => + p.name.trim().toLowerCase() === + input.projectName!.trim().toLowerCase(), + ); + if (!proj) + throw new Error(`Proyecto no encontrado: ${input.projectName}`); + update.projectId = proj.id; + } + + if (!input.labelIds && input.labelNames && input.labelNames.length > 0) { + // Resolve labelNames -> labelIds + const ld = await linearFetch<{ + issueLabels: { nodes: Array<{ id: string; name: string }> }; + }>(env, GQL.issueLabelsByTeam, { teamId: teamIdForResolution! }); + const mapByName = new Map( + ld.issueLabels.nodes.map( + (l) => [l.name.trim().toLowerCase(), l.id] as const, + ), + ); + update.labelIds = input.labelNames.map((n) => { + const id = mapByName.get(n.trim().toLowerCase()); + if (!id) throw new Error(`Label no encontrado: ${n}`); + return id; + }); + } + + const r = await linearFetch<{ + issueUpdate: { issue: { identifier: string; title: string } }; + }>(env, GQL.issueUpdate, { id: issueId, input: update, }); const issue = r.issueUpdate.issue; - return { content: [{ type: "text", text: `Actualizado ${issue.identifier}: ${issue.title}` }] }; + return { + content: [ + { + type: "text", + text: `Actualizado ${issue.identifier}: ${issue.title}`, + }, + ], + }; }, ); } From f47da78a5069273144a0afb3b81b0ee2766091d0 Mon Sep 17 00:00:00 2001 From: Kenneth Rios Date: Sun, 14 Sep 2025 22:44:12 -0500 Subject: [PATCH 2/2] Enhance Linear tools with new functionalities and improved type safety - Updated `biome.json` to include only necessary files. - Added `projectsByTeam` query to fetch projects associated with a specific team. - Enhanced `linearComment`, `linearCreateIssue`, `linearDeleteIssue`, `linearGetIssue`, `linearListIssues`, `linearListLabels`, `linearListStates`, `linearListTeams`, `linearListUsers`, and `linearUpdateIssue` tools with descriptive summaries for better clarity. - Improved type safety in GraphQL fetch calls across various tools, ensuring more robust error handling and data management. --- biome.json | 2 +- src/tools/linear.ts | 4 + src/tools/linearComment.ts | 14 +++- src/tools/linearCreateIssue.ts | 6 +- src/tools/linearDeleteIssue.ts | 1 + src/tools/linearGetIssue.ts | 1 + src/tools/linearListIssues.ts | 81 +++++++++++-------- src/tools/linearListLabels.ts | 8 +- src/tools/linearListStates.ts | 15 ++-- src/tools/linearListTeams.ts | 13 ++-- src/tools/linearListUsers.ts | 15 ++-- src/tools/linearUpdateIssue.ts | 138 +++++++++++++++++---------------- src/tools/linearWebhooks.ts | 20 ++++- 13 files changed, 191 insertions(+), 127 deletions(-) diff --git a/biome.json b/biome.json index 49164d5..6e069bf 100644 --- a/biome.json +++ b/biome.json @@ -9,7 +9,7 @@ "enabled": true }, "files": { - "includes": ["!worker-configuration.d.ts", "src/**/*"] + "includes": ["src/**/*"] }, "formatter": { "enabled": true, diff --git a/src/tools/linear.ts b/src/tools/linear.ts index 1c6ab40..6e380a3 100644 --- a/src/tools/linear.ts +++ b/src/tools/linear.ts @@ -106,6 +106,10 @@ export const GQL = { query { users { nodes { id name email } } }`, + projectsByTeam: ` + query ($teamId: ID) { + projects(filter: { team: { id: { eq: $teamId } } }) { nodes { id name } } + }`, }; export function isUuidLike(value: string): boolean { diff --git a/src/tools/linearComment.ts b/src/tools/linearComment.ts index a32ab14..5d6d95f 100644 --- a/src/tools/linearComment.ts +++ b/src/tools/linearComment.ts @@ -5,16 +5,26 @@ import { linearFetch, GQL, resolveIssueId } from "./linear"; export default function register(server: McpServer, env: Env) { server.tool( "linearComment", + "Add a Markdown comment to a Linear issue by id or key (e.g., ENG-123).", { issueIdOrKey: z.string(), body: z.string(), }, async (input) => { const issueId = await resolveIssueId(env, input.issueIdOrKey); - const r = await linearFetch(env, GQL.commentCreate, { + const r = await linearFetch<{ + commentCreate?: { comment?: { id?: string } }; + }>(env, GQL.commentCreate, { input: { issueId, body: input.body }, }); - return { content: [{ type: "text", text: `Comentario ${r.commentCreate?.comment?.id ?? "ok"}` }] }; + return { + content: [ + { + type: "text", + text: `Comentario ${r.commentCreate?.comment?.id ?? "ok"}`, + }, + ], + }; }, ); } diff --git a/src/tools/linearCreateIssue.ts b/src/tools/linearCreateIssue.ts index 9a4c71b..05798c7 100644 --- a/src/tools/linearCreateIssue.ts +++ b/src/tools/linearCreateIssue.ts @@ -5,6 +5,7 @@ import { linearFetch, GQL, resolveTeamIdFromKey } from "./linear"; export default function register(server: McpServer, env: Env) { server.tool( "linearCreateIssue", + "Create a Linear issue. Supports project/labels by name, optional assignee and due date.", { teamKey: z.string(), title: z.string(), @@ -51,10 +52,9 @@ export default function register(server: McpServer, env: Env) { const pd = await linearFetch<{ projects: { nodes: Array<{ id: string; name: string }> }; }>(env, GQL.projectsByTeam, { teamId }); + const needle = (input.projectName ?? "").trim().toLowerCase(); const proj = pd.projects.nodes.find( - (p) => - p.name.trim().toLowerCase() === - input.projectName!.trim().toLowerCase(), + (p) => p.name.trim().toLowerCase() === needle, ); if (!proj) throw new Error( diff --git a/src/tools/linearDeleteIssue.ts b/src/tools/linearDeleteIssue.ts index cfceb77..08bde44 100644 --- a/src/tools/linearDeleteIssue.ts +++ b/src/tools/linearDeleteIssue.ts @@ -5,6 +5,7 @@ import { linearFetch, GQL, resolveIssueId } from "./linear"; export default function register(server: McpServer, env: Env) { server.tool( "linearDeleteIssue", + "Delete a Linear issue by id or key (e.g., ENG-123).", { idOrKey: z.string() }, async (input) => { const id = await resolveIssueId(env, input.idOrKey); diff --git a/src/tools/linearGetIssue.ts b/src/tools/linearGetIssue.ts index 4a3761a..40ab0dc 100644 --- a/src/tools/linearGetIssue.ts +++ b/src/tools/linearGetIssue.ts @@ -5,6 +5,7 @@ import { linearFetch, GQL, resolveIssueId } from "./linear"; export default function register(server: McpServer, env: Env) { server.tool( "linearGetIssue", + "Get detailed info for a Linear issue by id or key.", { idOrKey: z.string() }, async (input) => { const id = await resolveIssueId(env, input.idOrKey); diff --git a/src/tools/linearListIssues.ts b/src/tools/linearListIssues.ts index 5a38720..038859a 100644 --- a/src/tools/linearListIssues.ts +++ b/src/tools/linearListIssues.ts @@ -12,37 +12,53 @@ function isoDate(d: Date): string { export default function register(server: McpServer, env: Env) { server.tool( "linearListIssues", + "List Linear issues filtered by team, assignee, and date ranges.", { teamKey: z.string().optional(), assigneeEmail: z.string().email().optional(), - createdOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), - updatedOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), - createdAfter: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), - createdBefore: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), - updatedAfter: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), - updatedBefore: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + createdOn: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), + updatedOn: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), + createdAfter: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), + createdBefore: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), + updatedAfter: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), + updatedBefore: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), }, async (input) => { let teamId: string | undefined; if (input.teamKey) { - const td = await linearFetch<{ teams: { nodes: Array<{ id: string }> } }>( - env, - GQL.teamByKey, - { key: input.teamKey }, - ); + const td = await linearFetch<{ + teams: { nodes: Array<{ id: string }> }; + }>(env, GQL.teamByKey, { key: input.teamKey }); teamId = td.teams.nodes[0]?.id; if (!teamId) throw new Error(`Team no encontrado: ${input.teamKey}`); } let assigneeId: string | undefined; if (input.assigneeEmail) { - const ud = await linearFetch<{ users: { nodes: Array<{ id: string }> } }>( - env, - GQL.userByEmail, - { email: input.assigneeEmail }, - ); + const ud = await linearFetch<{ + users: { nodes: Array<{ id: string }> }; + }>(env, GQL.userByEmail, { email: input.assigneeEmail }); assigneeId = ud.users.nodes[0]?.id; - if (!assigneeId) throw new Error(`Usuario no encontrado: ${input.assigneeEmail}`); + if (!assigneeId) + throw new Error(`Usuario no encontrado: ${input.assigneeEmail}`); } let createdAfter = input.createdAfter; @@ -61,7 +77,8 @@ export default function register(server: McpServer, env: Env) { } const toDateTime = (s?: string) => (s ? `${s}T00:00:00.000Z` : undefined); - const toDateTimeEnd = (s?: string) => (s ? `${s}T23:59:59.999Z` : undefined); + const toDateTimeEnd = (s?: string) => + s ? `${s}T23:59:59.999Z` : undefined; const data = await linearFetch<{ issues: { @@ -93,12 +110,15 @@ export default function register(server: McpServer, env: Env) { return `${n.identifier} ${n.title} [${n.state?.name ?? ""}] P:${n.priority ?? ""} Proy:${n.project?.name ?? ""} Labels:${labels}`; }, ); - return { content: [{ type: "text", text: items.join("\n") || "Sin resultados" }] }; + return { + content: [{ type: "text", text: items.join("\n") || "Sin resultados" }], + }; }, ); server.tool( "linearListIssuesToday", + "List issues updated today, optionally filtered by team and assignee.", { teamKey: z.string().optional(), assigneeEmail: z.string().email().optional(), @@ -106,24 +126,21 @@ export default function register(server: McpServer, env: Env) { async (input) => { let teamId: string | undefined; if (input.teamKey) { - const td = await linearFetch<{ teams: { nodes: Array<{ id: string }> } }>( - env, - GQL.teamByKey, - { key: input.teamKey }, - ); + const td = await linearFetch<{ + teams: { nodes: Array<{ id: string }> }; + }>(env, GQL.teamByKey, { key: input.teamKey }); teamId = td.teams.nodes[0]?.id; if (!teamId) throw new Error(`Team no encontrado: ${input.teamKey}`); } let assigneeId: string | undefined; if (input.assigneeEmail) { - const ud = await linearFetch<{ users: { nodes: Array<{ id: string }> } }>( - env, - GQL.userByEmail, - { email: input.assigneeEmail }, - ); + const ud = await linearFetch<{ + users: { nodes: Array<{ id: string }> }; + }>(env, GQL.userByEmail, { email: input.assigneeEmail }); assigneeId = ud.users.nodes[0]?.id; - if (!assigneeId) throw new Error(`Usuario no encontrado: ${input.assigneeEmail}`); + if (!assigneeId) + throw new Error(`Usuario no encontrado: ${input.assigneeEmail}`); } const today = isoDate(new Date()); @@ -156,7 +173,9 @@ export default function register(server: McpServer, env: Env) { return `${n.identifier} ${n.title} [${n.state?.name ?? ""}] P:${n.priority ?? ""} Proy:${n.project?.name ?? ""} Labels:${labels}`; }, ); - return { content: [{ type: "text", text: items.join("\n") || "Sin resultados" }] }; + return { + content: [{ type: "text", text: items.join("\n") || "Sin resultados" }], + }; }, ); } diff --git a/src/tools/linearListLabels.ts b/src/tools/linearListLabels.ts index 4081ea3..a352ef7 100644 --- a/src/tools/linearListLabels.ts +++ b/src/tools/linearListLabels.ts @@ -5,16 +5,20 @@ import { GQL, linearFetch, resolveTeamIdFromKey } from "./linear"; export default function register(server: McpServer, env: Env) { server.tool( "linearListLabels", + "List labels for a team (or all teams when omitted).", { teamKey: z.string().optional() }, async (input) => { let teamId: string | undefined; - if (input.teamKey) teamId = await resolveTeamIdFromKey(env, input.teamKey); + if (input.teamKey) + teamId = await resolveTeamIdFromKey(env, input.teamKey); const data = await linearFetch<{ issueLabels: { nodes: Array<{ id: string; name: string }> }; }>(env, GQL.issueLabelsByTeam, { teamId }); const rows = data.issueLabels?.nodes || []; const items = rows.map((l) => `${l.id}\t${l.name}`); - return { content: [{ type: "text", text: items.join("\n") || "Sin labels" }] }; + return { + content: [{ type: "text", text: items.join("\n") || "Sin labels" }], + }; }, ); } diff --git a/src/tools/linearListStates.ts b/src/tools/linearListStates.ts index df785d5..1c2cab7 100644 --- a/src/tools/linearListStates.ts +++ b/src/tools/linearListStates.ts @@ -5,18 +5,21 @@ import { GQL, linearFetch, resolveTeamIdFromKey } from "./linear"; export default function register(server: McpServer, env: Env) { server.tool( "linearListStates", + "List workflow states for a given team.", { teamKey: z.string() }, async (input) => { const teamId = await resolveTeamIdFromKey(env, input.teamKey); - const data = await linearFetch<{ workflowStates: { nodes: Array<{ id: string; name: string; type?: string }> } }>( - env, - GQL.teamWorkflowStates, - { teamId }, - ); + const data = await linearFetch<{ + workflowStates: { + nodes: Array<{ id: string; name: string; type?: string }>; + }; + }>(env, GQL.teamWorkflowStates, { teamId }); const items = (data.workflowStates?.nodes || []).map( (s) => `${s.id}\t${s.name}\t${s.type ?? ""}`, ); - return { content: [{ type: "text", text: items.join("\n") || "Sin estados" }] }; + return { + content: [{ type: "text", text: items.join("\n") || "Sin estados" }], + }; }, ); } diff --git a/src/tools/linearListTeams.ts b/src/tools/linearListTeams.ts index 6ecdf27..25f5ead 100644 --- a/src/tools/linearListTeams.ts +++ b/src/tools/linearListTeams.ts @@ -4,15 +4,16 @@ import { GQL, linearFetch } from "./linear"; export default function register(server: McpServer, env: Env) { server.tool( "linearListTeams", + "List available Linear teams (key and name).", {}, async () => { - const data = await linearFetch<{ teams: { nodes: Array<{ id: string; key: string; name: string }> } }>( - env, - GQL.teamsList, - {}, - ); + const data = await linearFetch<{ + teams: { nodes: Array<{ id: string; key: string; name: string }> }; + }>(env, GQL.teamsList, {}); const items = (data.teams?.nodes || []).map((t) => `${t.key}\t${t.name}`); - return { content: [{ type: "text", text: items.join("\n") || "Sin equipos" }] }; + return { + content: [{ type: "text", text: items.join("\n") || "Sin equipos" }], + }; }, ); } diff --git a/src/tools/linearListUsers.ts b/src/tools/linearListUsers.ts index ca63280..7c1ee4a 100644 --- a/src/tools/linearListUsers.ts +++ b/src/tools/linearListUsers.ts @@ -4,15 +4,18 @@ import { GQL, linearFetch } from "./linear"; export default function register(server: McpServer, env: Env) { server.tool( "linearListUsers", + "List users in the Linear workspace (id, name, email).", {}, async () => { - const data = await linearFetch<{ users: { nodes: Array<{ id: string; name: string; email: string }> } }>( - env, - GQL.usersList, - {}, + const data = await linearFetch<{ + users: { nodes: Array<{ id: string; name: string; email: string }> }; + }>(env, GQL.usersList, {}); + const items = (data.users?.nodes || []).map( + (u) => `${u.id}\t${u.name}\t${u.email}`, ); - const items = (data.users?.nodes || []).map((u) => `${u.id}\t${u.name}\t${u.email}`); - return { content: [{ type: "text", text: items.join("\n") || "Sin usuarios" }] }; + return { + content: [{ type: "text", text: items.join("\n") || "Sin usuarios" }], + }; }, ); } diff --git a/src/tools/linearUpdateIssue.ts b/src/tools/linearUpdateIssue.ts index 0863196..b48caf2 100644 --- a/src/tools/linearUpdateIssue.ts +++ b/src/tools/linearUpdateIssue.ts @@ -7,29 +7,28 @@ function isUuidLike(value: string): boolean { return /^[0-9a-fA-F-]{36}$/.test(value); } +const updateIssueArgsSchema = { + idOrKey: z.string(), + title: z.string().optional(), + description: z.string().optional(), + stateId: z.string().optional(), + state: z.string().optional().describe("Human-friendly status name or type"), + assigneeEmail: z.string().email().optional(), + dueDate: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), + projectId: z.string().optional(), + projectName: z.string().optional(), + labelIds: z.array(z.string()).optional(), + labelNames: z.array(z.string()).optional(), +} satisfies z.ZodRawShape; + export default function register(server: McpServer, env: Env) { server.tool( "linearUpdateIssue", - { - idOrKey: z.string(), - title: z.string().optional(), - description: z.string().optional(), - stateId: z.string().optional(), - state: z - .string() - .optional() - .describe("Human-friendly status name or type"), - assigneeEmail: z.string().email().optional(), - dueDate: z - .string() - .regex(/^\d{4}-\d{2}-\d{2}$/) - .optional(), - projectId: z.string().optional(), - projectName: z.string().optional(), - priority: z.number().int().min(0).max(4).optional(), - labelIds: z.array(z.string()).optional(), - labelNames: z.array(z.string()).optional(), - }, + "Update a Linear issue: title, description, state (alias), assignee, due date, project and labels by name or ID.", + updateIssueArgsSchema, async (input) => { const issueId = await resolveIssueId(env, input.idOrKey); let assigneeId: string | undefined; @@ -49,9 +48,9 @@ export default function register(server: McpServer, env: Env) { if (input.description) update.description = input.description; if (assigneeId) update.assigneeId = assigneeId; if (input.dueDate) update.dueDate = input.dueDate; - if (input.projectId) update.projectId = input.projectId; - if (typeof input.priority === "number") update.priority = input.priority; - if (input.labelIds) update.labelIds = input.labelIds; + + // We may need the issue's team for resolving aliases/names + let teamId: string | undefined; // Resolve state: prefer explicit stateId if it is a UUID; otherwise map via name/type let desiredAlias: string | undefined = input.state; @@ -60,29 +59,13 @@ export default function register(server: McpServer, env: Env) { if (input.stateId && isUuidLike(input.stateId)) update.stateId = input.stateId; - let teamIdForResolution: string | undefined; - const needTeamForState = !update.stateId && !!desiredAlias; - const needTeamForProject = !!input.projectName && !input.projectId; - const needTeamForLabels = !!( - input.labelNames && - (!input.labelIds || input.labelIds.length === 0) - ); - if (needTeamForState || needTeamForProject || needTeamForLabels) { - const issueData = await linearFetch<{ - issue?: { team?: { id?: string } }; - }>(env, GQL.issueById, { id: issueId }); - teamIdForResolution = issueData.issue?.team?.id; - if (!teamIdForResolution) - throw new Error("No se pudo determinar el team del issue"); - } - if (!update.stateId && desiredAlias) { const issueData = await linearFetch<{ - issue?: { team?: { id?: string } }; + issue?: { team?: { id: string } }; }>(env, GQL.issueById, { id: issueId, }); - const teamId: string | undefined = issueData.issue?.team?.id; + teamId = issueData.issue?.team?.id; if (!teamId) throw new Error("No se pudo determinar el team del issue"); const statesData = await linearFetch<{ workflowStates: { @@ -91,7 +74,8 @@ export default function register(server: McpServer, env: Env) { }>(env, GQL.teamWorkflowStates, { teamId, }); - const nodes = statesData.workflowStates.nodes; + const nodes: Array<{ id: string; name: string; type?: string }> = + statesData.workflowStates.nodes; const wanted = desiredAlias.trim().toLowerCase(); const byName = nodes.find( (s) => s.name.trim().toLowerCase() === wanted, @@ -104,35 +88,59 @@ export default function register(server: McpServer, env: Env) { update.stateId = stateId; } - // Resolve projectName -> projectId + if (input.projectId) { + // Project resolution: projectId direct or projectName via team + update.projectId = input.projectId; + } else if (input.projectName) { + if (!teamId) { + const issueData = await linearFetch<{ + issue?: { team?: { id: string } }; + }>(env, GQL.issueById, { id: issueId }); + teamId = issueData.issue?.team?.id; + if (!teamId) + throw new Error("No se pudo determinar el team del issue"); + } const pd = await linearFetch<{ projects: { nodes: Array<{ id: string; name: string }> }; - }>(env, GQL.projectsByTeam, { teamId: teamIdForResolution! }); + }>(env, GQL.projectsByTeam, { teamId }); + const needle = input.projectName.trim().toLowerCase(); const proj = pd.projects.nodes.find( - (p) => - p.name.trim().toLowerCase() === - input.projectName!.trim().toLowerCase(), + (p) => p.name.trim().toLowerCase() === needle, ); if (!proj) - throw new Error(`Proyecto no encontrado: ${input.projectName}`); + throw new Error(`Proyecto no encontrado por nombre: ${input.projectName}`); update.projectId = proj.id; } - if (!input.labelIds && input.labelNames && input.labelNames.length > 0) { - // Resolve labelNames -> labelIds - const ld = await linearFetch<{ - issueLabels: { nodes: Array<{ id: string; name: string }> }; - }>(env, GQL.issueLabelsByTeam, { teamId: teamIdForResolution! }); - const mapByName = new Map( - ld.issueLabels.nodes.map( - (l) => [l.name.trim().toLowerCase(), l.id] as const, - ), - ); - update.labelIds = input.labelNames.map((n) => { - const id = mapByName.get(n.trim().toLowerCase()); - if (!id) throw new Error(`Label no encontrado: ${n}`); - return id; - }); + if (input.labelIds !== undefined) { + // Labels resolution: labelIds direct or labelNames via team + update.labelIds = input.labelIds; + } else if (input.labelNames !== undefined) { + if (input.labelNames.length === 0) { + update.labelIds = []; + } else { + if (!teamId) { + const issueData = await linearFetch<{ + issue?: { team?: { id: string } }; + }>(env, GQL.issueById, { id: issueId }); + teamId = issueData.issue?.team?.id; + if (!teamId) + throw new Error("No se pudo determinar el team del issue"); + } + const ld = await linearFetch<{ + issueLabels: { nodes: Array<{ id: string; name: string }> }; + }>(env, GQL.issueLabelsByTeam, { teamId }); + const mapByName = new Map( + ld.issueLabels.nodes.map( + (l) => [l.name.trim().toLowerCase(), l.id] as const, + ), + ); + update.labelIds = input.labelNames.map((n) => { + const id = mapByName.get(n.trim().toLowerCase()); + if (!id) throw new Error(`Label no encontrado: ${n}`); + return id; + }); + } } const r = await linearFetch<{ @@ -152,6 +160,4 @@ export default function register(server: McpServer, env: Env) { }; }, ); -} - - +} \ No newline at end of file diff --git a/src/tools/linearWebhooks.ts b/src/tools/linearWebhooks.ts index 2c8a991..71869d9 100644 --- a/src/tools/linearWebhooks.ts +++ b/src/tools/linearWebhooks.ts @@ -5,21 +5,28 @@ import { GQL, linearFetch } from "./linear"; export default function register(server: McpServer, env: Env) { server.tool( "linearWebhookCreate", + "Create a Linear webhook (optionally scoped to a team).", { url: z.string().url().describe("Ej: https:///webhooks/linear"), teamKey: z.string().optional(), allPublicTeams: z.boolean().optional().default(false), - resourceTypes: z.array(z.string()).default(["Issue", "Comment", "Project"]), + resourceTypes: z + .array(z.string()) + .default(["Issue", "Comment", "Project"]), enabled: z.boolean().optional().default(true), }, async (input) => { let teamId: string | undefined; if (input.teamKey && !input.allPublicTeams) { - const td = await linearFetch(env, GQL.teamIdByKey, { key: input.teamKey }); + const td = await linearFetch<{ + teams: { nodes: Array<{ id: string }> }; + }>(env, GQL.teamIdByKey, { key: input.teamKey }); teamId = td.teams.nodes[0]?.id; if (!teamId) throw new Error(`Team no encontrado: ${input.teamKey}`); } - const r = await linearFetch(env, GQL.webhookCreate, { + const r = await linearFetch<{ + webhookCreate: { webhook: { id: string; enabled: boolean } }; + }>(env, GQL.webhookCreate, { input: { url: input.url, enabled: input.enabled, @@ -29,12 +36,17 @@ export default function register(server: McpServer, env: Env) { }, }); const w = r.webhookCreate.webhook; - return { content: [{ type: "text", text: `Webhook id=${w.id} enabled=${w.enabled}` }] }; + return { + content: [ + { type: "text", text: `Webhook id=${w.id} enabled=${w.enabled}` }, + ], + }; }, ); server.tool( "linearWebhookDelete", + "Delete a Linear webhook by id.", { id: z.string() }, async (input) => { await linearFetch(env, GQL.webhookDelete, { id: input.id });