From b140e0673ba64824805fdd7200957e07f91ada73 Mon Sep 17 00:00:00 2001 From: Tim Pearson Date: Thu, 21 May 2026 21:20:23 -0400 Subject: [PATCH] fix: correct todo tool GraphQL shapes (1.18.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs shipped in 1.16.0 that none of us caught because the GraphQL strings were never executed against a real GitLab: 1. mark_all_todos_done selected a non-existent updatedIds field on TodosMarkAllDonePayload — every call failed with a GraphQL error. Payload actually has todos { id state }; fix selects that. 2. list_my_todos groupPath/projectPath filters were silently dropped. The shipped code passed path strings as values of groupId/projectId args, which expect [ID!] node GIDs. Filter now resolves group(fullPath:) / project(fullPath:) to a GID before the query runs. 3. Todo target selection only handled Issue/MergeRequest/Epic — todos targeting WorkItem, Commit, Alerts, etc. came back as empty objects. Target now selects __typename + name/webUrl on the Todoable interface, with inline fragments kept for the common types. Also exposes filter args that already existed in the schema: - list_my_todos: authorIds, isSnoozed, sort - mark_all_todos_done: groupPath/projectPath/action/type/authorIds/targetId And adds Todo.targetUrl + note + snoozedUntil to the list_my_todos response (schema-guarded for older self-hosted GitLab). All three corrected shapes were round-tripped against the live GitLab schema before merging. --- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 12 +++ package-lock.json | 4 +- package.json | 2 +- src/gitlab-client.ts | 199 +++++++++++++++++++++++++++++++------ src/tools.ts | 68 ++++++++++++- 6 files changed, 248 insertions(+), 39 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 9d15a17..8679e38 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "gitlab-mcp", "description": "GitLab MCP server with GraphQL discovery and team activity tools", - "version": "1.17.0", + "version": "1.18.0", "icon": "assets/logo.svg", "author": { "name": "Tim Pearson" diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c9d708..f87f4d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.18.0] - 2026-05-21 + +### Fixed +- `mark_all_todos_done` no longer fails with `Field 'updatedIds' doesn't exist on type 'TodosMarkAllDonePayload'`. The shipped query selected a non-existent payload field on every GitLab instance; it now selects `todos { id state }` (the actual payload shape) and returns the list of updated todos. +- `list_my_todos` `groupPath` / `projectPath` filters now actually filter. The previous implementation passed path strings as the value of the `groupId` / `projectId` arguments (which expect `[ID!]` node GIDs, not paths), causing GitLab to silently ignore the filter and return every todo. Paths are now resolved to GIDs via `group(fullPath:)` / `project(fullPath:)` before the todos query runs. +- `list_my_todos` `target` selection now uses the `Todoable` interface plus inline fragments. Todos targeting `WorkItem`, `Commit`, `AlertManagement::Alert`, etc. previously came back as empty objects; they now return `__typename`, `name`, and `webUrl` from the interface. + +### Added +- `list_my_todos` exposes `authorIds`, `isSnoozed`, and `sort` filters that already exist on the GitLab schema but weren't surfaced. +- `list_my_todos` response now includes `targetUrl` (direct deep link to the todo target), `note { id body }` (the comment that triggered mention/directly-addressed todos), and `snoozedUntil` — schema-guarded so older self-hosted GitLab instances that lack the fields still work. +- `mark_all_todos_done` accepts optional scoping args (`groupPath`, `projectPath`, `action`, `type`, `authorIds`, `targetId`) so callers can mark a subset of pending todos done — matching `TodosMarkAllDoneInput`. With no args it still marks every pending todo as before. + ## [1.17.0] - 2026-05-21 ### Changed diff --git a/package-lock.json b/package-lock.json index deaa3ff..5b95735 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ttpears/gitlab-mcp-server", - "version": "1.17.0", + "version": "1.18.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ttpears/gitlab-mcp-server", - "version": "1.17.0", + "version": "1.18.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", diff --git a/package.json b/package.json index 1b83cc7..e4ae39f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ttpears/gitlab-mcp-server", - "version": "1.17.0", + "version": "1.18.0", "description": "GitLab MCP Server with GraphQL discovery", "main": "dist/index.js", "module": "./src/index.ts", diff --git a/src/gitlab-client.ts b/src/gitlab-client.ts index 36e6aa5..1888eb0 100644 --- a/src/gitlab-client.ts +++ b/src/gitlab-client.ts @@ -2566,11 +2566,39 @@ export class GitLabGraphQLClient { } /** - * List the authenticated user's GitLab todos. Always scoped to the caller via - * `currentUser.todos`. Filters are passed through to GraphQL; if the - * self-hosted GitLab schema is missing an enum value or filter argument we - * drop it and add a `_warning` field to the response, matching the fallback - * pattern used by updateIssueComposite. + * Resolve a GitLab group's full path (e.g. "gleim/foo") to its node GID. + * Returns undefined if the group can't be found; callers can surface that as + * a warning instead of failing the whole query. + */ + private async resolveGroupGid(fullPath: string, userConfig?: UserConfig): Promise { + const query = gql` + query resolveGroupGid($fullPath: ID!) { + group(fullPath: $fullPath) { id } + } + `; + const result = await this.query<{ group: { id: string } | null }>(query, { fullPath }, userConfig); + return result.group?.id; + } + + /** + * Resolve a GitLab project's full path (e.g. "gleim/foo/bar") to its node GID. + */ + private async resolveProjectGid(fullPath: string, userConfig?: UserConfig): Promise { + const query = gql` + query resolveProjectGid($fullPath: ID!) { + project(fullPath: $fullPath) { id } + } + `; + const result = await this.query<{ project: { id: string } | null }>(query, { fullPath }, userConfig); + return result.project?.id; + } + + /** + * List the authenticated user's GitLab todos via `currentUser.todos`. Path + * filters are resolved to node GIDs before being passed as `groupId` / + * `projectId` (the schema accepts `[ID!]`, not paths). Enum filters are + * validated against the live schema via introspection; unsupported values + * are dropped with a `_warning` in the response. */ async listMyTodos( params: { @@ -2579,6 +2607,9 @@ export class GitLabGraphQLClient { type?: string; groupPath?: string; projectPath?: string; + authorIds?: string[]; + isSnoozed?: boolean; + sort?: string; first?: number; after?: string; fetchAll?: boolean; @@ -2590,10 +2621,10 @@ export class GitLabGraphQLClient { const first = params.first ?? 20; const fetchAll = params.fetchAll ?? false; - const currentUserType = this.schema.getType('CurrentUser') || this.schema.getType('UserCore'); - const todosField = currentUserType?.getFields?.()?.['todos']; + const userType = this.schema.getType('CurrentUser') || this.schema.getType('User') || this.schema.getType('UserCore'); + const todosField = userType?.getFields?.()?.['todos']; if (!todosField) { - throw new Error('GitLab GraphQL schema does not expose currentUser.todos on this instance.'); + throw new Error('GitLab GraphQL schema does not expose User.todos on this instance.'); } const todosArgs: any[] = todosField.args || []; const argNames = new Set(todosArgs.map((a: any) => a.name)); @@ -2610,7 +2641,6 @@ export class GitLabGraphQLClient { } else { warnings.push(`state=${params.state} not supported by this GitLab; returning all states`); } - // state='all': GitLab defaults to [PENDING], so we must enumerate explicitly — unlike issues/MRs where omitting the filter returns all states. } else if (params.state?.toLowerCase() === 'all') { if (stateAllowed.length >= 2) { stateFilter = stateAllowed; @@ -2654,11 +2684,60 @@ export class GitLabGraphQLClient { } } - const groupArgName = argNames.has('groupId') ? 'groupId' : (argNames.has('group') ? 'group' : undefined); - const projectArgName = argNames.has('projectId') ? 'projectId' : (argNames.has('project') ? 'project' : undefined); + let groupIdFilter: string[] | undefined; + if (params.groupPath) { + if (!argNames.has('groupId')) { + warnings.push('groupId filter not supported by this GitLab; ignoring groupPath'); + } else { + const gid = await this.resolveGroupGid(params.groupPath.trim(), userConfig); + if (gid) { + groupIdFilter = [gid]; + } else { + warnings.push(`group ${params.groupPath} not found; ignoring filter`); + } + } + } - if (params.groupPath && !groupArgName) warnings.push('group filter not supported by this GitLab; ignoring'); - if (params.projectPath && !projectArgName) warnings.push('project filter not supported by this GitLab; ignoring'); + let projectIdFilter: string[] | undefined; + if (params.projectPath) { + if (!argNames.has('projectId')) { + warnings.push('projectId filter not supported by this GitLab; ignoring projectPath'); + } else { + const gid = await this.resolveProjectGid(params.projectPath.trim(), userConfig); + if (gid) { + projectIdFilter = [gid]; + } else { + warnings.push(`project ${params.projectPath} not found; ignoring filter`); + } + } + } + + let authorIdFilter: string[] | undefined; + if (params.authorIds && params.authorIds.length > 0) { + if (!argNames.has('authorId')) { + warnings.push('authorId filter not supported by this GitLab; ignoring'); + } else { + authorIdFilter = params.authorIds.map(id => + id.startsWith('gid://') ? id : `gid://gitlab/User/${id}` + ); + } + } + + const sortEnumName = this.getTypeName(todosArgs.find((a: any) => a.name === 'sort')?.type) || 'TodoSort'; + let sortFilter: string | undefined; + if (params.sort) { + if (!argNames.has('sort')) { + warnings.push('sort not supported by this GitLab; ignoring'); + } else { + const sortAllowed = this.getEnumValues(sortEnumName).map(v => String(v)); + const match = sortAllowed.find(v => v.toLowerCase() === params.sort!.toLowerCase()); + if (match) { + sortFilter = match; + } else { + warnings.push(`sort=${params.sort} not in ${sortEnumName}; ignoring`); + } + } + } const variableDefs: string[] = ['$first: Int!', '$after: String']; const argParts: string[] = ['first: $first', 'after: $after']; @@ -2679,17 +2758,39 @@ export class GitLabGraphQLClient { argParts.push('type: $type'); variables.type = typeFilter; } - if (params.groupPath && groupArgName) { - variableDefs.push(`$${groupArgName}: ID!`); - argParts.push(`${groupArgName}: $${groupArgName}`); - variables[groupArgName] = params.groupPath.trim(); + if (groupIdFilter) { + variableDefs.push('$groupId: [ID!]'); + argParts.push('groupId: $groupId'); + variables.groupId = groupIdFilter; + } + if (projectIdFilter) { + variableDefs.push('$projectId: [ID!]'); + argParts.push('projectId: $projectId'); + variables.projectId = projectIdFilter; + } + if (authorIdFilter) { + variableDefs.push('$authorId: [ID!]'); + argParts.push('authorId: $authorId'); + variables.authorId = authorIdFilter; + } + if (params.isSnoozed !== undefined && argNames.has('isSnoozed')) { + variableDefs.push('$isSnoozed: Boolean'); + argParts.push('isSnoozed: $isSnoozed'); + variables.isSnoozed = params.isSnoozed; + } else if (params.isSnoozed !== undefined) { + warnings.push('isSnoozed filter not supported by this GitLab; ignoring'); } - if (params.projectPath && projectArgName) { - variableDefs.push(`$${projectArgName}: ID!`); - argParts.push(`${projectArgName}: $${projectArgName}`); - variables[projectArgName] = params.projectPath.trim(); + if (sortFilter) { + variableDefs.push(`$sort: ${sortEnumName}`); + argParts.push('sort: $sort'); + variables.sort = sortFilter; } + const todoFields = this.schema.getType('Todo')?.getFields?.() || {}; + const targetUrlField = todoFields['targetUrl'] ? 'targetUrl' : ''; + const noteField = todoFields['note'] ? 'note { id body }' : ''; + const snoozedUntilField = todoFields['snoozedUntil'] ? 'snoozedUntil' : ''; + const query = gql` query listMyTodos(${variableDefs.join(', ')}) { currentUser { @@ -2702,13 +2803,18 @@ export class GitLabGraphQLClient { action targetType createdAt + ${targetUrlField} + ${snoozedUntilField} + ${noteField} author { username name } group { fullPath name } project { fullPath name } target { + __typename ... on Issue { id iid title webUrl state } ... on MergeRequest { id iid title webUrl state } ... on Epic { id iid title webUrl state } + ... on WorkItem { id iid title webUrl } } } } @@ -2763,24 +2869,57 @@ export class GitLabGraphQLClient { } /** - * Mark every pending todo for the authenticated user as done. Requires - * write capability. Idempotent: succeeds (with an empty updatedIds list) - * when nothing is pending. + * Mark the authenticated user's pending todos as done. Defaults to "every + * pending todo" but accepts optional scoping filters that match + * `TodosMarkAllDoneInput`: targetId, authorIds, groupPath, projectPath, + * action, type. Paths are resolved to node GIDs before the mutation runs. + * Returns the actual updated todos (id + state) — the schema does not + * expose an updatedIds field on the payload. */ async markAllTodosDone( + params: { + targetId?: string; + authorIds?: string[]; + groupPath?: string; + projectPath?: string; + action?: string; + type?: string; + } = {}, userConfig?: UserConfig - ): Promise<{ updatedIds: string[]; errors: string[] }> { + ): Promise<{ todos: Array<{ id: string; state: string }>; errors: string[] }> { + const input: Record = {}; + if (params.targetId) { + input.targetId = params.targetId.startsWith('gid://') ? params.targetId : params.targetId; + } + if (params.authorIds && params.authorIds.length > 0) { + input.authorId = params.authorIds.map(id => + id.startsWith('gid://') ? id : `gid://gitlab/User/${id}` + ); + } + if (params.groupPath) { + const gid = await this.resolveGroupGid(params.groupPath.trim(), userConfig); + if (!gid) throw new Error(`group not found: ${params.groupPath}`); + input.groupId = gid; + } + if (params.projectPath) { + const gid = await this.resolveProjectGid(params.projectPath.trim(), userConfig); + if (!gid) throw new Error(`project not found: ${params.projectPath}`); + input.projectId = gid; + } + if (params.action) input.action = [params.action.toUpperCase()]; + if (params.type) input.type = [params.type]; + const mutation = ` - mutation TodosMarkAllDone { - todosMarkAllDone(input: {}) { - updatedIds + mutation TodosMarkAllDone($input: TodosMarkAllDoneInput!) { + todosMarkAllDone(input: $input) { + todos { id state } errors } } `; - const result = await this.query<{ todosMarkAllDone: { updatedIds: string[]; errors: string[] } }>( + const result = await this.query<{ todosMarkAllDone: { todos: Array<{ id: string; state: string }>; errors: string[] } }>( mutation, - {}, + { input }, userConfig, true ); diff --git a/src/tools.ts b/src/tools.ts index 95f2930..ab35c96 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -1844,11 +1844,23 @@ const listMyTodosTool: Tool = { groupPath: z .string() .optional() - .describe('Limit to a group by full path (e.g. "my-org/platform")'), + .describe('Limit to a group by full path (e.g. "my-org/platform"). Resolved to a node GID server-side before filtering.'), projectPath: z .string() .optional() - .describe('Limit to a project by full path (e.g. "my-org/my-repo")'), + .describe('Limit to a project by full path (e.g. "my-org/my-repo"). Resolved to a node GID server-side before filtering.'), + authorIds: z + .array(z.string().min(1)) + .optional() + .describe('Filter by todo authors. Accepts numeric user IDs or full gid://gitlab/User/N strings.'), + isSnoozed: z + .boolean() + .optional() + .describe('Filter by snooze state: true → only snoozed, false → only non-snoozed.'), + sort: z + .string() + .optional() + .describe('Sort order — e.g. CREATED_DESC (default on server), CREATED_ASC, UPDATED_DESC, UPDATED_ASC.'), first: z.number().min(1).max(100).default(20).describe('Number of todos to retrieve'), after: z.string().optional().describe('Cursor for pagination'), fetchAll: z.boolean().default(false).describe('Fetch all pages up to 100 results'), @@ -1873,6 +1885,9 @@ const listMyTodosTool: Tool = { type: input.type, groupPath, projectPath, + authorIds: input.authorIds, + isSnoozed: input.isSnoozed, + sort: input.sort, first: input.first, after: input.after, fetchAll: input.fetchAll, @@ -1906,14 +1921,57 @@ const markAllTodosDoneTool: Tool = { name: 'mark_all_todos_done', title: 'Mark All Todos Done', description: - 'Mark every pending to-do item as done for the authenticated user. Irreversible without per-item restore_todo calls. Idempotent — succeeds even if there are zero pending todos.', + 'Mark pending to-do items as done for the authenticated user. With no arguments, marks every pending todo. Optional scoping args (groupPath, projectPath, action, type, authorIds, targetId) narrow which todos are marked. Irreversible without per-item restore_todo calls. Returns the actual list of updated todos.', requiresAuth: false, requiresWrite: true, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true }, - inputSchema: withUserAuth(z.object({})), + inputSchema: withUserAuth(z.object({ + groupPath: z + .string() + .optional() + .describe('Limit to a group by full path. Resolved to a node GID server-side.'), + projectPath: z + .string() + .optional() + .describe('Limit to a project by full path. Resolved to a node GID server-side.'), + action: z + .string() + .optional() + .describe('Limit to todos with this action (e.g. assigned, mentioned, review_requested).'), + type: z + .string() + .optional() + .describe('Limit to todos targeting this type (e.g. Issue, MergeRequest, Epic).'), + authorIds: z + .array(z.string().min(1)) + .optional() + .describe('Limit to todos authored by these users. Numeric IDs or gid://gitlab/User/N strings.'), + targetId: z + .string() + .optional() + .describe('Limit to todos for a single target (e.g. a specific issue/MR GID).'), + })), handler: async (input, client, userConfig) => { const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig; - return client.markAllTodosDone(credentials); + const groupPath = input.groupPath?.trim(); + const projectPath = input.projectPath?.trim(); + if (input.groupPath !== undefined && !groupPath) { + throw new Error('groupPath cannot be empty or whitespace'); + } + if (input.projectPath !== undefined && !projectPath) { + throw new Error('projectPath cannot be empty or whitespace'); + } + return client.markAllTodosDone( + { + groupPath, + projectPath, + action: input.action, + type: input.type, + authorIds: input.authorIds, + targetId: input.targetId, + }, + credentials, + ); }, };