diff --git a/README.md b/README.md index c1f9857d1a..c6e88f2d35 100644 --- a/README.md +++ b/README.md @@ -616,6 +616,8 @@ The following sets of tools are available: ## Tools +The issue metadata (`list_issue_types`, `list_issue_fields`), comment (`add_issue_comment`, `update_issue_comment`), dependency (`issue_dependency_read`, `issue_dependency_write`), and duplicate-detection (`find_duplicate`) tools provide typed `outputSchema` and `structuredContent` for protocol `2026-07-28` and newer. Older or unknown protocol versions retain the same text responses without these fields. Tool errors do not return structured content. Dependency and duplicate-detection tools retain their existing feature gates. +
diff --git a/pkg/github/__toolsnaps__/add_issue_comment.snap b/pkg/github/__toolsnaps__/add_issue_comment.snap index a68494d448..88f2862d3a 100644 --- a/pkg/github/__toolsnaps__/add_issue_comment.snap +++ b/pkg/github/__toolsnaps__/add_issue_comment.snap @@ -51,5 +51,74 @@ ], "type": "object" }, - "name": "add_issue_comment" + "name": "add_issue_comment", + "outputSchema": { + "oneOf": [ + { + "type": "null" + }, + { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "comment": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": "object" + }, + "reaction": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": "object" + } + }, + "required": [ + "comment", + "reaction" + ], + "type": "object" + } + ], + "type": [ + "object", + "null" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/add_issue_comment_typed.snap b/pkg/github/__toolsnaps__/add_issue_comment_typed.snap new file mode 100644 index 0000000000..a3f77a922f --- /dev/null +++ b/pkg/github/__toolsnaps__/add_issue_comment_typed.snap @@ -0,0 +1,138 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": false, + "title": "Add comment to issue or pull request" + }, + "description": "Add a comment and/or reaction to a specific issue or issue comment in a GitHub repository. Use this tool with pull requests as well (in this case pass pull request number as issue_number), but only if user is not asking specifically to add or react to review comments. At least one of body or reaction is required.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], + "inputSchema": { + "properties": { + "body": { + "description": "Comment content. Required unless reaction is provided.", + "minLength": 1, + "type": "string" + }, + "comment_id": { + "description": "The numeric ID of the issue or pull request comment to react to. Use this for reactions to comments; omit it to react to the issue or pull request itself. Cannot be combined with body.", + "minimum": 1, + "type": "integer" + }, + "issue_number": { + "description": "Issue or pull request number to comment on or react to.", + "type": "number" + }, + "owner": { + "description": "Repository owner", + "type": "string", + "x-mcp-header": "owner" + }, + "reaction": { + "description": "Emoji reaction to add. Required unless body is provided.", + "enum": [ + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "rocket", + "eyes" + ], + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string", + "x-mcp-header": "repo" + } + }, + "required": [ + "owner", + "repo", + "issue_number" + ], + "type": "object" + }, + "name": "add_issue_comment", + "outputSchema": { + "oneOf": [ + { + "type": "null" + }, + { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "comment": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": "object" + }, + "reaction": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": "object" + } + }, + "required": [ + "comment", + "reaction" + ], + "type": "object" + } + ], + "type": [ + "object", + "null" + ] + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/find_duplicate_ff_duplicate_detection.snap b/pkg/github/__toolsnaps__/find_duplicate_ff_duplicate_detection.snap index ac95fd4138..e57db3d3d3 100644 --- a/pkg/github/__toolsnaps__/find_duplicate_ff_duplicate_detection.snap +++ b/pkg/github/__toolsnaps__/find_duplicate_ff_duplicate_detection.snap @@ -21,13 +21,13 @@ }, "page": { "description": "Page number for pagination (min 1)", - "minimum": 1, + "minimum": 0, "type": "number" }, "perPage": { "description": "Results per page for pagination (min 1, max 100)", "maximum": 100, - "minimum": 1, + "minimum": 0, "type": "number" }, "repo": { diff --git a/pkg/github/__toolsnaps__/find_duplicate_typed.snap b/pkg/github/__toolsnaps__/find_duplicate_typed.snap new file mode 100644 index 0000000000..953b3722dc --- /dev/null +++ b/pkg/github/__toolsnaps__/find_duplicate_typed.snap @@ -0,0 +1,117 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "Find duplicate issues" + }, + "description": "Find likely duplicate issues for an existing issue in a GitHub repository. This is a read-only search scoped to the source issue's repository: it returns ranked candidate issues with a similarity score and confidence, and does not close, link, comment on, or otherwise modify any issue.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], + "inputSchema": { + "properties": { + "confidence_threshold": { + "description": "Minimum similarity threshold a candidate must meet to be returned; higher values are stricter. When omitted, the API's high-precision default is used. The scale is defined by the API, so no client-side bounds are enforced.", + "type": "number" + }, + "issue_number": { + "description": "The number of the existing issue to find duplicates for", + "type": "number" + }, + "owner": { + "description": "The owner of the repository", + "type": "string", + "x-mcp-header": "owner" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 0, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 0, + "type": "number" + }, + "repo": { + "description": "The name of the repository", + "type": "string", + "x-mcp-header": "repo" + } + }, + "required": [ + "owner", + "repo", + "issue_number" + ], + "type": "object" + }, + "name": "find_duplicate", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "confidence": { + "type": "string" + }, + "issue": { + "additionalProperties": false, + "properties": { + "number": { + "type": "integer" + }, + "repository": { + "type": "string" + }, + "state": { + "type": "string" + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "number", + "title", + "state", + "url" + ], + "type": "object" + }, + "likely_duplicate": { + "type": "boolean" + }, + "score": { + "type": [ + "null", + "number" + ] + } + }, + "required": [ + "issue", + "score", + "confidence", + "likely_duplicate" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/issue_dependency_read_typed.snap b/pkg/github/__toolsnaps__/issue_dependency_read_typed.snap new file mode 100644 index 0000000000..0f30eba680 --- /dev/null +++ b/pkg/github/__toolsnaps__/issue_dependency_read_typed.snap @@ -0,0 +1,124 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "Read issue dependencies" + }, + "description": "Read an issue's dependency relationships in a GitHub repository: the issues that block it (blocked_by) or the issues it blocks (blocking).", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], + "inputSchema": { + "properties": { + "issue_number": { + "description": "The number of the issue", + "type": "number" + }, + "method": { + "description": "The read operation to perform on a single issue's dependencies.\nOptions are:\n1. get_blocked_by - List the issues that block this issue (this issue is blocked by them).\n2. get_blocking - List the issues that this issue blocks.\n", + "enum": [ + "get_blocked_by", + "get_blocking" + ], + "type": "string" + }, + "owner": { + "description": "The owner of the repository", + "type": "string", + "x-mcp-header": "owner" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "The name of the repository", + "type": "string", + "x-mcp-header": "repo" + } + }, + "required": [ + "method", + "owner", + "repo", + "issue_number" + ], + "type": "object" + }, + "name": "issue_dependency_read", + "outputSchema": { + "additionalProperties": false, + "properties": { + "issues": { + "items": { + "additionalProperties": false, + "properties": { + "number": { + "type": "integer" + }, + "repository": { + "type": "string" + }, + "state": { + "type": "string" + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "number", + "title", + "state", + "url" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "pageInfo": { + "additionalProperties": false, + "properties": { + "hasNextPage": { + "type": "boolean" + }, + "nextPage": { + "type": "integer" + } + }, + "required": [ + "hasNextPage", + "nextPage" + ], + "type": "object" + } + }, + "required": [ + "issues", + "pageInfo" + ], + "type": "object" + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/issue_dependency_write_typed.snap b/pkg/github/__toolsnaps__/issue_dependency_write_typed.snap new file mode 100644 index 0000000000..c276777785 --- /dev/null +++ b/pkg/github/__toolsnaps__/issue_dependency_write_typed.snap @@ -0,0 +1,144 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": false, + "title": "Change issue dependency" + }, + "description": "Add or remove an issue dependency relationship in a GitHub repository. Use type 'blocked_by' to record that the subject issue is blocked by a related issue, or type 'blocking' to record that the subject issue blocks a related issue. The related issue defaults to the same repository as the subject unless related_owner/related_repo are provided.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], + "inputSchema": { + "properties": { + "issue_number": { + "description": "The number of the subject issue", + "type": "number" + }, + "method": { + "description": "The action to perform.\nOptions are:\n- 'add' - create the dependency relationship.\n- 'remove' - delete the dependency relationship.", + "enum": [ + "add", + "remove" + ], + "type": "string" + }, + "owner": { + "description": "The owner of the subject issue's repository", + "type": "string", + "x-mcp-header": "owner" + }, + "related_issue_number": { + "description": "The number of the related issue to link or unlink", + "type": "number" + }, + "related_owner": { + "description": "The owner of the related issue's repository. Defaults to 'owner' when omitted.", + "type": "string" + }, + "related_repo": { + "description": "The name of the related issue's repository. Defaults to 'repo' when omitted.", + "type": "string" + }, + "repo": { + "description": "The name of the subject issue's repository", + "type": "string", + "x-mcp-header": "repo" + }, + "type": { + "description": "The relationship direction relative to the subject issue.\nOptions are:\n- 'blocked_by' - the subject issue is blocked by the related issue.\n- 'blocking' - the subject issue blocks the related issue.", + "enum": [ + "blocked_by", + "blocking" + ], + "type": "string" + } + }, + "required": [ + "method", + "type", + "owner", + "repo", + "issue_number", + "related_issue_number" + ], + "type": "object" + }, + "name": "issue_dependency_write", + "outputSchema": { + "additionalProperties": false, + "properties": { + "blocked_issue": { + "additionalProperties": false, + "properties": { + "number": { + "type": "integer" + }, + "repository": { + "type": "string" + }, + "state": { + "type": "string" + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "number", + "title", + "state", + "url" + ], + "type": "object" + }, + "blocking_issue": { + "additionalProperties": false, + "properties": { + "number": { + "type": "integer" + }, + "repository": { + "type": "string" + }, + "state": { + "type": "string" + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "number", + "title", + "state", + "url" + ], + "type": "object" + }, + "message": { + "type": "string" + } + }, + "required": [ + "blocked_issue", + "blocking_issue", + "message" + ], + "type": "object" + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_issue_fields_typed.snap b/pkg/github/__toolsnaps__/list_issue_fields_typed.snap new file mode 100644 index 0000000000..da43522c6d --- /dev/null +++ b/pkg/github/__toolsnaps__/list_issue_fields_typed.snap @@ -0,0 +1,110 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "List issue fields" + }, + "description": "List issue fields for a repository or organization. Returns field definitions including name, type (text, number, date, single_select), and for single_select fields the list of valid option names. When repo is omitted, returns org-level fields directly.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], + "inputSchema": { + "properties": { + "owner": { + "description": "The account owner of the repository or organization. The name is not case sensitive.", + "type": "string", + "x-mcp-header": "owner" + }, + "repo": { + "description": "The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly.", + "type": "string", + "x-mcp-header": "repo" + } + }, + "required": [ + "owner" + ], + "type": "object" + }, + "name": "list_issue_fields", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "data_type": { + "type": "string" + }, + "description": { + "type": "string" + }, + "full_database_id": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "options": { + "items": { + "additionalProperties": false, + "properties": { + "color": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "priority": { + "type": [ + "null", + "integer" + ] + } + }, + "required": [ + "id", + "name", + "color" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "visibility": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "data_type", + "visibility" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_issue_types_typed.snap b/pkg/github/__toolsnaps__/list_issue_types_typed.snap new file mode 100644 index 0000000000..9d1b68b989 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_issue_types_typed.snap @@ -0,0 +1,102 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "List available issue types" + }, + "description": "List supported issue types for a repository or its owner organization. When repo is omitted, returns org-level issue types directly.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], + "inputSchema": { + "properties": { + "owner": { + "description": "The account owner of the repository or organization.", + "type": "string", + "x-mcp-header": "owner" + }, + "repo": { + "description": "The name of the repository. When provided, returns issue types for this specific repository. When omitted, returns org-level issue types directly.", + "type": "string", + "x-mcp-header": "repo" + } + }, + "required": [ + "owner" + ], + "type": "object" + }, + "name": "list_issue_types", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "color": { + "type": [ + "null", + "string" + ] + }, + "created_at": { + "type": [ + "null", + "string" + ] + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "integer" + ] + }, + "is_enabled": { + "type": [ + "null", + "boolean" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "node_id": { + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_issue_comment_typed.snap b/pkg/github/__toolsnaps__/update_issue_comment_typed.snap new file mode 100644 index 0000000000..258817d96b --- /dev/null +++ b/pkg/github/__toolsnaps__/update_issue_comment_typed.snap @@ -0,0 +1,68 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": false, + "title": "Update issue comment" + }, + "description": "Update the body of an existing issue or pull request conversation comment. This tool cannot update pull request review comments.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], + "inputSchema": { + "properties": { + "body": { + "description": "New comment content", + "minLength": 1, + "type": "string" + }, + "comment_id": { + "description": "The numeric ID of the issue or pull request conversation comment to update. Do not use a pull request review comment ID.", + "minimum": 1, + "type": "integer" + }, + "owner": { + "description": "Repository owner", + "type": "string", + "x-mcp-header": "owner" + }, + "repo": { + "description": "Repository name", + "type": "string", + "x-mcp-header": "repo" + } + }, + "required": [ + "owner", + "repo", + "comment_id", + "body" + ], + "type": "object" + }, + "name": "update_issue_comment", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": "object" + } +} \ No newline at end of file diff --git a/pkg/github/find_duplicate.go b/pkg/github/find_duplicate.go index 236f53a489..757d1ebe03 100644 --- a/pkg/github/find_duplicate.go +++ b/pkg/github/find_duplicate.go @@ -33,9 +33,9 @@ type rankedSimilarIssue struct { LikelyDuplicate bool `json:"likely_duplicate"` } -// duplicateCandidate is the trimmed output for a ranked duplicate candidate, +// DuplicateCandidate is the trimmed output for a ranked duplicate candidate, // carrying only what an agent needs to explain and act on it. -type duplicateCandidate struct { +type DuplicateCandidate struct { Issue MinimalIssueRef `json:"issue"` Score *float64 `json:"score"` Confidence string `json:"confidence"` @@ -71,8 +71,14 @@ func FindDuplicate(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "issue_number"}, } WithPagination(schema) + // This endpoint owns its defaults. Unlike issue_dependency_read, explicit + // zero pagination values must be forwarded, not replaced by SDK defaults. + for _, field := range []string{"page", "perPage"} { + schema.Properties[field].Default = nil + schema.Properties[field].Minimum = new(0.0) + } - st := NewTool( + st := NewTool[FindDuplicateInput, []DuplicateCandidate]( ToolsetMetadataIssues, mcp.Tool{ Name: "find_duplicate", @@ -84,41 +90,23 @@ func FindDuplicate(t translations.TranslationHelperFunc) inventory.ServerTool { InputSchema: schema, }, scopes.PublicRead(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - issueNumber, err := RequiredInt(args, "issue_number") - if err != nil { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input FindDuplicateInput) (*mcp.CallToolResult, []DuplicateCandidate, error) { + owner, repo, issueNumber := input.Owner, input.Repo, input.IssueNumber + if err := validateIssueCoordinate(owner, repo, issueNumber); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } // Build the query preserving whether each optional value was supplied // so unset parameters fall back to the API's own defaults. query := url.Values{} - if threshold, ok, err := OptionalParamOK[float64](args, "confidence_threshold"); err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } else if ok { - query.Set("threshold", strconv.FormatFloat(threshold, 'g', -1, 64)) + if input.ConfidenceThreshold != nil { + query.Set("threshold", strconv.FormatFloat(*input.ConfidenceThreshold, 'g', -1, 64)) } - if _, ok := args["perPage"]; ok { - perPage, err := OptionalIntParam(args, "perPage") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - query.Set("per_page", strconv.Itoa(perPage)) + if input.PerPage != nil { + query.Set("per_page", strconv.Itoa(*input.PerPage)) } - if _, ok := args["page"]; ok { - page, err := OptionalIntParam(args, "page") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - query.Set("page", strconv.Itoa(page)) + if input.Page != nil { + query.Set("page", strconv.Itoa(*input.Page)) } client, err := deps.GetClient(ctx) @@ -143,7 +131,7 @@ func FindDuplicate(t translations.TranslationHelperFunc) inventory.ServerTool { } defer func() { _ = resp.Body.Close() }() - candidates := make([]duplicateCandidate, 0, len(results)) + candidates := make([]DuplicateCandidate, 0, len(results)) for _, res := range results { // A bare issue (no ranking metadata) means ranked duplicate // detection is not enabled for this caller; fail clearly rather @@ -151,7 +139,7 @@ func FindDuplicate(t translations.TranslationHelperFunc) inventory.ServerTool { if res.Confidence == "" || res.Issue == nil { return utils.NewToolResultError("ranked duplicate detection is unavailable: the semantic-similarity endpoint returned issues without ranking metadata (the server-side duplicate-ranking feature is not enabled for this caller or repository)"), nil, nil } - candidates = append(candidates, duplicateCandidate{ + candidates = append(candidates, DuplicateCandidate{ // Candidates are always scoped to the requested repository, so the // ref's repository field is left empty as it was before. Issue: newMinimalIssueRef( @@ -176,8 +164,9 @@ func FindDuplicate(t translations.TranslationHelperFunc) inventory.ServerTool { // repository, so classify the result like issue_read. result := utils.NewToolResultText(string(r)) result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoUserContent) - return result, nil, nil - }) + return result, candidates, nil + }, normalizeIssueStrings([]string{"owner", "repo"}, nil), + normalizeIssueIntegers([]string{"issue_number"}, []string{"page", "perPage"}), normalizeDuplicateThreshold) st.FeatureRule = featureEnabledRule(FeatureFlagDuplicateDetection) return st } diff --git a/pkg/github/find_duplicate_test.go b/pkg/github/find_duplicate_test.go index f7299116ba..ebd9f0225d 100644 --- a/pkg/github/find_duplicate_test.go +++ b/pkg/github/find_duplicate_test.go @@ -104,7 +104,7 @@ func Test_FindDuplicate_RankedResults(t *testing.T) { assert.Equal(t, "1", capturedURL.Query().Get("page")) text := getTextResult(t, result) - var candidates []duplicateCandidate + var candidates []DuplicateCandidate require.NoError(t, json.Unmarshal([]byte(text.Text), &candidates)) require.Len(t, candidates, 2) @@ -162,7 +162,7 @@ func Test_FindDuplicate_SanitizesIssueTitle(t *testing.T) { require.False(t, result.IsError, "expected result to not be an error") text := getTextResult(t, result) - var candidates []duplicateCandidate + var candidates []DuplicateCandidate require.NoError(t, json.Unmarshal([]byte(text.Text), &candidates)) require.Len(t, candidates, 1) assert.Equal(t, sanitizedText, candidates[0].Issue.Title) @@ -218,7 +218,7 @@ func Test_FindDuplicate_EmptyResults(t *testing.T) { require.False(t, result.IsError, "empty results is a successful search") text := getTextResult(t, result) - var candidates []duplicateCandidate + var candidates []DuplicateCandidate require.NoError(t, json.Unmarshal([]byte(text.Text), &candidates)) assert.Empty(t, candidates) } diff --git a/pkg/github/issue_dependencies.go b/pkg/github/issue_dependencies.go index e553bab365..d69960273e 100644 --- a/pkg/github/issue_dependencies.go +++ b/pkg/github/issue_dependencies.go @@ -51,7 +51,7 @@ Options are: } WithPagination(schema) - st := NewTool( + st := NewTool[IssueDependencyReadInput, *IssueDependencyReadOutput]( ToolsetMetadataIssues, mcp.Tool{ Name: "issue_dependency_read", @@ -63,29 +63,22 @@ Options are: InputSchema: schema, }, scopes.PublicRead(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - method, err := RequiredParam[string](args, "method") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input IssueDependencyReadInput) (*mcp.CallToolResult, *IssueDependencyReadOutput, error) { + method, owner, repo, issueNumber := input.Method, input.Owner, input.Repo, input.IssueNumber + if method == "" { + return utils.NewToolResultError("missing required parameter: method"), nil, nil } - owner, err := RequiredParam[string](args, "owner") - if err != nil { + if err := validateIssueCoordinate(owner, repo, issueNumber); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + page, perPage := input.Page, input.PerPage + if page == 0 { + page = 1 } - issueNumber, err := RequiredInt(args, "issue_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if perPage == 0 { + perPage = 30 } - opts := &github.ListOptions{Page: pagination.Page, PerPage: pagination.PerPage} + opts := &github.ListOptions{Page: page, PerPage: perPage} client, err := deps.GetClient(ctx) if err != nil { @@ -94,58 +87,70 @@ Options are: switch method { case "get_blocked_by": - result, err := GetIssueBlockedBy(ctx, client, owner, repo, issueNumber, opts) - return result, nil, err + return getIssueBlockedByOutput(ctx, client, owner, repo, issueNumber, opts) case "get_blocking": - result, err := GetIssueBlocking(ctx, client, owner, repo, issueNumber, opts) - return result, nil, err + return getIssueBlockingOutput(ctx, client, owner, repo, issueNumber, opts) default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } - }) + }, normalizeIssueStrings([]string{"method", "owner", "repo"}, nil), + normalizeIssueIntegers([]string{"issue_number"}, []string{"page", "perPage"}), + normalizeTypedReadArguments(nil, false)) st.FeatureRule = featureEnabledRule(FeatureFlagIssueDependencies) return st } // GetIssueBlockedBy lists the issues that block the given issue. func GetIssueBlockedBy(ctx context.Context, client *github.Client, owner, repo string, issueNumber int, opts *github.ListOptions) (*mcp.CallToolResult, error) { + result, _, err := getIssueBlockedByOutput(ctx, client, owner, repo, issueNumber, opts) + return result, err +} + +func getIssueBlockedByOutput(ctx context.Context, client *github.Client, owner, repo string, issueNumber int, opts *github.ListOptions) (*mcp.CallToolResult, *IssueDependencyReadOutput, error) { issues, resp, err := client.Issues.ListBlockedBy(ctx, owner, repo, int64(issueNumber), opts) if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list blocked-by issues", resp, err), nil + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list blocked-by issues", resp, err), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list blocked-by issues", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list blocked-by issues", resp, body), nil, nil } - return dependencyReadResult(issues, resp), nil + output := dependencyReadOutput(issues, resp) + return MarshalledTextResult(output), output, nil } // GetIssueBlocking lists the issues that the given issue blocks. func GetIssueBlocking(ctx context.Context, client *github.Client, owner, repo string, issueNumber int, opts *github.ListOptions) (*mcp.CallToolResult, error) { + result, _, err := getIssueBlockingOutput(ctx, client, owner, repo, issueNumber, opts) + return result, err +} + +func getIssueBlockingOutput(ctx context.Context, client *github.Client, owner, repo string, issueNumber int, opts *github.ListOptions) (*mcp.CallToolResult, *IssueDependencyReadOutput, error) { issues, resp, err := client.Issues.ListBlocking(ctx, owner, repo, int64(issueNumber), opts) if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list blocking issues", resp, err), nil + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list blocking issues", resp, err), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list blocking issues", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list blocking issues", resp, body), nil, nil } - return dependencyReadResult(issues, resp), nil + output := dependencyReadOutput(issues, resp) + return MarshalledTextResult(output), output, nil } -// dependencyReadResult projects a list of related issues into the minimal +// dependencyReadOutput projects a list of related issues into the minimal // dependency shape and attaches page-based pagination info. -func dependencyReadResult(issues []*github.Issue, resp *github.Response) *mcp.CallToolResult { +func dependencyReadOutput(issues []*github.Issue, resp *github.Response) *IssueDependencyReadOutput { refs := make([]MinimalIssueRef, 0, len(issues)) for _, issue := range issues { if issue == nil { @@ -153,13 +158,13 @@ func dependencyReadResult(issues []*github.Issue, resp *github.Response) *mcp.Ca } refs = append(refs, issueToDependencyRef(issue)) } - return MarshalledTextResult(map[string]any{ - "issues": refs, - "pageInfo": map[string]any{ - "hasNextPage": resp.NextPage != 0, - "nextPage": resp.NextPage, + return &IssueDependencyReadOutput{ + Issues: refs, + PageInfo: IssueDependencyPageInfo{ + HasNextPage: resp.NextPage != 0, + NextPage: resp.NextPage, }, - }) + } } // issueToDependencyRef converts a REST issue into the compact reference used by @@ -189,7 +194,7 @@ func issueToDependencyRef(issue *github.Issue) MinimalIssueRef { // expressed as "the blocked issue is blocked_by the blocking issue", so both // directions are served by the same endpoint pair with the two issues swapped. func IssueDependencyWrite(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := NewTool[IssueDependencyWriteInput, *IssueDependencyWriteOutput]( ToolsetMetadataIssues, mcp.Tool{ Name: "issue_dependency_write", @@ -250,39 +255,23 @@ Options are: }, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - method, err := RequiredParam[string](args, "method") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input IssueDependencyWriteInput) (*mcp.CallToolResult, *IssueDependencyWriteOutput, error) { + method, relationshipType := input.Method, input.Type + if method == "" { + return utils.NewToolResultError("missing required parameter: method"), nil, nil } - relationshipType, err := RequiredParam[string](args, "type") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - issueNumber, err := RequiredInt(args, "issue_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if relationshipType == "" { + return utils.NewToolResultError("missing required parameter: type"), nil, nil } - relatedIssueNumber, err := RequiredInt(args, "related_issue_number") - if err != nil { + owner, repo, issueNumber := input.Owner, input.Repo, input.IssueNumber + if err := validateIssueCoordinate(owner, repo, issueNumber); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - relatedOwner, err := OptionalParam[string](args, "related_owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - relatedRepo, err := OptionalParam[string](args, "related_repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + relatedIssueNumber := input.RelatedIssueNumber + if relatedIssueNumber == 0 { + return utils.NewToolResultError("missing required parameter: related_issue_number"), nil, nil } + relatedOwner, relatedRepo := input.RelatedOwner, input.RelatedRepo if relatedOwner == "" { relatedOwner = owner } @@ -318,9 +307,9 @@ Options are: return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } - result, err := writeIssueDependency(ctx, client, method, blocked, blocking) - return result, nil, err - }) + return writeIssueDependency(ctx, client, method, blocked, blocking) + }, normalizeIssueStrings([]string{"method", "type", "owner", "repo"}, []string{"related_owner", "related_repo"}), + normalizeIssueIntegers([]string{"issue_number", "related_issue_number"}, nil), normalizeIssueDependencyWriteArguments) st.FeatureRule = featureEnabledRule(FeatureFlagIssueDependencies) return st } @@ -334,12 +323,12 @@ type issueCoordinate struct { // writeIssueDependency resolves the blocking issue to its global database ID and // then adds or removes the blocked-by relationship on the blocked issue. -func writeIssueDependency(ctx context.Context, client *github.Client, method string, blocked, blocking issueCoordinate) (*mcp.CallToolResult, error) { +func writeIssueDependency(ctx context.Context, client *github.Client, method string, blocked, blocking issueCoordinate) (*mcp.CallToolResult, *IssueDependencyWriteOutput, error) { // The REST API identifies the blocking issue by its global database ID // (not its number), so resolve the number to an ID first. blockingIssue, resp, err := client.Issues.Get(ctx, blocking.owner, blocking.repo, blocking.number) if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to resolve blocking issue", resp, err), nil + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to resolve blocking issue", resp, err), nil, nil } _ = resp.Body.Close() blockingID := blockingIssue.GetID() @@ -348,41 +337,43 @@ func writeIssueDependency(ctx context.Context, client *github.Client, method str case "add": blockedIssue, opResp, err := client.Issues.AddBlockedBy(ctx, blocked.owner, blocked.repo, int64(blocked.number), github.IssueDependencyRequest{IssueID: blockingID}) if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add issue dependency", opResp, err), nil + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add issue dependency", opResp, err), nil, nil } defer func() { _ = opResp.Body.Close() }() if opResp.StatusCode != http.StatusCreated { body, readErr := io.ReadAll(opResp.Body) if readErr != nil { - return nil, fmt.Errorf("failed to read response body: %w", readErr) + return nil, nil, fmt.Errorf("failed to read response body: %w", readErr) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to add issue dependency", opResp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to add issue dependency", opResp, body), nil, nil } - return dependencyWriteResult("dependency added", blockedIssue, blockingIssue, blocked, blocking), nil + output := dependencyWriteOutput("dependency added", blockedIssue, blockingIssue, blocked, blocking) + return MarshalledTextResult(output), output, nil case "remove": blockedIssue, opResp, err := client.Issues.RemoveBlockedBy(ctx, blocked.owner, blocked.repo, int64(blocked.number), blockingID) if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to remove issue dependency", opResp, err), nil + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to remove issue dependency", opResp, err), nil, nil } defer func() { _ = opResp.Body.Close() }() if opResp.StatusCode != http.StatusOK { body, readErr := io.ReadAll(opResp.Body) if readErr != nil { - return nil, fmt.Errorf("failed to read response body: %w", readErr) + return nil, nil, fmt.Errorf("failed to read response body: %w", readErr) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to remove issue dependency", opResp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to remove issue dependency", opResp, body), nil, nil } - return dependencyWriteResult("dependency removed", blockedIssue, blockingIssue, blocked, blocking), nil + output := dependencyWriteOutput("dependency removed", blockedIssue, blockingIssue, blocked, blocking) + return MarshalledTextResult(output), output, nil default: - return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } } -// dependencyWriteResult builds the minimal description of the affected issues. +// dependencyWriteOutput builds the minimal description of the affected issues. // The blocked issue comes from the mutation response and the blocking issue from // the earlier resolve; each falls back to its known coordinate when the API // response omits the repository URL. -func dependencyWriteResult(message string, blockedIssue, blockingIssue *github.Issue, blocked, blocking issueCoordinate) *mcp.CallToolResult { +func dependencyWriteOutput(message string, blockedIssue, blockingIssue *github.Issue, blocked, blocking issueCoordinate) *IssueDependencyWriteOutput { blockedRef := issueToDependencyRef(blockedIssue) if blockedRef.Repository == "" { blockedRef.Repository = blocked.owner + "/" + blocked.repo @@ -391,9 +382,7 @@ func dependencyWriteResult(message string, blockedIssue, blockingIssue *github.I if blockingRef.Repository == "" { blockingRef.Repository = blocking.owner + "/" + blocking.repo } - return MarshalledTextResult(map[string]any{ - "message": message, - "blocked_issue": blockedRef, - "blocking_issue": blockingRef, - }) + return &IssueDependencyWriteOutput{ + BlockedIssue: blockedRef, BlockingIssue: blockingRef, Message: message, + } } diff --git a/pkg/github/issue_fields.go b/pkg/github/issue_fields.go index 325fb548e2..60b1d78fbe 100644 --- a/pkg/github/issue_fields.go +++ b/pkg/github/issue_fields.go @@ -104,7 +104,7 @@ type issueFieldsOrgQuery struct { // ListIssueFields creates a tool to list issue field definitions for a repository or organization. func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := NewTool[IssueMetadataInput, []IssueField]( ToolsetMetadataIssues, mcp.Tool{ Name: "list_issue_fields", @@ -129,14 +129,10 @@ func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool }, }, repositoryOrOrganizationScopeAccess(), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := OptionalParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input IssueMetadataInput) (*mcp.CallToolResult, []IssueField, error) { + owner, repo := input.Owner, input.Repo + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } gqlClient, err := deps.GetGQLClient(ctx) @@ -164,8 +160,8 @@ func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool } else { result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoMetadata) } - return result, nil, nil - }) + return result, fields, nil + }, normalizeIssueStrings([]string{"owner"}, []string{"repo"})) return st } diff --git a/pkg/github/issue_tool_types.go b/pkg/github/issue_tool_types.go new file mode 100644 index 0000000000..074ece5a26 --- /dev/null +++ b/pkg/github/issue_tool_types.go @@ -0,0 +1,258 @@ +package github + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" +) + +type IssueMetadataInput struct { + Owner string `json:"owner"` + Repo string `json:"repo,omitempty"` +} + +type IssueTypeOutput struct { + ID *int64 `json:"id,omitempty"` + NodeID *string `json:"node_id,omitempty"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Color *string `json:"color,omitempty"` + IsEnabled *bool `json:"is_enabled,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +func issueTypeOutputs(types []*github.IssueType) []*IssueTypeOutput { + if types == nil { + return nil + } + output := make([]*IssueTypeOutput, len(types)) + for i, item := range types { + if item != nil { + output[i] = &IssueTypeOutput{ + ID: item.ID, NodeID: item.NodeID, Name: item.Name, + Description: item.Description, Color: item.Color, IsEnabled: item.IsEnabled, + CreatedAt: githubTimestampTime(item.CreatedAt), UpdatedAt: githubTimestampTime(item.UpdatedAt), + } + } + } + return output +} + +type AddIssueCommentInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + IssueNumber int `json:"issue_number"` + CommentID *int64 `json:"comment_id,omitempty"` + Body *string `json:"body,omitempty"` + Reaction *string `json:"reaction,omitempty"` +} + +type UpdateIssueCommentInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + CommentID int64 `json:"comment_id"` + Body *string `json:"body"` +} + +// A single mutation returns id/url; a combined comment and reaction returns +// both references. The explicit union schema excludes partial combinations. +type AddIssueCommentOutput struct { + Single *MinimalResponse + Combined *IssueCommentAndReactionOutput +} + +type IssueCommentAndReactionOutput struct { + Comment MinimalResponse `json:"comment"` + Reaction MinimalResponse `json:"reaction"` +} + +func (output AddIssueCommentOutput) MarshalJSON() ([]byte, error) { + if output.Combined != nil { + return json.Marshal(output.Combined) + } + // The SDK serializes a nil typed pointer as its element's zero value even + // on errors. Represent an absent union as null, never as a partial object. + return json.Marshal(output.Single) +} + +func addIssueCommentOutputSchema() *jsonschema.Schema { + ref := func() *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "id": {Type: "string"}, "url": {Type: "string"}, + }, + Required: []string{"id", "url"}, + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + } + } + return &jsonschema.Schema{ + Types: []string{"object", "null"}, + OneOf: []*jsonschema.Schema{ + {Type: "null"}, + ref(), + { + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "comment": ref(), "reaction": ref(), + }, + Required: []string{"comment", "reaction"}, + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + }, + }, + } +} + +type IssueDependencyReadInput struct { + Method string `json:"method"` + Owner string `json:"owner"` + Repo string `json:"repo"` + IssueNumber int `json:"issue_number"` + Page int `json:"page,omitempty"` + PerPage int `json:"perPage,omitempty"` +} + +type IssueDependencyPageInfo struct { + HasNextPage bool `json:"hasNextPage"` + NextPage int `json:"nextPage"` +} + +type IssueDependencyReadOutput struct { + Issues []MinimalIssueRef `json:"issues"` + PageInfo IssueDependencyPageInfo `json:"pageInfo"` +} + +type IssueDependencyWriteInput struct { + Method string `json:"method"` + Type string `json:"type"` + Owner string `json:"owner"` + Repo string `json:"repo"` + IssueNumber int `json:"issue_number"` + RelatedIssueNumber int `json:"related_issue_number"` + RelatedOwner string `json:"related_owner,omitempty"` + RelatedRepo string `json:"related_repo,omitempty"` +} + +type IssueDependencyWriteOutput struct { + BlockedIssue MinimalIssueRef `json:"blocked_issue"` + BlockingIssue MinimalIssueRef `json:"blocking_issue"` + Message string `json:"message"` +} + +type FindDuplicateInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + IssueNumber int `json:"issue_number"` + ConfidenceThreshold *float64 `json:"confidence_threshold,omitempty"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` +} + +func validateIssueCoordinate(owner, repo string, number int) error { + if owner == "" { + return fmt.Errorf("missing required parameter: owner") + } + if repo == "" { + return fmt.Errorf("missing required parameter: repo") + } + if number == 0 { + return fmt.Errorf("missing required parameter: issue_number") + } + return nil +} + +// Only the dependency writer historically accepted case-insensitive methods +// and directions. Do not broaden the dependency reader's method handling. +func normalizeIssueDependencyWriteArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]json.RawMessage + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + for _, field := range []string{"method", "type"} { + var value string + if err := json.Unmarshal(args[field], &value); err == nil { + args[field], _ = json.Marshal(strings.ToLower(value)) + } + } + return json.Marshal(args) +} + +func normalizeIssueCommentID(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + if value, exists := args["comment_id"]; exists { + id, err := toInt64(value) + if err != nil { + return nil, fmt.Errorf("parameter comment_id is not a valid number: %w", err) + } + args["comment_id"] = id + } + return json.Marshal(args) +} + +func normalizeIssueStrings(required, optional []string) inventory.InputNormalizer { + return func(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + for _, field := range required { + if _, err := RequiredParam[string](args, field); err != nil { + return nil, err + } + } + + for _, field := range optional { + if _, err := OptionalParam[string](args, field); err != nil { + return nil, err + } + } + return raw, nil + } +} + +func normalizeIssueIntegers(required, optional []string) inventory.InputNormalizer { + return func(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + for _, field := range required { + value, err := RequiredInt(args, field) + if err != nil { + return nil, err + } + args[field] = value + } + for _, field := range optional { + if _, exists := args[field]; !exists { + continue + } + value, err := OptionalIntParam(args, field) + if err != nil { + return nil, err + } + args[field] = value + } + return json.Marshal(args) + } +} + +func normalizeDuplicateThreshold(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + if _, err := OptionalParam[float64](args, "confidence_threshold"); err != nil { + return nil, err + } + return raw, nil +} diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 3adf308a3c..413174e25c 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1261,7 +1261,7 @@ func GetIssueLabels(ctx context.Context, client *githubv4.Client, owner string, // ListIssueTypes creates a tool to list defined issue types for an organization or repository. // This can be used to understand supported issue type values for creating or updating issues. func ListIssueTypes(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := NewTool[IssueMetadataInput, []*IssueTypeOutput]( ToolsetMetadataIssues, mcp.Tool{ Name: "list_issue_types", @@ -1286,14 +1286,10 @@ func ListIssueTypes(t translations.TranslationHelperFunc) inventory.ServerTool { }, }, repositoryOrOrganizationScopeAccess(), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := OptionalParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input IssueMetadataInput) (*mcp.CallToolResult, []*IssueTypeOutput, error) { + owner, repo := input.Owner, input.Repo + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } client, err := deps.GetClient(ctx) @@ -1329,7 +1325,7 @@ func ListIssueTypes(t translations.TranslationHelperFunc) inventory.ServerTool { result := utils.NewToolResultText(string(r)) result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoMetadata) - return result, nil, nil + return result, issueTypeOutputs(issueTypes), nil } issueTypes, resp, err := client.Organizations.ListIssueTypes(ctx, owner) @@ -1357,14 +1353,14 @@ func ListIssueTypes(t translations.TranslationHelperFunc) inventory.ServerTool { // than a single repo, so confidentiality is conservatively treated // as private (restricted to org members). result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelRepoMetadata(true)) - return result, nil, nil - }) + return result, issueTypeOutputs(issueTypes), nil + }, normalizeIssueStrings([]string{"owner"}, []string{"repo"})) return st } // AddIssueComment creates a tool to add a comment or reaction to an issue. func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[AddIssueCommentInput, *AddIssueCommentOutput]( ToolsetMetadataIssues, mcp.Tool{ Name: "add_issue_comment", @@ -1406,40 +1402,29 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool }, Required: []string{"owner", "repo", "issue_number"}, }, + OutputSchema: addIssueCommentOutputSchema(), }, publicRepositoryWriteScopeAccess(), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - issueNumber, err := RequiredInt(args, "issue_number") - if err != nil { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input AddIssueCommentInput) (*mcp.CallToolResult, *AddIssueCommentOutput, error) { + owner, repo, issueNumber := input.Owner, input.Repo, input.IssueNumber + if err := validateIssueCoordinate(owner, repo, issueNumber); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } var commentID int64 - hasCommentID := false - if value, ok := args["comment_id"]; ok { - commentID, err = toInt64(value) - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("parameter comment_id is not a valid number: %v", err)), nil, nil - } + hasCommentID := input.CommentID != nil + if hasCommentID { + commentID = *input.CommentID if commentID < 1 { return utils.NewToolResultError("comment_id must be greater than 0"), nil, nil } - hasCommentID = true } - body, hasBody, err := OptionalParamOK[string](args, "body") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + hasBody, hasReaction := input.Body != nil, input.Reaction != nil + var body, reactionContent string + if hasBody { + body = *input.Body } - reactionContent, hasReaction, err := OptionalParamOK[string](args, "reaction") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if hasReaction { + reactionContent = *input.Reaction } if hasCommentID && hasBody { return utils.NewToolResultError("comment_id cannot be combined with body"), nil, nil @@ -1531,17 +1516,14 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool } } - var result any + result := &AddIssueCommentOutput{} switch { case hasBody && hasReaction: - result = map[string]MinimalResponse{ - "comment": *commentResponse, - "reaction": *reactionResponse, - } + result.Combined = &IssueCommentAndReactionOutput{Comment: *commentResponse, Reaction: *reactionResponse} case hasReaction: - result = reactionResponse + result.Single = reactionResponse default: - result = commentResponse + result.Single = commentResponse } r, err := json.Marshal(result) @@ -1549,13 +1531,14 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil - }) + return utils.NewToolResultText(string(r)), result, nil + }, normalizeIssueStrings([]string{"owner", "repo"}, []string{"body", "reaction"}), + normalizeIssueIntegers([]string{"issue_number"}, nil), normalizeIssueCommentID) } // UpdateIssueComment creates a tool to update an issue or pull request conversation comment. func UpdateIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[UpdateIssueCommentInput, *MinimalResponse]( ToolsetMetadataIssues, mcp.Tool{ Name: "update_issue_comment", @@ -1590,29 +1573,24 @@ func UpdateIssueComment(t translations.TranslationHelperFunc) inventory.ServerTo }, }, publicRepositoryWriteScopeAccess(), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input UpdateIssueCommentInput) (*mcp.CallToolResult, *MinimalResponse, error) { + owner, repo, commentID := input.Owner, input.Repo, input.CommentID + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - commentID, err := RequiredBigInt(args, "comment_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if commentID == 0 { + return utils.NewToolResultError("missing required parameter: comment_id"), nil, nil } if commentID < 1 { return utils.NewToolResultError("comment_id must be greater than 0"), nil, nil } - body, hasBody, err := OptionalParamOK[string](args, "body") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - if !hasBody { + if input.Body == nil { return utils.NewToolResultError("missing required parameter: body"), nil, nil } + body := *input.Body if body == "" { return utils.NewToolResultError("body cannot be empty when provided"), nil, nil } @@ -1632,16 +1610,17 @@ func UpdateIssueComment(t translations.TranslationHelperFunc) inventory.ServerTo return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update issue comment", resp, err), nil, nil } - r, err := json.Marshal(MinimalResponse{ + output := &MinimalResponse{ ID: fmt.Sprintf("%d", updatedComment.GetID()), URL: updatedComment.GetHTMLURL(), - }) + } + r, err := json.Marshal(output) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil - }) + return utils.NewToolResultText(string(r)), output, nil + }, normalizeIssueStrings([]string{"owner", "repo"}, []string{"body"}), normalizeIssueCommentID) } func isValidIssueReaction(reaction string) bool { diff --git a/pkg/github/typed_issue_outputs_test.go b/pkg/github/typed_issue_outputs_test.go new file mode 100644 index 0000000000..f208178f91 --- /dev/null +++ b/pkg/github/typed_issue_outputs_test.go @@ -0,0 +1,254 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func typedIssueDeps(t *testing.T) BaseDeps { + t.Helper() + relatedIssue := &github.Issue{ + ID: new(int64(7)), Number: new(7), Title: new("Blocker"), + State: new("open"), HTMLURL: new("https://github.com/owner/repo/issues/7"), + RepositoryURL: new("https://api.github.com/repos/owner/repo"), + } + subject := &github.Issue{ + Number: new(123), Title: new("Subject"), State: new("open"), + HTMLURL: new("https://github.com/owner/repo/issues/123"), + } + types := []*github.IssueType{{ID: new(int64(1)), Name: new("Bug"), IsEnabled: new(false)}} + comment := &github.IssueComment{ + ID: new(int64(42)), HTMLURL: new("https://github.com/owner/repo/issues/123#issuecomment-42"), + IssueURL: new("https://api.github.com/repos/owner/repo/issues/123"), + } + handlers := map[string]http.HandlerFunc{ + "GET /orgs/{owner}/issue-types": mockResponse(t, http.StatusOK, types), + "GET /repos/{owner}/{repo}/issue-types": mockResponse(t, http.StatusOK, types), + string(endpointGetIssue): mockResponse(t, http.StatusOK, relatedIssue), + string(endpointAddBlock): mockResponse(t, http.StatusCreated, subject), + string(endpointRemoveBlk): mockResponse(t, http.StatusOK, subject), + string(endpointBlockedBy): mockResponse(t, http.StatusOK, []*github.Issue{relatedIssue}), + string(endpointBlocking): mockResponse(t, http.StatusOK, []*github.Issue{}), + "POST /repos/{owner}/{repo}/issues/{issue_number}/comments": mockResponse(t, http.StatusCreated, comment), + "PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}": mockResponse(t, http.StatusOK, comment), + "GET /repos/{owner}/{repo}/issues/comments/{comment_id}": mockResponse(t, http.StatusOK, comment), + "POST /repos/{owner}/{repo}/issues/{issue_number}/reactions": mockResponse(t, http.StatusCreated, &github.Reaction{ID: new(int64(9))}), + "POST /repos/{owner}/{repo}/issues/comments/{comment_id}/reactions": mockResponse(t, http.StatusCreated, &github.Reaction{ID: new(int64(9))}), + "GET /repos/{owner}/{repo}/issues/{issue_number}/semantically_similar": func(w http.ResponseWriter, r *http.Request) { + if r.URL.RawQuery != "" { + assert.Equal(t, "0", r.URL.Query().Get("threshold")) + assert.Equal(t, "0", r.URL.Query().Get("page")) + assert.Equal(t, "0", r.URL.Query().Get("per_page")) + } + _, _ = w.Write([]byte(`[{"issue":{"number":7,"title":"Candidate","state":"open","html_url":"https://github.com/owner/repo/issues/7"},"score":null,"confidence":"high","likely_duplicate":true}]`)) + }, + } + gql := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + Query string `json:"query"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + container := "repository" + if strings.Contains(request.Query, "organization(") { + container = "organization" + } + _, _ = w.Write([]byte(`{"data":{"` + container + `":{"issueFields":{"nodes":[{"__typename":"IssueFieldSingleSelect","id":"IF_1","fullDatabaseId":"99","name":"Priority","description":"Importance","dataType":"SINGLE_SELECT","visibility":"ALL","options":[{"id":"OPT_1","name":"High","description":"","color":"red","priority":0}]}]}}}}`)) + })}} + return BaseDeps{ + Client: mustNewGHClient(t, MockHTTPClientWithHandlers(handlers)), GQLClient: githubv4.NewClient(gql), + } +} + +func TestTypedIssueOutputs(t *testing.T) { + translate := translations.NullTranslationHelper + tools := []inventory.ServerTool{ + ListIssueTypes(translate), ListIssueFields(translate), AddIssueComment(translate), + UpdateIssueComment(translate), IssueDependencyRead(translate), IssueDependencyWrite(translate), FindDuplicate(translate), + } + // These tools remain feature-gated in production. Enable their existing + // feature rules in the contract harness rather than clearing the rules. + inv, err := inventory.NewBuilder().SetTools(tools).WithToolsets([]string{"all"}). + WithFeatureChecker(func(context.Context, string) (bool, error) { return true, nil }).Build() + require.NoError(t, err) + + const commentText = `{"id":"42","url":"https://github.com/owner/repo/issues/123#issuecomment-42"}` + const reactionText = `{"id":"9","url":"https://api.github.com/repos/owner/repo/issues/123/reactions/9"}` + const blockedText = `{"issues":[{"number":7,"title":"Blocker","state":"OPEN","url":"https://github.com/owner/repo/issues/7","repository":"owner/repo"}],"pageInfo":{"hasNextPage":false,"nextPage":0}}` + const dependencyRefs = `"blocked_issue":{"number":123,"title":"Subject","state":"OPEN","url":"https://github.com/owner/repo/issues/123","repository":"owner/repo"},"blocking_issue":{"number":7,"title":"Blocker","state":"OPEN","url":"https://github.com/owner/repo/issues/7","repository":"owner/repo"}` + calls := []struct { + name string + args map[string]any + text string + }{ + {"list_issue_types", map[string]any{"owner": "owner"}, `[{"id":1,"name":"Bug","is_enabled":false}]`}, + {"list_issue_types", map[string]any{"owner": "owner", "repo": "repo"}, `[{"id":1,"name":"Bug","is_enabled":false}]`}, + {"list_issue_fields", map[string]any{"owner": "owner"}, `[{"id":"IF_1","full_database_id":99,"name":"Priority","description":"Importance","data_type":"SINGLE_SELECT","visibility":"ALL","options":[{"id":"OPT_1","name":"High","color":"red","priority":0}]}]`}, + {"list_issue_fields", map[string]any{"owner": "owner", "repo": "repo"}, `[{"id":"IF_1","full_database_id":99,"name":"Priority","description":"Importance","data_type":"SINGLE_SELECT","visibility":"ALL","options":[{"id":"OPT_1","name":"High","color":"red","priority":0}]}]`}, + {"add_issue_comment", map[string]any{"owner": "owner", "repo": "repo", "issue_number": "123.0", "body": "Hello"}, commentText}, + {"add_issue_comment", map[string]any{"owner": "owner", "repo": "repo", "issue_number": "123", "reaction": "heart"}, reactionText}, + {"add_issue_comment", map[string]any{"owner": "owner", "repo": "repo", "issue_number": "123", "body": "Hello", "reaction": "heart"}, `{"comment":` + commentText + `,"reaction":` + reactionText + `}`}, + {"add_issue_comment", map[string]any{"owner": "owner", "repo": "repo", "issue_number": "123", "comment_id": "42.0", "reaction": "heart"}, `{"id":"9","url":"https://api.github.com/repos/owner/repo/issues/comments/42/reactions/9"}`}, + {"update_issue_comment", map[string]any{"owner": "owner", "repo": "repo", "comment_id": "42.0", "body": "Changed"}, commentText}, + {"issue_dependency_read", map[string]any{"method": "get_blocked_by", "owner": "owner", "repo": "repo", "issue_number": "123", "page": "0", "perPage": "0"}, blockedText}, + {"issue_dependency_read", map[string]any{"method": "get_blocking", "owner": "owner", "repo": "repo", "issue_number": "123"}, `{"issues":[],"pageInfo":{"hasNextPage":false,"nextPage":0}}`}, + {"issue_dependency_write", map[string]any{"method": "ADD", "type": "BLOCKED_BY", "owner": "owner", "repo": "repo", "issue_number": "123", "related_issue_number": "7"}, `{` + dependencyRefs + `,"message":"dependency added"}`}, + {"issue_dependency_write", map[string]any{"method": "REMOVE", "type": "BLOCKED_BY", "owner": "owner", "repo": "repo", "issue_number": "123", "related_issue_number": "7"}, `{` + dependencyRefs + `,"message":"dependency removed"}`}, + {"find_duplicate", map[string]any{"owner": "owner", "repo": "repo", "issue_number": "123", "confidence_threshold": 0, "page": "0", "perPage": "0"}, `[{"issue":{"number":7,"title":"Candidate","state":"open","url":"https://github.com/owner/repo/issues/7"},"score":null,"confidence":"high","likely_duplicate":true}]`}, + {"find_duplicate", map[string]any{"owner": "owner", "repo": "repo", "issue_number": "123"}, `[{"issue":{"number":7,"title":"Candidate","state":"open","url":"https://github.com/owner/repo/issues/7"},"score":null,"confidence":"high","likely_duplicate":true}]`}, + } + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip, ""} { + name := protocol + if name == "" { + name = "unknown" + } + t.Run(name, func(t *testing.T) { + deps := typedIssueDeps(t) + server := mcp.NewServer(&mcp.Implementation{Name: "typed-issue-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + if protocol == "" { + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + switch request := req.(type) { + case *mcp.ListToolsRequest: + request.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + case *mcp.CallToolRequest: + request.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + } + return next(ctx, method, req) + } + }) + } + version := protocol + if version == "" { + version = inventory.ProtocolVersionMultiRoundTrip + } + session := connectCommentVisibilityClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, len(tools)) + schemas := make(map[string]*jsonschema.Resolved) + for _, tool := range list.Tools { + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Nil(t, tool.OutputSchema, tool.Name) + continue + } + require.NotNil(t, tool.OutputSchema, tool.Name) + require.NoError(t, toolsnaps.Test(tool.Name+"_typed", *tool)) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, tool.OutputSchema)), &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + schemas[tool.Name] = resolved + if tool.Name == "add_issue_comment" { + for _, invalid := range []string{`{}`, `{"id":"1"}`, `{"comment":{"id":"1","url":"url"}}`, `{"id":"1","url":"url","reaction":{"id":"2","url":"url"}}`} { + var output any + require.NoError(t, json.Unmarshal([]byte(invalid), &output)) + require.Error(t, resolved.Validate(output), "partial comment unions must be rejected") + } + } + } + + for _, call := range calls { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: call.name, Arguments: call.args}) + require.NoError(t, err, call.name) + require.False(t, result.IsError, "%s: %s", call.name, result) + require.Len(t, result.Content, 1, call.name) + assert.Equal(t, call.text, getTextResult(t, result).Text, call.name) + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Nil(t, result.StructuredContent, call.name) + continue + } + require.NotNil(t, result.StructuredContent, call.name) + structured := mustMarshalJSON(t, result.StructuredContent) + assert.JSONEq(t, call.text, structured) + var output any + require.NoError(t, json.Unmarshal([]byte(structured), &output)) + require.NoError(t, schemas[call.name].Validate(output), call.name) + } + errors := []struct { + name string + args map[string]any + text string + }{ + {"list_issue_types", map[string]any{}, "missing required parameter: owner"}, + {"list_issue_fields", map[string]any{"owner": "owner", "repo": nil}, "parameter repo is not of type string"}, + {"add_issue_comment", map[string]any{"owner": "owner", "repo": "repo", "issue_number": 123}, "at least one of body or reaction is required"}, + {"add_issue_comment", map[string]any{"owner": "owner", "repo": "repo", "issue_number": 123, "body": "Hello", "comment_id": 42}, "comment_id cannot be combined with body"}, + {"update_issue_comment", map[string]any{"owner": "owner", "repo": "repo", "comment_id": "1.5", "body": "Hello"}, "non-integer numeric value"}, + {"issue_dependency_read", map[string]any{"method": "GET_BLOCKING", "owner": "owner", "repo": "repo", "issue_number": 123}, "get_blocking"}, + {"issue_dependency_write", map[string]any{"method": "add", "type": "blocked_by", "owner": "owner", "repo": "repo", "issue_number": 123, "related_issue_number": 123}, "an issue cannot block or depend on itself"}, + {"find_duplicate", map[string]any{"owner": "owner", "repo": "repo", "issue_number": "1.5"}, "not a valid number"}, + {"find_duplicate", map[string]any{"owner": "owner", "repo": "repo", "issue_number": 123, "confidence_threshold": nil}, "parameter confidence_threshold is not of type float64"}, + } + for _, call := range errors { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: call.name, Arguments: call.args}) + require.NoError(t, err, call.name) + require.True(t, result.IsError, call.name) + assert.Contains(t, getErrorResult(t, result).Text, call.text) + assert.Nil(t, result.StructuredContent, call.name) + } + }) + } +} + +func TestTypedIssueAPIErrors(t *testing.T) { + translate := translations.NullTranslationHelper + cases := []struct { + tool inventory.ServerTool + args map[string]any + text string + }{ + {ListIssueTypes(translate), map[string]any{"owner": "owner"}, "failed to list issue types"}, + {ListIssueFields(translate), map[string]any{"owner": "owner"}, "failed to list issue fields"}, + {AddIssueComment(translate), map[string]any{"owner": "owner", "repo": "repo", "issue_number": 123, "body": "Hello"}, "failed to create comment"}, + {UpdateIssueComment(translate), map[string]any{"owner": "owner", "repo": "repo", "comment_id": 42, "body": "Hello"}, "failed to update issue comment"}, + {IssueDependencyRead(translate), map[string]any{"method": "get_blocked_by", "owner": "owner", "repo": "repo", "issue_number": 123}, "failed to list blocked-by issues"}, + {IssueDependencyWrite(translate), map[string]any{"method": "add", "type": "blocked_by", "owner": "owner", "repo": "repo", "issue_number": 123, "related_issue_number": 7}, "failed to resolve blocking issue"}, + {FindDuplicate(translate), map[string]any{"owner": "owner", "repo": "repo", "issue_number": 123}, "failed to find duplicate issues"}, + } + for _, tc := range cases { + t.Run(tc.tool.Tool.Name, func(t *testing.T) { + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"Forbidden"}`)) + })}} + deps := BaseDeps{Client: mustNewGHClient(t, client), GQLClient: githubv4.NewClient(client)} + inv, err := inventory.NewBuilder().SetTools([]inventory.ServerTool{tc.tool}).WithToolsets([]string{"all"}). + WithFeatureChecker(func(context.Context, string) (bool, error) { return true, nil }).Build() + require.NoError(t, err) + var legacyText string + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + server := mcp.NewServer(&mcp.Implementation{Name: "typed-issue-errors", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + session := connectCommentVisibilityClient(t, server, protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool.Tool.Name, Arguments: tc.args}) + require.NoError(t, err) + require.True(t, result.IsError) + require.Len(t, result.Content, 1) + text := getErrorResult(t, result).Text + assert.Contains(t, text, tc.text) + assert.Nil(t, result.StructuredContent) + if protocol == "2025-11-25" { + legacyText = text + } else { + assert.Equal(t, legacyText, text, "API errors must be byte-exact across protocols") + } + } + }) + } +}