diff --git a/README.md b/README.md index 254cc23d02..af75ef1c29 100644 --- a/README.md +++ b/README.md @@ -616,13 +616,15 @@ The following sets of tools are available: ## Tools -Repository tools expose output schemas and typed `structuredContent` to clients using MCP protocol `2026-07-28` or later. Older clients, or clients with an unknown protocol version, receive the existing content without an output schema or structured output. +Repository tools expose output schemas and typed `structuredContent` when the negotiated MCP protocol version is a supported version `2026-07-28` or newer. Older clients, clients with an unknown protocol version, and clients omitting the version receive the existing content without an output schema or structured output. Unknown versions are not treated as supported merely because they sort after `2026-07-28`. For `get_file_contents`, directory results (including field projections) are structured arrays. File downloads and metadata-only responses use a `content` array of typed text, embedded-resource, or resource-link blocks, preserving status messages, text, base64 blobs, empty files, and download links. Text-only repository mutations such as starring and deletion also return a structured `message`; fork responses distinguish a repository reference from an in-progress message. Repository deletion still requires its existing confirmation flow before returning a completed result. Structured repository results use compact DTOs rather than raw REST objects. Release lookups share the compact release shape with release listings; Git references retain the ref, object type, and SHA; annotated tags retain tag/message/tagger/target information; deletion commits retain SHA, message, authors, tree SHA, parent SHAs, and a human-facing `html_url` when available. Directory entries retain file metadata and useful HTML/download links, but omit API and hypermedia routing URLs even when those fields were requested in a projection. Legacy and unknown protocol versions retain the original text byte-for-byte; modern text-only JSON responses serialize the same compact DTO as `structuredContent`. Non-text resource blocks and tool errors retain their existing content behavior. Structured projections use the same sanitized and filtered source as the legacy formatter. -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. +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 negotiated supported protocol versions `2026-07-28` or 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. + +For negotiated supported protocol versions `2026-07-28` or newer, the consolidated Actions tools (`actions_list`, `actions_get`, `actions_run_trigger`) return structured objects with a `method` discriminant and a method-specific typed field; JSON text contains the same compact projection. Workflow, job, and artifact projections omit API/hypermedia URLs while retaining browser links and useful identifiers, states, timestamps, and usage durations. Run usage lists billable runner environments in deterministic name order. Older and unknown-protocol text responses remain unchanged; `get_job_logs` retains its typed content, URL, or failed-job collection variants.
diff --git a/docs/error-handling.md b/docs/error-handling.md index 7f62443483..d483ebdec2 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -60,10 +60,12 @@ return ghErrors.NewGitHubGraphQLErrorResponse(ctx, message, err), nil ### Typed Tool Results Tools registered with concrete `NewTool[In, Out]` types expose output schemas and -`structuredContent` only when the negotiated protocol is `2026-07-28` or newer. -Older or unknown protocols retain the original text responses without these -typed-output additions. Ordinary API and validation errors never expose the -SDK-generated zero-value structured output. +`structuredContent` when the negotiated protocol version is a supported version +`2026-07-28` or newer. Older, unknown, or omitted protocol versions retain the +original text responses without these typed-output additions. Unknown versions +are not treated as supported merely because they sort after `2026-07-28`. +Ordinary API and validation errors never expose the SDK-generated zero-value +structured output. Handler-provided structured statuses are distinct from generated error output. For example, `issue_write` can return `IsError: true` with diff --git a/pkg/github/__toolsnaps__/actions_get.snap b/pkg/github/__toolsnaps__/actions_get.snap index 661f379f5f..404931f389 100644 --- a/pkg/github/__toolsnaps__/actions_get.snap +++ b/pkg/github/__toolsnaps__/actions_get.snap @@ -40,5 +40,708 @@ ], "type": "object" }, - "name": "actions_get" + "name": "actions_get", + "outputSchema": { + "additionalProperties": false, + "properties": { + "artifact": { + "additionalProperties": false, + "properties": { + "artifact_id": { + "type": "integer" + }, + "download_url": { + "type": "string" + }, + "message": { + "type": "string" + }, + "note": { + "type": "string" + } + }, + "required": [ + "artifact_id", + "download_url", + "message", + "note" + ], + "type": [ + "null", + "object" + ] + }, + "logs": { + "additionalProperties": false, + "properties": { + "logs_url": { + "type": "string" + }, + "message": { + "type": "string" + }, + "note": { + "type": "string" + }, + "optimization_tip": { + "type": "string" + }, + "warning": { + "type": "string" + } + }, + "required": [ + "logs_url", + "message", + "note", + "optimization_tip", + "warning" + ], + "type": [ + "null", + "object" + ] + }, + "method": { + "description": "Operation that produced this response.", + "enum": [ + "get_workflow", + "get_workflow_run", + "get_workflow_job", + "download_workflow_run_artifact", + "get_workflow_run_usage", + "get_workflow_run_logs_url" + ], + "type": "string" + }, + "usage": { + "additionalProperties": false, + "properties": { + "billable": { + "items": { + "additionalProperties": false, + "properties": { + "job_runs": { + "items": { + "additionalProperties": false, + "properties": { + "duration_ms": { + "description": "Duration in milliseconds.", + "type": [ + "null", + "integer" + ] + }, + "job_id": { + "type": [ + "null", + "integer" + ] + } + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "jobs": { + "type": [ + "null", + "integer" + ] + }, + "runner": { + "description": "Runner environment name; custom environments are supported.", + "type": "string" + }, + "total_ms": { + "description": "Duration in milliseconds.", + "type": [ + "null", + "integer" + ] + } + }, + "required": [ + "runner" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "run_duration_ms": { + "description": "Duration in milliseconds.", + "type": [ + "null", + "integer" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "workflow": { + "additionalProperties": false, + "properties": { + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "html_url": { + "description": "Browser URL.", + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "integer" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "path": { + "type": [ + "null", + "string" + ] + }, + "state": { + "description": "Workflow state, e.g. active, deleted, disabled_fork, disabled_inactivity, disabled_manually.", + "enum": [ + "active", + "deleted", + "disabled_fork", + "disabled_inactivity", + "disabled_manually" + ], + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + }, + "workflow_job": { + "additionalProperties": false, + "properties": { + "completed_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "conclusion": { + "description": "Completion outcome, e.g. success, failure, cancelled, skipped, timed_out, action_required.", + "enum": [ + "success", + "failure", + "neutral", + "cancelled", + "skipped", + "timed_out", + "action_required", + "stale", + "startup_failure" + ], + "type": "string" + }, + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "head_branch": { + "type": "string" + }, + "head_sha": { + "type": "string" + }, + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "integer" + }, + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "name": { + "type": "string" + }, + "run_attempt": { + "type": "integer" + }, + "run_id": { + "type": "integer" + }, + "runner_group_id": { + "type": "integer" + }, + "runner_group_name": { + "type": "string" + }, + "runner_id": { + "type": "integer" + }, + "runner_name": { + "type": "string" + }, + "started_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "status": { + "description": "Lifecycle status, e.g. queued, in_progress, completed, waiting.", + "enum": [ + "queued", + "in_progress", + "completed", + "requested", + "waiting", + "pending", + "" + ], + "type": "string" + }, + "steps": { + "items": { + "additionalProperties": false, + "properties": { + "completed_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "conclusion": { + "description": "Completion outcome, e.g. success, failure, cancelled, skipped, timed_out, action_required.", + "enum": [ + "success", + "failure", + "neutral", + "cancelled", + "skipped", + "timed_out", + "action_required", + "stale", + "startup_failure" + ], + "type": "string" + }, + "name": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "started_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "status": { + "description": "Lifecycle status, e.g. queued, in_progress, completed, waiting.", + "enum": [ + "queued", + "in_progress", + "completed", + "requested", + "waiting", + "pending", + "" + ], + "type": "string" + } + }, + "required": [ + "name", + "status", + "number" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "workflow_name": { + "type": "string" + } + }, + "required": [ + "id", + "run_id", + "name", + "status" + ], + "type": [ + "null", + "object" + ] + }, + "workflow_run": { + "additionalProperties": false, + "properties": { + "actor": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "conclusion": { + "description": "Completion outcome, e.g. success, failure, cancelled, skipped, timed_out, action_required.", + "enum": [ + "success", + "failure", + "neutral", + "cancelled", + "skipped", + "timed_out", + "action_required", + "stale", + "startup_failure" + ], + "type": "string" + }, + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "display_title": { + "type": "string" + }, + "event": { + "type": "string" + }, + "head_branch": { + "type": "string" + }, + "head_commit": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + }, + "head_sha": { + "type": "string" + }, + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "pull_requests": { + "items": { + "type": "integer" + }, + "type": [ + "null", + "array" + ] + }, + "referenced_workflows": { + "items": { + "additionalProperties": false, + "properties": { + "path": { + "type": "string" + }, + "ref": { + "type": "string" + }, + "sha": { + "type": "string" + } + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "run_attempt": { + "type": "integer" + }, + "run_number": { + "type": "integer" + }, + "run_started_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "status": { + "description": "Lifecycle status, e.g. queued, in_progress, completed, waiting.", + "enum": [ + "queued", + "in_progress", + "completed", + "requested", + "waiting", + "pending", + "" + ], + "type": "string" + }, + "triggering_actor": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "workflow_id": { + "type": "integer" + } + }, + "required": [ + "id", + "name", + "workflow_id", + "run_number", + "run_attempt", + "status" + ], + "type": [ + "null", + "object" + ] + } + }, + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/actions_list.snap b/pkg/github/__toolsnaps__/actions_list.snap index 4df628bf85..021c3da43f 100644 --- a/pkg/github/__toolsnaps__/actions_list.snap +++ b/pkg/github/__toolsnaps__/actions_list.snap @@ -125,5 +125,737 @@ ], "type": "object" }, - "name": "actions_list" + "name": "actions_list", + "outputSchema": { + "additionalProperties": false, + "properties": { + "artifacts": { + "additionalProperties": false, + "properties": { + "artifacts": { + "items": { + "additionalProperties": false, + "properties": { + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "digest": { + "type": [ + "null", + "string" + ] + }, + "expired": { + "type": [ + "null", + "boolean" + ] + }, + "expires_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "id": { + "type": [ + "null", + "integer" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "size_in_bytes": { + "description": "Artifact size in bytes.", + "type": [ + "null", + "integer" + ] + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "workflow_run": { + "additionalProperties": false, + "properties": { + "head_branch": { + "type": [ + "null", + "string" + ] + }, + "head_sha": { + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "integer" + ] + } + }, + "type": [ + "null", + "object" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + }, + "total_count": { + "type": [ + "null", + "integer" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "method": { + "description": "Operation that produced this response.", + "enum": [ + "list_workflows", + "list_workflow_runs", + "list_workflow_jobs", + "list_workflow_run_artifacts" + ], + "type": "string" + }, + "workflow_jobs": { + "additionalProperties": false, + "properties": { + "jobs": { + "items": { + "additionalProperties": false, + "properties": { + "completed_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "conclusion": { + "description": "Completion outcome, e.g. success, failure, cancelled, skipped, timed_out, action_required.", + "enum": [ + "success", + "failure", + "neutral", + "cancelled", + "skipped", + "timed_out", + "action_required", + "stale", + "startup_failure" + ], + "type": "string" + }, + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "head_branch": { + "type": "string" + }, + "head_sha": { + "type": "string" + }, + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "integer" + }, + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "name": { + "type": "string" + }, + "run_attempt": { + "type": "integer" + }, + "run_id": { + "type": "integer" + }, + "runner_group_id": { + "type": "integer" + }, + "runner_group_name": { + "type": "string" + }, + "runner_id": { + "type": "integer" + }, + "runner_name": { + "type": "string" + }, + "started_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "status": { + "description": "Lifecycle status, e.g. queued, in_progress, completed, waiting.", + "enum": [ + "queued", + "in_progress", + "completed", + "requested", + "waiting", + "pending", + "" + ], + "type": "string" + }, + "steps": { + "items": { + "additionalProperties": false, + "properties": { + "completed_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "conclusion": { + "description": "Completion outcome, e.g. success, failure, cancelled, skipped, timed_out, action_required.", + "enum": [ + "success", + "failure", + "neutral", + "cancelled", + "skipped", + "timed_out", + "action_required", + "stale", + "startup_failure" + ], + "type": "string" + }, + "name": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "started_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "status": { + "description": "Lifecycle status, e.g. queued, in_progress, completed, waiting.", + "enum": [ + "queued", + "in_progress", + "completed", + "requested", + "waiting", + "pending", + "" + ], + "type": "string" + } + }, + "required": [ + "name", + "status", + "number" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "workflow_name": { + "type": "string" + } + }, + "required": [ + "id", + "run_id", + "name", + "status" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "total_count", + "jobs" + ], + "type": [ + "null", + "object" + ] + }, + "workflow_runs": { + "additionalProperties": false, + "properties": { + "total_count": { + "type": "integer" + }, + "workflow_runs": { + "items": { + "additionalProperties": false, + "properties": { + "actor": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "conclusion": { + "description": "Completion outcome, e.g. success, failure, cancelled, skipped, timed_out, action_required.", + "enum": [ + "success", + "failure", + "neutral", + "cancelled", + "skipped", + "timed_out", + "action_required", + "stale", + "startup_failure" + ], + "type": "string" + }, + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "display_title": { + "type": "string" + }, + "event": { + "type": "string" + }, + "head_branch": { + "type": "string" + }, + "head_commit": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + }, + "head_sha": { + "type": "string" + }, + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "pull_requests": { + "items": { + "type": "integer" + }, + "type": [ + "null", + "array" + ] + }, + "referenced_workflows": { + "items": { + "additionalProperties": false, + "properties": { + "path": { + "type": "string" + }, + "ref": { + "type": "string" + }, + "sha": { + "type": "string" + } + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "run_attempt": { + "type": "integer" + }, + "run_number": { + "type": "integer" + }, + "run_started_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "status": { + "description": "Lifecycle status, e.g. queued, in_progress, completed, waiting.", + "enum": [ + "queued", + "in_progress", + "completed", + "requested", + "waiting", + "pending", + "" + ], + "type": "string" + }, + "triggering_actor": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "workflow_id": { + "type": "integer" + } + }, + "required": [ + "id", + "name", + "workflow_id", + "run_number", + "run_attempt", + "status" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } + }, + "required": [ + "total_count", + "workflow_runs" + ], + "type": [ + "null", + "object" + ] + }, + "workflows": { + "additionalProperties": false, + "properties": { + "total_count": { + "type": [ + "null", + "integer" + ] + }, + "workflows": { + "items": { + "additionalProperties": false, + "properties": { + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "html_url": { + "description": "Browser URL.", + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "integer" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "path": { + "type": [ + "null", + "string" + ] + }, + "state": { + "description": "Workflow state, e.g. active, deleted, disabled_fork, disabled_inactivity, disabled_manually.", + "enum": [ + "active", + "deleted", + "disabled_fork", + "disabled_inactivity", + "disabled_manually" + ], + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + } + }, + "type": [ + "null", + "object" + ] + } + }, + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/actions_run_trigger.snap b/pkg/github/__toolsnaps__/actions_run_trigger.snap index 0c859b86ec..d7982a88ec 100644 --- a/pkg/github/__toolsnaps__/actions_run_trigger.snap +++ b/pkg/github/__toolsnaps__/actions_run_trigger.snap @@ -52,5 +52,109 @@ ], "type": "object" }, - "name": "actions_run_trigger" + "name": "actions_run_trigger", + "outputSchema": { + "$defs": { + "run_operation": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + }, + "run_id": { + "type": "integer" + }, + "status": { + "description": "HTTP response status.", + "type": "string" + }, + "status_code": { + "type": "integer" + } + }, + "required": [ + "message", + "run_id", + "status", + "status_code" + ], + "type": "object" + } + }, + "additionalProperties": false, + "properties": { + "cancel": { + "$ref": "#/$defs/run_operation" + }, + "delete_logs": { + "$ref": "#/$defs/run_operation" + }, + "dispatch": { + "additionalProperties": false, + "properties": { + "inputs": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, + "message": { + "type": "string" + }, + "ref": { + "type": "string" + }, + "status": { + "description": "HTTP response status.", + "type": "string" + }, + "status_code": { + "type": "integer" + }, + "workflow_id": { + "type": "string" + }, + "workflow_type": { + "enum": [ + "workflow_file", + "workflow_id" + ], + "type": "string" + } + }, + "required": [ + "inputs", + "message", + "ref", + "status", + "status_code", + "workflow_id", + "workflow_type" + ], + "type": [ + "null", + "object" + ] + }, + "method": { + "description": "Operation that produced this response.", + "enum": [ + "run_workflow", + "rerun_workflow_run", + "rerun_failed_jobs", + "cancel_workflow_run", + "delete_workflow_run_logs" + ], + "type": "string" + }, + "rerun": { + "$ref": "#/$defs/run_operation" + }, + "rerun_failed": { + "$ref": "#/$defs/run_operation" + } + }, + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_job_logs_actions.snap b/pkg/github/__toolsnaps__/get_job_logs_actions.snap new file mode 100644 index 0000000000..b49924f2c0 --- /dev/null +++ b/pkg/github/__toolsnaps__/get_job_logs_actions.snap @@ -0,0 +1,237 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "Get GitHub Actions workflow job logs" + }, + "description": "Get logs for GitHub Actions workflow jobs.\nUse this tool to retrieve logs for a specific job or all failed jobs in a workflow run.\nFor single job logs, provide job_id. For all failed jobs in a run, provide run_id with failed_only=true.\n", + "inputSchema": { + "properties": { + "failed_only": { + "description": "When true, gets logs for all failed jobs in the workflow run specified by run_id. Requires run_id to be provided.", + "type": "boolean" + }, + "job_id": { + "description": "The unique identifier of the workflow job. Required when getting logs for a single job.", + "type": "number" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "return_content": { + "description": "Returns actual log content instead of URLs", + "type": "boolean" + }, + "run_id": { + "description": "The unique identifier of the workflow run. Required when failed_only is true to get logs for all failed jobs in the run.", + "type": "number" + }, + "tail_lines": { + "default": 500, + "description": "Number of lines to return from the end of the log", + "type": "number" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "name": "get_job_logs", + "outputSchema": { + "oneOf": [ + { + "type": "null" + }, + { + "additionalProperties": false, + "properties": { + "job_id": { + "type": "integer" + }, + "job_name": { + "type": "string" + }, + "logs_content": { + "type": "string" + }, + "message": { + "type": "string" + }, + "original_length": { + "description": "Number of log lines before truncation.", + "type": "integer" + } + }, + "required": [ + "job_id", + "logs_content", + "message", + "original_length" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "job_id": { + "type": "integer" + }, + "job_name": { + "type": "string" + }, + "logs_url": { + "type": "string" + }, + "message": { + "type": "string" + }, + "note": { + "type": "string" + } + }, + "required": [ + "job_id", + "logs_url", + "message", + "note" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failed_jobs": { + "type": "integer" + }, + "logs": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "job_id": { + "type": "integer" + }, + "job_name": { + "type": "string" + }, + "logs_content": { + "type": "string" + }, + "message": { + "type": "string" + }, + "original_length": { + "description": "Number of log lines before truncation.", + "type": "integer" + } + }, + "required": [ + "job_id", + "logs_content", + "message", + "original_length" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "job_id": { + "type": "integer" + }, + "job_name": { + "type": "string" + }, + "logs_url": { + "type": "string" + }, + "message": { + "type": "string" + }, + "note": { + "type": "string" + } + }, + "required": [ + "job_id", + "logs_url", + "message", + "note" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "error": { + "type": "string" + }, + "job_id": { + "type": "integer" + }, + "job_name": { + "type": "string" + } + }, + "required": [ + "error", + "job_id", + "job_name" + ], + "type": "object" + } + ] + }, + "type": [ + "null", + "array" + ] + }, + "message": { + "type": "string" + }, + "return_format": { + "additionalProperties": false, + "properties": { + "content": { + "type": "boolean" + }, + "urls": { + "type": "boolean" + } + }, + "required": [ + "content", + "urls" + ], + "type": [ + "null", + "object" + ] + }, + "run_id": { + "type": "integer" + }, + "total_jobs": { + "type": "integer" + } + }, + "required": [ + "failed_jobs", + "message", + "run_id", + "total_jobs" + ], + "type": "object" + } + ] + } +} \ No newline at end of file diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 970e9fa31a..0f22c3cc5f 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/http" + "reflect" "strconv" "strings" @@ -47,7 +48,7 @@ const ( ) // handleFailedJobLogs gets logs for all failed jobs in a workflow run -func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, any, error) { +func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, *ActionsJobLogsOutput, error) { // First, get all jobs for the workflow run jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, &github.ListWorkflowJobsOptions{ Filter: "latest", @@ -66,41 +67,38 @@ func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo } if len(failedJobs) == 0 { - result := map[string]any{ - "message": "No failed jobs found in this workflow run", - "run_id": runID, - "total_jobs": len(jobs.Jobs), - "failed_jobs": 0, + result := &ActionsFailedJobLogsOutput{ + Message: "No failed jobs found in this workflow run", + RunID: runID, + TotalJobs: len(jobs.Jobs), } r, _ := json.Marshal(result) - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsJobLogsOutput{Failed: result}, nil } // Collect logs for all failed jobs - var logResults []map[string]any + var logResults []ActionsJobLog for _, job := range failedJobs { jobResult, resp, err := getJobLogData(ctx, client, owner, repo, job.GetID(), job.GetName(), returnContent, tailLines, contentWindowSize) if err != nil { // Continue with other jobs even if one fails - jobResult = map[string]any{ - "job_id": job.GetID(), - "job_name": job.GetName(), - "error": err.Error(), - } + jobResult = &ActionsJobLog{Error: &ActionsJobLogError{ + JobID: job.GetID(), JobName: job.GetName(), Error: err.Error(), + }} // Enable reporting of status codes and error causes _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get job logs", resp, err) // Explicitly ignore error for graceful handling } - logResults = append(logResults, jobResult) + logResults = append(logResults, *jobResult) } - result := map[string]any{ - "message": fmt.Sprintf("Retrieved logs for %d failed jobs", len(failedJobs)), - "run_id": runID, - "total_jobs": len(jobs.Jobs), - "failed_jobs": len(failedJobs), - "logs": logResults, - "return_format": map[string]bool{"content": returnContent, "urls": !returnContent}, + result := &ActionsFailedJobLogsOutput{ + Message: fmt.Sprintf("Retrieved logs for %d failed jobs", len(failedJobs)), + RunID: runID, + TotalJobs: len(jobs.Jobs), + FailedJobs: len(failedJobs), + Logs: &logResults, + ReturnFormat: &ActionsLogsReturnFormat{Content: returnContent, URLs: !returnContent}, } r, err := json.Marshal(result) @@ -108,11 +106,11 @@ func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsJobLogsOutput{Failed: result}, nil } // handleSingleJobLogs gets logs for a single job -func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, any, error) { +func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, *ActionsJobLogsOutput, error) { jobResult, resp, err := getJobLogData(ctx, client, owner, repo, jobID, "", returnContent, tailLines, contentWindowSize) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get job logs", resp, err), nil, nil @@ -123,11 +121,11 @@ func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsJobLogsOutput{Single: jobResult}, nil } // getJobLogData retrieves log data for a single job, either as URL or content -func getJobLogData(ctx context.Context, client *github.Client, owner, repo string, jobID int64, jobName string, returnContent bool, tailLines int, contentWindowSize int) (map[string]any, *github.Response, error) { +func getJobLogData(ctx context.Context, client *github.Client, owner, repo string, jobID int64, jobName string, returnContent bool, tailLines int, contentWindowSize int) (*ActionsJobLog, *github.Response, error) { // Get the download URL for the job logs url, resp, err := client.Actions.GetWorkflowJobLogs(ctx, owner, repo, jobID, 1) if err != nil { @@ -135,13 +133,7 @@ func getJobLogData(ctx context.Context, client *github.Client, owner, repo strin } defer func() { _ = resp.Body.Close() }() - result := map[string]any{ - "job_id": jobID, - } - if jobName != "" { - result["job_name"] = jobName - } - + result := &ActionsJobLog{} if returnContent { // Download and return the actual log content content, originalLength, httpResp, err := downloadLogContent(ctx, url.String(), tailLines, contentWindowSize) //nolint:bodyclose // Response body is closed in downloadLogContent, but we need to return httpResp @@ -152,14 +144,17 @@ func getJobLogData(ctx context.Context, client *github.Client, owner, repo strin } return nil, ghResp, fmt.Errorf("failed to download log content for job %d: %w", jobID, err) } - result["logs_content"] = content - result["message"] = "Job logs content retrieved successfully" - result["original_length"] = originalLength + result.Content = &ActionsJobLogContent{ + JobID: jobID, JobName: jobName, LogsContent: content, + Message: "Job logs content retrieved successfully", OriginalLength: originalLength, + } } else { // Return just the URL - result["logs_url"] = url.String() - result["message"] = "Job logs are available for download" - result["note"] = "The logs_url provides a download link for the individual job logs in plain text format. Use return_content=true to get the actual log content." + result.URL = &ActionsJobLogURL{ + JobID: jobID, JobName: jobName, LogsURL: url.String(), + Message: "Job logs are available for download", + Note: "The logs_url provides a download link for the individual job logs in plain text format. Use return_content=true to get the actual log content.", + } } return result, resp, nil @@ -199,10 +194,11 @@ func downloadLogContent(ctx context.Context, logURL string, tailLines int, maxLi // ActionsList returns the tool and handler for listing GitHub Actions resources. func ActionsList(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := newActionsTool( ToolsetMetadataActions, mcp.Tool{ - Name: "actions_list", + Name: "actions_list", + OutputSchema: actionsListOutputSchema(), Description: t("TOOL_ACTIONS_LIST_DESCRIPTION", `Tools for listing GitHub Actions resources. Use this tool to list workflows in a repository, or list workflow runs, jobs, and artifacts for a specific workflow or workflow run. @@ -217,12 +213,7 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an "method": { Type: "string", Description: "The action to perform", - Enum: []any{ - actionsMethodListWorkflows, - actionsMethodListWorkflowRuns, - actionsMethodListWorkflowJobs, - actionsMethodListWorkflowArtifacts, - }, + Enum: actionsEnumSchemas()[reflect.TypeFor[ActionsListMethod]()].Enum, }, "owner": { Type: "string", @@ -324,31 +315,9 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an }, }, 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 - } - - method, err := RequiredParam[string](args, "method") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - resourceID, err := OptionalParam[string](args, "resource_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args ActionsListInput) (*mcp.CallToolResult, *ActionsListOutput, error) { + owner, repo, method, resourceID := args.Owner, args.Repo, args.Method, args.ResourceID + pagination := PaginationParams{Page: args.Page, PerPage: args.PerPage} client, err := deps.GetClient(ctx) if err != nil { @@ -388,10 +357,10 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an result, payload, err := listWorkflows(ctx, client, owner, repo, pagination) return attachIFC(result), payload, err case actionsMethodListWorkflowRuns: - result, payload, err := listWorkflowRuns(ctx, client, args, owner, repo, resourceID, pagination) + result, payload, err := listWorkflowRuns(ctx, client, args.WorkflowRunsFilter, owner, repo, resourceID, pagination) return attachIFC(result), payload, err case actionsMethodListWorkflowJobs: - result, payload, err := listWorkflowJobs(ctx, client, args, owner, repo, resourceIDInt, pagination) + result, payload, err := listWorkflowJobs(ctx, client, args.WorkflowJobsFilter, owner, repo, resourceIDInt, pagination) return attachIFC(result), payload, err case actionsMethodListWorkflowArtifacts: result, payload, err := listWorkflowArtifacts(ctx, client, owner, repo, resourceIDInt, pagination) @@ -400,16 +369,18 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }, + normalizeActionsArguments("list"), ) return tool } // ActionsGet returns the tool and handler for getting GitHub Actions resources. func ActionsGet(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := newActionsTool( ToolsetMetadataActions, mcp.Tool{ - Name: "actions_get", + Name: "actions_get", + OutputSchema: actionsGetOutputSchema(), Description: t("TOOL_ACTIONS_GET_DESCRIPTION", `Get details about specific GitHub Actions resources. Use this tool to get details about individual workflows, workflow runs, jobs, and artifacts by their unique IDs. `), @@ -423,14 +394,7 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an "method": { Type: "string", Description: "The method to execute", - Enum: []any{ - actionsMethodGetWorkflow, - actionsMethodGetWorkflowRun, - actionsMethodGetWorkflowJob, - actionsMethodDownloadWorkflowArtifact, - actionsMethodGetWorkflowRunUsage, - actionsMethodGetWorkflowRunLogsURL, - }, + Enum: actionsEnumSchemas()[reflect.TypeFor[ActionsGetMethod]()].Enum, }, "owner": { Type: "string", @@ -454,24 +418,8 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an }, }, 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 - } - method, err := RequiredParam[string](args, "method") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - resourceID, err := RequiredParam[string](args, "resource_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args ActionsGetInput) (*mcp.CallToolResult, *ActionsGetOutput, error) { + owner, repo, method, resourceID := args.Owner, args.Repo, args.Method, args.ResourceID client, err := deps.GetClient(ctx) if err != nil { @@ -522,17 +470,19 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }, + normalizeActionsArguments("get"), ) return tool } // ActionsRunTrigger returns the tool and handler for triggering GitHub Actions workflows. func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := newActionsTool( ToolsetMetadataActions, mcp.Tool{ - Name: "actions_run_trigger", - Description: t("TOOL_ACTIONS_RUN_TRIGGER_DESCRIPTION", "Trigger GitHub Actions workflow operations, including running, re-running, cancelling workflow runs, and deleting workflow run logs."), + Name: "actions_run_trigger", + OutputSchema: actionsRunTriggerOutputSchema(), + Description: t("TOOL_ACTIONS_RUN_TRIGGER_DESCRIPTION", "Trigger GitHub Actions workflow operations, including running, re-running, cancelling workflow runs, and deleting workflow run logs."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ACTIONS_RUN_TRIGGER_USER_TITLE", "Trigger GitHub Actions workflow actions"), ReadOnlyHint: false, @@ -544,13 +494,7 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo "method": { Type: "string", Description: "The method to execute", - Enum: []any{ - actionsMethodRunWorkflow, - actionsMethodRerunWorkflowRun, - actionsMethodRerunFailedJobs, - actionsMethodCancelWorkflowRun, - actionsMethodDeleteWorkflowRunLogs, - }, + Enum: actionsEnumSchemas()[reflect.TypeFor[ActionsRunTriggerMethod]()].Enum, }, "owner": { Type: "string", @@ -582,30 +526,9 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo }, }, scopes.RequireAll(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 - } - method, err := RequiredParam[string](args, "method") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - // Get optional parameters - workflowID, _ := OptionalParam[string](args, "workflow_id") - ref, _ := OptionalParam[string](args, "ref") - runID, _ := OptionalIntParam(args, "run_id") - - // Get optional inputs parameter - inputs, err := OptionalParam[map[string]any](args, "inputs") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args ActionsRunTriggerInput) (*mcp.CallToolResult, *ActionsRunTriggerOutput, error) { + owner, repo, method := args.Owner, args.Repo, args.Method + workflowID, ref, runID, inputs := args.WorkflowID, args.Ref, args.RunID, args.Inputs // Validate required parameters based on action type if method == actionsMethodRunWorkflow { @@ -639,16 +562,18 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }, + normalizeActionsArguments("trigger"), ) return tool } // ActionsGetJobLogs returns the tool and handler for getting workflow job logs. func ActionsGetJobLogs(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := newActionsTool( ToolsetMetadataActions, mcp.Tool{ - Name: "get_job_logs", + Name: "get_job_logs", + OutputSchema: actionsJobLogsOutputSchema(), Description: t("TOOL_GET_JOB_LOGS_CONSOLIDATED_DESCRIPTION", `Get logs for GitHub Actions workflow jobs. Use this tool to retrieve logs for a specific job or all failed jobs in a workflow run. For single job logs, provide job_id. For all failed jobs in a run, provide run_id with failed_only=true. @@ -694,44 +619,9 @@ For single job logs, provide job_id. For all failed jobs in a run, provide run_i }, }, 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 - } - - jobID, err := OptionalIntParam(args, "job_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - runID, err := OptionalIntParam(args, "run_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - failedOnly, err := OptionalParam[bool](args, "failed_only") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - returnContent, err := OptionalParam[bool](args, "return_content") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - tailLines, err := OptionalIntParam(args, "tail_lines") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - // Default to 500 lines if not specified or invalid - if tailLines <= 0 { - tailLines = 500 - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args ActionsGetJobLogsInput) (*mcp.CallToolResult, *ActionsJobLogsOutput, error) { + owner, repo, jobID, runID := args.Owner, args.Repo, args.JobID, args.RunID + failedOnly, returnContent, tailLines := args.FailedOnly, args.ReturnContent, args.TailLines client, err := deps.GetClient(ctx) if err != nil { @@ -766,13 +656,14 @@ For single job logs, provide job_id. For all failed jobs in a run, provide run_i return utils.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), nil, nil }, + normalizeActionsArguments("logs"), ) return tool } // Helper functions for consolidated actions tools -func getWorkflow(ctx context.Context, client *github.Client, owner, repo, resourceID string) (*mcp.CallToolResult, any, error) { +func getWorkflow(ctx context.Context, client *github.Client, owner, repo, resourceID string) (*mcp.CallToolResult, *ActionsGetOutput, error) { var workflow *github.Workflow var resp *github.Response var err error @@ -793,23 +684,24 @@ func getWorkflow(ctx context.Context, client *github.Client, owner, repo, resour return nil, nil, fmt.Errorf("failed to marshal workflow: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsGetOutput{Method: actionsMethodGetWorkflow, Workflow: convertToActionsWorkflow(workflow)}, nil } -func getWorkflowRun(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, any, error) { +func getWorkflowRun(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, *ActionsGetOutput, error) { workflowRun, resp, err := client.Actions.GetWorkflowRunByID(ctx, owner, repo, resourceID) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run", resp, err), nil, nil } defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(convertToMinimalWorkflowRun(workflowRun)) + output := convertToMinimalWorkflowRun(workflowRun) + r, err := json.Marshal(output) if err != nil { return nil, nil, fmt.Errorf("failed to marshal workflow run: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsGetOutput{Method: actionsMethodGetWorkflowRun, Run: &output}, nil } -func getWorkflowJob(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, any, error) { +func getWorkflowJob(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, *ActionsGetOutput, error) { workflowJob, resp, err := client.Actions.GetWorkflowJobByID(ctx, owner, repo, resourceID) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow job", resp, err), nil, nil @@ -819,10 +711,15 @@ func getWorkflowJob(ctx context.Context, client *github.Client, owner, repo stri if err != nil { return nil, nil, fmt.Errorf("failed to marshal workflow job: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + var output *MinimalWorkflowJob + if workflowJob != nil { + job := convertToMinimalWorkflowJob(workflowJob) + output = &job + } + return utils.NewToolResultText(string(r)), &ActionsGetOutput{Method: actionsMethodGetWorkflowJob, Job: output}, nil } -func listWorkflows(ctx context.Context, client *github.Client, owner, repo string, pagination PaginationParams) (*mcp.CallToolResult, any, error) { +func listWorkflows(ctx context.Context, client *github.Client, owner, repo string, pagination PaginationParams) (*mcp.CallToolResult, *ActionsListOutput, error) { opts := &github.ListOptions{ PerPage: pagination.PerPage, Page: pagination.Page, @@ -839,29 +736,19 @@ func listWorkflows(ctx context.Context, client *github.Client, owner, repo strin return nil, nil, fmt.Errorf("failed to marshal workflows: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsListOutput{Method: actionsMethodListWorkflows, Workflows: convertToActionsWorkflows(workflows)}, nil } -func listWorkflowRuns(ctx context.Context, client *github.Client, args map[string]any, owner, repo, resourceID string, pagination PaginationParams) (*mcp.CallToolResult, any, error) { - filterArgs, err := OptionalParam[map[string]any](args, "workflow_runs_filter") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - filterArgsTyped := make(map[string]string) - for k, v := range filterArgs { - if strVal, ok := v.(string); ok { - filterArgsTyped[k] = strVal - } else { - filterArgsTyped[k] = "" - } +func listWorkflowRuns(ctx context.Context, client *github.Client, filter ActionsWorkflowRunsFilter, owner, repo, resourceID string, pagination PaginationParams) (*mcp.CallToolResult, *ActionsListOutput, error) { + if filter.validationError != "" { + return utils.NewToolResultError(filter.validationError), nil, nil } listWorkflowRunsOptions := &github.ListWorkflowRunsOptions{ - Actor: filterArgsTyped["actor"], - Branch: filterArgsTyped["branch"], - Event: filterArgsTyped["event"], - Status: filterArgsTyped["status"], + Actor: filter.Actor, + Branch: filter.Branch, + Event: filter.Event, + Status: filter.Status, ListOptions: github.ListOptions{ Page: pagination.Page, PerPage: pagination.PerPage, @@ -870,6 +757,7 @@ func listWorkflowRuns(ctx context.Context, client *github.Client, args map[strin var workflowRuns *github.WorkflowRuns var resp *github.Response + var err error if resourceID == "" { workflowRuns, resp, err = client.Actions.ListRepositoryWorkflowRuns(ctx, owner, repo, listWorkflowRunsOptions) @@ -884,31 +772,22 @@ func listWorkflowRuns(ctx context.Context, client *github.Client, args map[strin } defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(convertToMinimalWorkflowRuns(workflowRuns)) + output := convertToMinimalWorkflowRuns(workflowRuns) + r, err := json.Marshal(output) if err != nil { return nil, nil, fmt.Errorf("failed to marshal workflow runs: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsListOutput{Method: actionsMethodListWorkflowRuns, Runs: &output}, nil } -func listWorkflowJobs(ctx context.Context, client *github.Client, args map[string]any, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, any, error) { - filterArgs, err := OptionalParam[map[string]any](args, "workflow_jobs_filter") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - filterArgsTyped := make(map[string]string) - for k, v := range filterArgs { - if strVal, ok := v.(string); ok { - filterArgsTyped[k] = strVal - } else { - filterArgsTyped[k] = "" - } +func listWorkflowJobs(ctx context.Context, client *github.Client, filter ActionsWorkflowJobsFilter, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, *ActionsListOutput, error) { + if filter.validationError != "" { + return utils.NewToolResultError(filter.validationError), nil, nil } workflowJobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, resourceID, &github.ListWorkflowJobsOptions{ - Filter: filterArgsTyped["filter"], + Filter: filter.Filter, ListOptions: github.ListOptions{ Page: pagination.Page, PerPage: pagination.PerPage, @@ -918,8 +797,8 @@ func listWorkflowJobs(ctx context.Context, client *github.Client, args map[strin return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow jobs", resp, err), nil, nil } - response := map[string]any{ - "jobs": convertToMinimalWorkflowJobs(workflowJobs), + response := &ActionsJobsOutput{ + Jobs: convertToMinimalWorkflowJobs(workflowJobs), } defer func() { _ = resp.Body.Close() }() @@ -928,10 +807,10 @@ func listWorkflowJobs(ctx context.Context, client *github.Client, args map[strin return nil, nil, fmt.Errorf("failed to marshal workflow jobs: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsListOutput{Method: actionsMethodListWorkflowJobs, Jobs: &response.Jobs}, nil } -func listWorkflowArtifacts(ctx context.Context, client *github.Client, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, any, error) { +func listWorkflowArtifacts(ctx context.Context, client *github.Client, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, *ActionsListOutput, error) { opts := &github.ListOptions{ PerPage: pagination.PerPage, Page: pagination.Page, @@ -948,10 +827,10 @@ func listWorkflowArtifacts(ctx context.Context, client *github.Client, owner, re return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsListOutput{Method: actionsMethodListWorkflowArtifacts, Artifacts: convertToActionsArtifacts(artifacts)}, nil } -func downloadWorkflowArtifact(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, any, error) { +func downloadWorkflowArtifact(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, *ActionsGetOutput, error) { // Get the download URL for the artifact url, resp, err := client.Actions.DownloadArtifact(ctx, owner, repo, resourceID, 1) if err != nil { @@ -960,11 +839,11 @@ func downloadWorkflowArtifact(ctx context.Context, client *github.Client, owner, defer func() { _ = resp.Body.Close() }() // Create response with the download URL and information - result := map[string]any{ - "download_url": url.String(), - "message": "Artifact is available for download", - "note": "The download_url provides a download link for the artifact as a ZIP archive. The link is temporary and expires after a short time.", - "artifact_id": resourceID, + result := &ActionsArtifactDownloadOutput{ + DownloadURL: url.String(), + Message: "Artifact is available for download", + Note: "The download_url provides a download link for the artifact as a ZIP archive. The link is temporary and expires after a short time.", + ArtifactID: resourceID, } r, err := json.Marshal(result) @@ -972,10 +851,10 @@ func downloadWorkflowArtifact(ctx context.Context, client *github.Client, owner, return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsGetOutput{Method: actionsMethodDownloadWorkflowArtifact, Artifact: result}, nil } -func getWorkflowRunLogsURL(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { +func getWorkflowRunLogsURL(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, *ActionsGetOutput, error) { // Get the download URL for the logs url, resp, err := client.Actions.GetWorkflowRunLogs(ctx, owner, repo, runID, 1) if err != nil { @@ -984,12 +863,12 @@ func getWorkflowRunLogsURL(ctx context.Context, client *github.Client, owner, re defer func() { _ = resp.Body.Close() }() // Create response with the logs URL and information - result := map[string]any{ - "logs_url": url.String(), - "message": "Workflow run logs are available for download", - "note": "The logs_url provides a download link for the complete workflow run logs as a ZIP archive. You can download this archive to extract and examine individual job logs.", - "warning": "This downloads ALL logs as a ZIP file which can be large and expensive. For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id instead.", - "optimization_tip": "Use: get_job_logs with parameters {run_id: " + fmt.Sprintf("%d", runID) + ", failed_only: true} for more efficient failed job debugging", + result := &ActionsRunLogsOutput{ + LogsURL: url.String(), + Message: "Workflow run logs are available for download", + Note: "The logs_url provides a download link for the complete workflow run logs as a ZIP archive. You can download this archive to extract and examine individual job logs.", + Warning: "This downloads ALL logs as a ZIP file which can be large and expensive. For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id instead.", + OptimizationTip: "Use: get_job_logs with parameters {run_id: " + fmt.Sprintf("%d", runID) + ", failed_only: true} for more efficient failed job debugging", } r, err := json.Marshal(result) @@ -997,10 +876,10 @@ func getWorkflowRunLogsURL(ctx context.Context, client *github.Client, owner, re return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsGetOutput{Method: actionsMethodGetWorkflowRunLogsURL, Logs: result}, nil } -func getWorkflowRunUsage(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, any, error) { +func getWorkflowRunUsage(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, *ActionsGetOutput, error) { usage, resp, err := client.Actions.GetWorkflowRunUsageByID(ctx, owner, repo, resourceID) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run usage", resp, err), nil, nil @@ -1012,10 +891,10 @@ func getWorkflowRunUsage(ctx context.Context, client *github.Client, owner, repo return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsGetOutput{Method: actionsMethodGetWorkflowRunUsage, Usage: convertToActionsRunUsage(usage)}, nil } -func runWorkflow(ctx context.Context, client *github.Client, owner, repo, workflowID, ref string, inputs map[string]any) (*mcp.CallToolResult, any, error) { +func runWorkflow(ctx context.Context, client *github.Client, owner, repo, workflowID, ref string, inputs map[string]any) (*mcp.CallToolResult, *ActionsRunTriggerOutput, error) { event := github.CreateWorkflowDispatchEventRequest{ Ref: ref, Inputs: inputs, @@ -1023,7 +902,7 @@ func runWorkflow(ctx context.Context, client *github.Client, owner, repo, workfl var resp *github.Response var err error - var workflowType string + var workflowType ActionsWorkflowType if workflowIDInt, parseErr := strconv.ParseInt(workflowID, 10, 64); parseErr == nil { _, resp, err = client.Actions.CreateWorkflowDispatchEventByID(ctx, owner, repo, workflowIDInt, event) @@ -1038,14 +917,18 @@ func runWorkflow(ctx context.Context, client *github.Client, owner, repo, workfl } defer func() { _ = resp.Body.Close() }() - result := map[string]any{ - "message": "Workflow run has been queued", - "workflow_type": workflowType, - "workflow_id": workflowID, - "ref": ref, - "inputs": inputs, - "status": resp.Status, - "status_code": resp.StatusCode, + inputJSON, err := json.Marshal(inputs) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + result := &ActionsDispatchOutput{ + Message: "Workflow run has been queued", + WorkflowType: workflowType, + WorkflowID: workflowID, + Ref: ref, + Inputs: inputJSON, + Status: resp.Status, + StatusCode: resp.StatusCode, } r, err := json.Marshal(result) @@ -1053,21 +936,21 @@ func runWorkflow(ctx context.Context, client *github.Client, owner, repo, workfl return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsRunTriggerOutput{Method: actionsMethodRunWorkflow, Dispatch: result}, nil } -func rerunWorkflowRun(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { +func rerunWorkflowRun(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, *ActionsRunTriggerOutput, error) { resp, err := client.Actions.RerunWorkflowByID(ctx, owner, repo, runID) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to rerun workflow run", resp, err), nil, nil } defer func() { _ = resp.Body.Close() }() - result := map[string]any{ - "message": "Workflow run has been queued for re-run", - "run_id": runID, - "status": resp.Status, - "status_code": resp.StatusCode, + result := &ActionsRunOperationOutput{ + Message: "Workflow run has been queued for re-run", + RunID: runID, + Status: resp.Status, + StatusCode: resp.StatusCode, } r, err := json.Marshal(result) @@ -1075,21 +958,21 @@ func rerunWorkflowRun(ctx context.Context, client *github.Client, owner, repo st return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsRunTriggerOutput{Method: actionsMethodRerunWorkflowRun, Rerun: result}, nil } -func rerunFailedJobs(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { +func rerunFailedJobs(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, *ActionsRunTriggerOutput, error) { resp, err := client.Actions.RerunFailedJobsByID(ctx, owner, repo, runID) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to rerun failed jobs", resp, err), nil, nil } defer func() { _ = resp.Body.Close() }() - result := map[string]any{ - "message": "Failed jobs have been queued for re-run", - "run_id": runID, - "status": resp.Status, - "status_code": resp.StatusCode, + result := &ActionsRunOperationOutput{ + Message: "Failed jobs have been queued for re-run", + RunID: runID, + Status: resp.Status, + StatusCode: resp.StatusCode, } r, err := json.Marshal(result) @@ -1097,10 +980,10 @@ func rerunFailedJobs(ctx context.Context, client *github.Client, owner, repo str return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsRunTriggerOutput{Method: actionsMethodRerunFailedJobs, RerunFailed: result}, nil } -func cancelWorkflowRun(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { +func cancelWorkflowRun(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, *ActionsRunTriggerOutput, error) { resp, err := client.Actions.CancelWorkflowRunByID(ctx, owner, repo, runID) if err != nil { if _, ok := errors.AsType[*github.AcceptedError](err); !ok { @@ -1109,11 +992,11 @@ func cancelWorkflowRun(ctx context.Context, client *github.Client, owner, repo s } defer func() { _ = resp.Body.Close() }() - result := map[string]any{ - "message": "Workflow run has been cancelled", - "run_id": runID, - "status": resp.Status, - "status_code": resp.StatusCode, + result := &ActionsRunOperationOutput{ + Message: "Workflow run has been cancelled", + RunID: runID, + Status: resp.Status, + StatusCode: resp.StatusCode, } r, err := json.Marshal(result) @@ -1121,21 +1004,21 @@ func cancelWorkflowRun(ctx context.Context, client *github.Client, owner, repo s return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsRunTriggerOutput{Method: actionsMethodCancelWorkflowRun, Cancel: result}, nil } -func deleteWorkflowRunLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { +func deleteWorkflowRunLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, *ActionsRunTriggerOutput, error) { resp, err := client.Actions.DeleteWorkflowRunLogs(ctx, owner, repo, runID) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to delete workflow run logs", resp, err), nil, nil } defer func() { _ = resp.Body.Close() }() - result := map[string]any{ - "message": "Workflow run logs have been deleted", - "run_id": runID, - "status": resp.Status, - "status_code": resp.StatusCode, + result := &ActionsRunOperationOutput{ + Message: "Workflow run logs have been deleted", + RunID: runID, + Status: resp.Status, + StatusCode: resp.StatusCode, } r, err := json.Marshal(result) @@ -1143,5 +1026,5 @@ func deleteWorkflowRunLogs(ctx context.Context, client *github.Client, owner, re return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsRunTriggerOutput{Method: actionsMethodDeleteWorkflowRunLogs, DeleteLogs: result}, nil } diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 1490389a58..5caf7d269b 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -571,10 +571,8 @@ func Test_ActionsGetJobLogs(t *testing.T) { // Verify tool definition once toolDef := ActionsGetJobLogs(translations.NullTranslationHelper) - // Note: consolidated ActionsGetJobLogs has same tool name "get_job_logs" as the individual tool - // but with different descriptions. We skip toolsnap validation here since the individual - // tool's toolsnap already exists and is tested in Test_GetJobLogs. - // The functional feature rules ensure only one variant is active at a time. + // The legacy individual tool has a different contract under the same name. + require.NoError(t, toolsnaps.Test("get_job_logs_actions", toolDef.Tool)) assert.Equal(t, "get_job_logs", toolDef.Tool.Name) assert.NotEmpty(t, toolDef.Tool.Description) inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) diff --git a/pkg/github/actions_types.go b/pkg/github/actions_types.go new file mode 100644 index 0000000000..16c7a7fd63 --- /dev/null +++ b/pkg/github/actions_types.go @@ -0,0 +1,647 @@ +package github + +import ( + "context" + "encoding/json" + "reflect" + "slices" + "strings" + "sync" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type ActionsListMethod string +type ActionsGetMethod string +type ActionsRunTriggerMethod string +type ActionsWorkflowState string +type ActionsWorkflowType string + +var actionsEnumSchemas = sync.OnceValue(func() map[reflect.Type]*jsonschema.Schema { + return map[reflect.Type]*jsonschema.Schema{ + reflect.TypeFor[ActionsListMethod](): {Type: "string", Enum: []any{ + actionsMethodListWorkflows, actionsMethodListWorkflowRuns, + actionsMethodListWorkflowJobs, actionsMethodListWorkflowArtifacts, + }}, + reflect.TypeFor[ActionsGetMethod](): {Type: "string", Enum: []any{ + actionsMethodGetWorkflow, actionsMethodGetWorkflowRun, actionsMethodGetWorkflowJob, + actionsMethodDownloadWorkflowArtifact, actionsMethodGetWorkflowRunUsage, actionsMethodGetWorkflowRunLogsURL, + }}, + reflect.TypeFor[ActionsRunTriggerMethod](): {Type: "string", Enum: []any{ + actionsMethodRunWorkflow, actionsMethodRerunWorkflowRun, actionsMethodRerunFailedJobs, + actionsMethodCancelWorkflowRun, actionsMethodDeleteWorkflowRunLogs, + }}, + reflect.TypeFor[ActionsWorkflowState](): {Type: "string", Enum: []any{ + "active", "deleted", "disabled_fork", "disabled_inactivity", "disabled_manually", + }}, + reflect.TypeFor[ActionsWorkflowType](): {Type: "string", Enum: []any{"workflow_file", "workflow_id"}}, + } +}) + +type ActionsListInput struct { + Method string `json:"method"` + Owner string `json:"owner"` + Repo string `json:"repo"` + ResourceID string `json:"resource_id,omitempty"` + Page int `json:"page,omitempty"` + PerPage int `json:"perPage,omitempty"` + WorkflowRunsFilter ActionsWorkflowRunsFilter `json:"workflow_runs_filter"` + WorkflowJobsFilter ActionsWorkflowJobsFilter `json:"workflow_jobs_filter"` +} + +type ActionsWorkflowRunsFilter struct { + Actor string `json:"actor,omitempty"` + Branch string `json:"branch,omitempty"` + Event string `json:"event,omitempty"` + Status string `json:"status,omitempty"` + validationError string +} + +type ActionsWorkflowJobsFilter struct { + Filter string `json:"filter,omitempty"` + validationError string +} + +// The legacy handler validates filters only after client acquisition and +// resource-ID validation. Retain that ordering even for non-object filters. +func (filter *ActionsWorkflowRunsFilter) UnmarshalJSON(raw []byte) error { + type fields ActionsWorkflowRunsFilter + *filter = ActionsWorkflowRunsFilter{} + message, err := decodeActionsFilter(raw, "workflow_runs_filter", (*fields)(filter)) + filter.validationError = message + return err +} + +func (filter *ActionsWorkflowJobsFilter) UnmarshalJSON(raw []byte) error { + type fields ActionsWorkflowJobsFilter + *filter = ActionsWorkflowJobsFilter{} + message, err := decodeActionsFilter(raw, "workflow_jobs_filter", (*fields)(filter)) + filter.validationError = message + return err +} + +func decodeActionsFilter(raw []byte, field string, output any) (string, error) { + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return "", err + } + if _, err := OptionalParam[map[string]any](map[string]any{field: value}, field); err != nil { + return err.Error(), nil + } + return "", json.Unmarshal(raw, output) +} + +func newActionsTool[In, Out any]( + toolset inventory.ToolsetMetadata, + tool mcp.Tool, + scopeAccess inventory.ScopeAccess, + handler func(context.Context, ToolDependencies, *mcp.CallToolRequest, In) (*mcp.CallToolResult, Out, error), + normalizers ...inventory.InputNormalizer, +) inventory.ServerTool { + cached, _ := actionsValidationSchemas.LoadOrStore(tool.Name, sync.OnceValue(func() *jsonschema.Schema { + return actionsValidationSchema(tool.InputSchema) + })) + validation := cached.(func() *jsonschema.Schema)() + return NewToolWithSchemaOptions(toolset, tool, scopeAccess, + inventory.TypedSchemaOptions{ValidationInputSchema: validation}, handler, normalizers...) +} + +var actionsValidationSchemas sync.Map + +func actionsValidationSchema(inputSchema any) *jsonschema.Schema { + raw, err := json.Marshal(inputSchema) + if err != nil { + panic(err) + } + var schema jsonschema.Schema + if err := json.Unmarshal(raw, &schema); err != nil { + panic(err) + } + // The legacy raw handler treated enums and pagination bounds as metadata. + // Relax only SDK validation; leave the advertised contract unchanged. + for _, field := range []string{"method", "workflow_runs_filter", "workflow_jobs_filter"} { + property := schema.Properties[field] + if property == nil { + continue + } + if field == "method" { + property.Enum = nil + continue + } + for _, nested := range property.Properties { + nested.Enum = nil + } + schema.Properties[field] = &jsonschema.Schema{ + Description: property.Description, + AnyOf: []*jsonschema.Schema{ + property, + {Not: &jsonschema.Schema{Type: "object"}}, + }, + } + } + for _, field := range []string{"page", "perPage"} { + if property := schema.Properties[field]; property != nil { + property.Minimum, property.Maximum = nil, nil + } + } + return &schema +} + +type ActionsGetInput struct { + Method string `json:"method"` + Owner string `json:"owner"` + Repo string `json:"repo"` + ResourceID string `json:"resource_id"` +} + +type ActionsRunTriggerInput struct { + Method string `json:"method"` + Owner string `json:"owner"` + Repo string `json:"repo"` + WorkflowID string `json:"workflow_id,omitempty"` + Ref string `json:"ref,omitempty"` + RunID int `json:"run_id,omitempty"` + Inputs map[string]any `json:"inputs,omitempty"` +} + +type ActionsGetJobLogsInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + JobID int `json:"job_id,omitempty"` + RunID int `json:"run_id,omitempty"` + FailedOnly bool `json:"failed_only,omitempty"` + ReturnContent bool `json:"return_content,omitempty"` + TailLines int `json:"tail_lines,omitempty"` +} + +// Method is omitted only on SDK-validated error zero values, which the shared +// output middleware removes. Every successful handler sets its discriminant. +type ActionsListOutput struct { + Method ActionsListMethod `json:"method,omitempty"` + Workflows *ActionsWorkflowsOutput `json:"workflows,omitempty"` + Runs *MinimalWorkflowRunsResult `json:"workflow_runs,omitempty"` + Jobs *MinimalWorkflowJobsResult `json:"workflow_jobs,omitempty"` + Artifacts *ActionsArtifactsOutput `json:"artifacts,omitempty"` +} + +type ActionsWorkflow struct { + ID *int64 `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + Path *string `json:"path,omitempty"` + State *ActionsWorkflowState `json:"state,omitempty"` + HTMLURL *string `json:"html_url,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type ActionsWorkflowsOutput struct { + TotalCount *int `json:"total_count,omitempty"` + Workflows []*ActionsWorkflow `json:"workflows,omitempty"` +} + +type ActionsArtifact struct { + ID *int64 `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + SizeInBytes *int64 `json:"size_in_bytes,omitempty"` + Expired *bool `json:"expired,omitempty"` + Digest *string `json:"digest,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + WorkflowRun *ActionsArtifactWorkflowRun `json:"workflow_run,omitempty"` +} + +type ActionsArtifactWorkflowRun struct { + ID *int64 `json:"id,omitempty"` + HeadBranch *string `json:"head_branch,omitempty"` + HeadSHA *string `json:"head_sha,omitempty"` +} + +type ActionsArtifactsOutput struct { + TotalCount *int64 `json:"total_count,omitempty"` + Artifacts []*ActionsArtifact `json:"artifacts,omitempty"` +} + +type ActionsJobsOutput struct { + Jobs MinimalWorkflowJobsResult `json:"jobs"` +} + +var actionsListOutputSchema = sync.OnceValue(func() *jsonschema.Schema { + schema := actionsOutputSchema[ActionsListOutput]() + return schema +}) + +type ActionsGetOutput struct { + Method ActionsGetMethod `json:"method,omitempty"` + Workflow *ActionsWorkflow `json:"workflow,omitempty"` + Run *MinimalWorkflowRun `json:"workflow_run,omitempty"` + Job *MinimalWorkflowJob `json:"workflow_job,omitempty"` + Usage *ActionsRunUsageOutput `json:"usage,omitempty"` + Artifact *ActionsArtifactDownloadOutput `json:"artifact,omitempty"` + Logs *ActionsRunLogsOutput `json:"logs,omitempty"` +} + +type ActionsRunUsageOutput struct { + Billable []ActionsRunnerUsage `json:"billable,omitempty"` + RunDurationMS *int64 `json:"run_duration_ms,omitempty"` +} + +type ActionsRunnerUsage struct { + Runner string `json:"runner"` + TotalMS *int64 `json:"total_ms,omitempty"` + Jobs *int `json:"jobs,omitempty"` + JobRuns []ActionsJobRunUsage `json:"job_runs,omitempty"` +} + +type ActionsJobRunUsage struct { + JobID *int `json:"job_id,omitempty"` + DurationMS *int64 `json:"duration_ms,omitempty"` +} + +func convertToActionsWorkflow(workflow *github.Workflow) *ActionsWorkflow { + if workflow == nil { + return nil + } + var state *ActionsWorkflowState + if workflow.State != nil { + state = new(ActionsWorkflowState(*workflow.State)) + } + return &ActionsWorkflow{ + ID: workflow.ID, Name: workflow.Name, Path: workflow.Path, State: state, + HTMLURL: workflow.HTMLURL, CreatedAt: formatMinimalTimestamp(workflow.CreatedAt), + UpdatedAt: formatMinimalTimestamp(workflow.UpdatedAt), + } +} + +func convertToActionsWorkflows(workflows *github.Workflows) *ActionsWorkflowsOutput { + if workflows == nil { + return nil + } + out := &ActionsWorkflowsOutput{TotalCount: workflows.TotalCount} + for _, workflow := range workflows.Workflows { + out.Workflows = append(out.Workflows, convertToActionsWorkflow(workflow)) + } + return out +} + +func convertToActionsArtifacts(artifacts *github.ArtifactList) *ActionsArtifactsOutput { + if artifacts == nil { + return nil + } + out := &ActionsArtifactsOutput{TotalCount: artifacts.TotalCount} + for _, artifact := range artifacts.Artifacts { + if artifact == nil { + out.Artifacts = append(out.Artifacts, nil) + continue + } + item := &ActionsArtifact{ + ID: artifact.ID, Name: artifact.Name, SizeInBytes: artifact.SizeInBytes, + Expired: artifact.Expired, Digest: artifact.Digest, + CreatedAt: formatMinimalTimestamp(artifact.CreatedAt), + UpdatedAt: formatMinimalTimestamp(artifact.UpdatedAt), + ExpiresAt: formatMinimalTimestamp(artifact.ExpiresAt), + } + if run := artifact.WorkflowRun; run != nil { + item.WorkflowRun = &ActionsArtifactWorkflowRun{ + ID: run.ID, HeadBranch: run.HeadBranch, HeadSHA: run.HeadSHA, + } + } + out.Artifacts = append(out.Artifacts, item) + } + return out +} + +func convertToActionsRunUsage(usage *github.WorkflowRunUsage) *ActionsRunUsageOutput { + if usage == nil { + return nil + } + out := &ActionsRunUsageOutput{RunDurationMS: usage.RunDurationMS} + if usage.Billable == nil { + return out + } + for runner, bill := range *usage.Billable { + item := ActionsRunnerUsage{Runner: runner} + if bill != nil { + item.TotalMS, item.Jobs = bill.TotalMS, bill.Jobs + for _, job := range bill.JobRuns { + if job != nil { + item.JobRuns = append(item.JobRuns, ActionsJobRunUsage{ + JobID: job.JobID, DurationMS: job.DurationMS, + }) + } + } + } + out.Billable = append(out.Billable, item) + } + slices.SortFunc(out.Billable, func(a, b ActionsRunnerUsage) int { + return strings.Compare(a.Runner, b.Runner) + }) + return out +} + +type ActionsArtifactDownloadOutput struct { + ArtifactID int64 `json:"artifact_id"` + DownloadURL string `json:"download_url"` + Message string `json:"message"` + Note string `json:"note"` +} + +type ActionsRunLogsOutput struct { + LogsURL string `json:"logs_url"` + Message string `json:"message"` + Note string `json:"note"` + OptimizationTip string `json:"optimization_tip"` + Warning string `json:"warning"` +} + +var actionsGetOutputSchema = sync.OnceValue(func() *jsonschema.Schema { + schema := actionsOutputSchema[ActionsGetOutput]() + return schema +}) + +type ActionsRunTriggerOutput struct { + Method ActionsRunTriggerMethod `json:"method,omitempty"` + Dispatch *ActionsDispatchOutput `json:"dispatch,omitempty"` + Rerun *ActionsRunOperationOutput `json:"rerun,omitempty"` + RerunFailed *ActionsRunOperationOutput `json:"rerun_failed,omitempty"` + Cancel *ActionsRunOperationOutput `json:"cancel,omitempty"` + DeleteLogs *ActionsRunOperationOutput `json:"delete_logs,omitempty"` +} + +// Workflow dispatch inputs are arbitrary user JSON, echoed verbatim by the +// legacy response. This is the only open-ended output field. +type ActionsDispatchOutput struct { + Inputs json.RawMessage `json:"inputs"` + Message string `json:"message"` + Ref string `json:"ref"` + Status string `json:"status"` + StatusCode int `json:"status_code"` + WorkflowID string `json:"workflow_id"` + WorkflowType ActionsWorkflowType `json:"workflow_type"` +} + +type ActionsRunOperationOutput struct { + Message string `json:"message"` + RunID int64 `json:"run_id"` + Status string `json:"status"` + StatusCode int `json:"status_code"` +} + +var actionsRunTriggerOutputSchema = sync.OnceValue(func() *jsonschema.Schema { + schema := actionsOutputSchema[ActionsRunTriggerOutput]() + dispatch := schema.Properties["dispatch"] + dispatch.Properties["inputs"] = &jsonschema.Schema{ + Types: []string{"object", "null"}, + AdditionalProperties: &jsonschema.Schema{}, + } + schema.Defs["run_operation"] = actionsOutputSchema[ActionsRunOperationOutput]() + for _, field := range []string{"rerun", "rerun_failed", "cancel", "delete_logs"} { + schema.Properties[field] = &jsonschema.Schema{Ref: "#/$defs/run_operation"} + } + return schema +}) + +type ActionsJobLogsOutput struct { + Single *ActionsJobLog + Failed *ActionsFailedJobLogsOutput +} + +type ActionsJobLog struct { + Content *ActionsJobLogContent + URL *ActionsJobLogURL + Error *ActionsJobLogError +} + +type ActionsJobLogContent struct { + JobID int64 `json:"job_id"` + JobName string `json:"job_name,omitempty"` + LogsContent string `json:"logs_content"` + Message string `json:"message"` + OriginalLength int `json:"original_length"` +} + +type ActionsJobLogURL struct { + JobID int64 `json:"job_id"` + JobName string `json:"job_name,omitempty"` + LogsURL string `json:"logs_url"` + Message string `json:"message"` + Note string `json:"note"` +} + +type ActionsJobLogError struct { + Error string `json:"error"` + JobID int64 `json:"job_id"` + JobName string `json:"job_name"` +} + +func (out ActionsJobLog) MarshalJSON() ([]byte, error) { + switch { + case out.Content != nil: + return json.Marshal(out.Content) + case out.URL != nil: + return json.Marshal(out.URL) + default: + return json.Marshal(out.Error) + } +} + +type ActionsFailedJobLogsOutput struct { + FailedJobs int `json:"failed_jobs"` + Logs *[]ActionsJobLog `json:"logs,omitempty"` + Message string `json:"message"` + ReturnFormat *ActionsLogsReturnFormat `json:"return_format,omitempty"` + RunID int64 `json:"run_id"` + TotalJobs int `json:"total_jobs"` +} + +type ActionsLogsReturnFormat struct { + Content bool `json:"content"` + URLs bool `json:"urls"` +} + +func (out ActionsJobLogsOutput) MarshalJSON() ([]byte, error) { + if out.Single != nil { + return json.Marshal(out.Single) + } + return json.Marshal(out.Failed) +} + +func actionsJobLogSchema() *jsonschema.Schema { + return repositoryUnionSchema( + actionsOutputSchema[ActionsJobLogContent](), + actionsOutputSchema[ActionsJobLogURL](), + actionsOutputSchema[ActionsJobLogError](), + ) +} + +var actionsJobLogsOutputSchema = sync.OnceValue(func() *jsonschema.Schema { + failed := actionsOutputSchema[ActionsFailedJobLogsOutput]() + failed.Properties["logs"].Items = actionsJobLogSchema() + return repositoryUnionSchema( + &jsonschema.Schema{Type: "null"}, + actionsOutputSchema[ActionsJobLogContent](), + actionsOutputSchema[ActionsJobLogURL](), + failed, + ) +}) + +func actionsOutputSchema[T any]() *jsonschema.Schema { + schema, err := jsonschema.For[T](&jsonschema.ForOptions{TypeSchemas: actionsEnumSchemas()}) + if err != nil { + panic(err) + } + if schema.Defs == nil { + schema.Defs = make(map[string]*jsonschema.Schema) + } + describeActionsOutput(schema) + return schema +} + +func describeActionsOutput(schema *jsonschema.Schema) { + if schema == nil { + return + } + for name, property := range schema.Properties { + switch { + case strings.HasSuffix(name, "_at"): + property.Description = "Timestamp in RFC3339 format." + property.Format = "date-time" + case strings.HasSuffix(name, "_ms"): + property.Description = "Duration in milliseconds." + case name == "size_in_bytes": + property.Description = "Artifact size in bytes." + case name == "original_length": + property.Description = "Number of log lines before truncation." + case name == "state": + property.Description = "Workflow state, e.g. active, deleted, disabled_fork, disabled_inactivity, disabled_manually." + case name == "conclusion": + property.Description = "Completion outcome, e.g. success, failure, cancelled, skipped, timed_out, action_required." + property.Enum = inventory.EnumSchema(WorkflowConclusionValues()...).Enum + case name == "status" && schema.Properties["status_code"] != nil: + property.Description = "HTTP response status." + case name == "status": + property.Description = "Lifecycle status, e.g. queued, in_progress, completed, waiting." + // Sparse legacy minimal DTOs serialize an unknown status as "". + property.Enum = inventory.EnumSchema(append(WorkflowStatusValues(), "")...).Enum + case name == "method": + property.Description = "Operation that produced this response." + case name == "runner": + property.Description = "Runner environment name; custom environments are supported." + case name == "html_url": + property.Description = "Browser URL." + } + describeActionsOutput(property) + } + describeActionsOutput(schema.Items) + for _, variant := range schema.AnyOf { + describeActionsOutput(variant) + } + for _, variant := range schema.OneOf { + describeActionsOutput(variant) + } +} + +// Normalize only fields inspected by each legacy method. Ignored optional +// trigger values and non-string filter values retain their legacy defaults. +func normalizeActionsArguments(kind 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, &inventory.ToolInputError{Message: "invalid arguments: " + err.Error()} + } + if args == nil { + return nil, &inventory.ToolInputError{Message: "invalid arguments: arguments must be a JSON object"} + } + if err := normalizeActionsFields(args, kind); err != nil { + return nil, &inventory.ToolInputError{Message: err.Error()} + } + return json.Marshal(args) + } +} + +func normalizeActionsFields(args map[string]any, kind string) error { + for _, field := range []string{"owner", "repo"} { + if _, err := RequiredParam[string](args, field); err != nil { + return err + } + } + if kind != "logs" { + if _, err := RequiredParam[string](args, "method"); err != nil { + return err + } + } + switch kind { + case "get": + _, err := RequiredParam[string](args, "resource_id") + return err + case "list": + if _, err := OptionalParam[string](args, "resource_id"); err != nil { + return err + } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return err + } + args["page"], args["perPage"] = pagination.Page, pagination.PerPage + method := args["method"] + for _, field := range []string{"workflow_runs_filter", "workflow_jobs_filter"} { + relevant := field == "workflow_runs_filter" && method == actionsMethodListWorkflowRuns || + field == "workflow_jobs_filter" && method == actionsMethodListWorkflowJobs + if !relevant { + delete(args, field) + continue + } + filter, err := OptionalParam[map[string]any](args, field) + if err != nil { + // The typed filter retains the error until its method runs. + continue + } + if filter == nil { + delete(args, field) + continue + } + for key, value := range filter { + if _, ok := value.(string); !ok { + filter[key] = "" + } + } + } + case "trigger": + for _, field := range []string{"workflow_id", "ref"} { + if _, ok := args[field].(string); !ok { + delete(args, field) + } + } + runID, _ := OptionalIntParam(args, "run_id") + args["run_id"] = runID + _, err := OptionalParam[map[string]any](args, "inputs") + return err + case "logs": + for _, field := range []string{"job_id", "run_id"} { + value, err := OptionalIntParam(args, field) + if err != nil { + return err + } + args[field] = value + } + for _, field := range []string{"failed_only", "return_content"} { + if _, err := OptionalParam[bool](args, field); err != nil { + return err + } + } + tailLines, err := OptionalIntParam(args, "tail_lines") + if err != nil { + return err + } + if tailLines <= 0 { + tailLines = 500 + } + args["tail_lines"] = tailLines + default: + panic("unknown Actions argument kind: " + kind) + } + return nil +} diff --git a/pkg/github/typed_actions_outputs_test.go b/pkg/github/typed_actions_outputs_test.go new file mode 100644 index 0000000000..dadae042c5 --- /dev/null +++ b/pkg/github/typed_actions_outputs_test.go @@ -0,0 +1,613 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + "net/http/httptest" + "os" + "reflect" + "strings" + "testing" + "time" + + "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/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func actionsTypedSession(t *testing.T, deps ToolDependencies, protocol string) (*mcp.ClientSession, map[string]*jsonschema.Resolved) { + t.Helper() + tr := translations.NullTranslationHelper + tools := []inventory.ServerTool{ActionsList(tr), ActionsGet(tr), ActionsRunTrigger(tr), ActionsGetJobLogs(tr)} + inv, err := inventory.NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "actions", Version: "v1"}, nil) + inv.RegisterTools(context.Background(), server, deps) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + if protocol == "" || protocol == "unknown" { + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + switch req := req.(type) { + case *mcp.ListToolsRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: protocol} + case *mcp.CallToolRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: protocol} + } + return next(ctx, method, req) + } + }) + } + version := protocol + if version == "" || version == "unknown" { + version = inventory.ProtocolVersionMultiRoundTrip + } + session := connectCommentVisibilityClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 4) + schemas := make(map[string]*jsonschema.Resolved) + for _, tool := range list.Tools { + if os.Getenv("ACTIONS_LEGACY_BASELINE") == "true" { + continue + } + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Nil(t, tool.OutputSchema, tool.Name) + continue + } + require.NotNil(t, tool.OutputSchema) + 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 + } + return session, schemas +} + +var typedActionsProtocols = []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", "", "unknown"} + +type actionsWireCase struct { + name, method string + args map[string]any + path, query string + body, text string + status int +} + +func actionsWireCases() []actionsWireCase { + run := `{"id":7,"name":"CI","workflow_id":2,"run_number":3,"run_attempt":1,"status":"completed","conclusion":"failure","head_branch":"main","head_commit":{"message":"change"}}` + return []actionsWireCase{ + {"actions_list", "list_workflows", nil, "/repos/owner/repo/actions/workflows", "page=1&per_page=30", `{"total_count":1,"workflows":[{"id":2,"name":"CI","created_at":"2026-01-01T00:00:00Z"}]}`, `{"total_count":1,"workflows":[{"id":2,"name":"CI","created_at":"2026-01-01T00:00:00Z"}]}`, 200}, + {"actions_list", "list_workflow_runs", map[string]any{"workflow_runs_filter": map[string]any{"actor": "octocat", "branch": false, "event": 1}}, "/repos/owner/repo/actions/runs", "actor=octocat&page=1&per_page=30", `{"total_count":1,"workflow_runs":[` + run + `]}`, `{"total_count":1,"workflow_runs":[` + run + `]}`, 200}, + {"actions_list", "list_workflow_runs", map[string]any{"resource_id": "ci.yml", "page": "2e0", "perPage": "5.0"}, "/repos/owner/repo/actions/workflows/ci.yml/runs", "page=2&per_page=5", `{"total_count":0,"workflow_runs":[]}`, `{"total_count":0,"workflow_runs":[]}`, 200}, + {"actions_list", "list_workflow_runs", map[string]any{"resource_id": "2"}, "/repos/owner/repo/actions/workflows/2/runs", "page=1&per_page=30", `null`, `{"total_count":0,"workflow_runs":[]}`, 200}, + {"actions_list", "list_workflow_jobs", map[string]any{"resource_id": "7", "workflow_jobs_filter": map[string]any{"filter": "all"}}, "/repos/owner/repo/actions/runs/7/jobs", "filter=all&page=1&per_page=30", `{"total_count":1,"jobs":[{"id":8,"run_id":7,"name":"test","status":"completed"}]}`, `{"jobs":{"total_count":1,"jobs":[{"id":8,"run_id":7,"name":"test","status":"completed"}]}}`, 200}, + {"actions_list", "list_workflow_run_artifacts", map[string]any{"resource_id": "7", "workflow_jobs_filter": false}, "/repos/owner/repo/actions/runs/7/artifacts", "page=1&per_page=30", `{"total_count":1,"artifacts":[{"id":9,"digest":"sha256:abc","workflow_run":{"id":7}}]}`, `{"total_count":1,"artifacts":[{"id":9,"digest":"sha256:abc","workflow_run":{"id":7}}]}`, 200}, + {"actions_get", "get_workflow", map[string]any{"resource_id": "ci.yml"}, "/repos/owner/repo/actions/workflows/ci.yml", "", `{"id":2,"name":"CI"}`, `{"id":2,"name":"CI"}`, 200}, + {"actions_get", "get_workflow_run", map[string]any{"resource_id": "7"}, "/repos/owner/repo/actions/runs/7", "", run, run, 200}, + {"actions_get", "get_workflow_job", map[string]any{"resource_id": "8"}, "/repos/owner/repo/actions/jobs/8", "", `{"id":8,"steps":[{"name":"test","number":1,"started_at":"2026-01-01T00:00:00Z"}],"labels":["linux"]}`, `{"id":8,"steps":[{"name":"test","number":1,"started_at":"2026-01-01T00:00:00Z"}],"labels":["linux"]}`, 200}, + {"actions_get", "get_workflow_run_usage", map[string]any{"resource_id": "7"}, "/repos/owner/repo/actions/runs/7/timing", "", `{"billable":{"CUSTOM":{"total_ms":42,"jobs":1,"job_runs":[{"job_id":8,"duration_ms":42}]}},"run_duration_ms":42}`, `{"billable":{"CUSTOM":{"total_ms":42,"jobs":1,"job_runs":[{"job_id":8,"duration_ms":42}]}},"run_duration_ms":42}`, 200}, + {"actions_get", "get_workflow", map[string]any{"resource_id": "-2"}, "/repos/owner/repo/actions/workflows/-2", "", `{}`, `{}`, 200}, + {"actions_get", "get_workflow_job", map[string]any{"resource_id": "8"}, "/repos/owner/repo/actions/jobs/8", "", `null`, `null`, 200}, + {"actions_get", "get_workflow_run_usage", map[string]any{"resource_id": "7"}, "/repos/owner/repo/actions/runs/7/timing", "", `{}`, `{}`, 200}, + {"actions_list", "list_workflows", map[string]any{"page": 0, "perPage": 0, "workflow_runs_filter": nil}, "/repos/owner/repo/actions/workflows", "page=1&per_page=30", `{}`, `{}`, 200}, + {"actions_list", "list_workflow_run_artifacts", map[string]any{"resource_id": "7"}, "/repos/owner/repo/actions/runs/7/artifacts", "page=1&per_page=30", `{}`, `{}`, 200}, + {"actions_list", "list_workflow_runs", map[string]any{"workflow_runs_filter": map[string]any{"event": "custom", "status": "custom"}}, "/repos/owner/repo/actions/runs", "event=custom&page=1&per_page=30&status=custom", `{}`, `{"total_count":0,"workflow_runs":[]}`, 200}, + {"actions_get", "download_workflow_run_artifact", map[string]any{"resource_id": "9"}, "/repos/owner/repo/actions/artifacts/9/zip", "", "", `{"artifact_id":9,"download_url":"https://logs.example/archive","message":"Artifact is available for download","note":"The download_url provides a download link for the artifact as a ZIP archive. The link is temporary and expires after a short time."}`, 302}, + {"actions_get", "get_workflow_run_logs_url", map[string]any{"resource_id": "7"}, "/repos/owner/repo/actions/runs/7/logs", "", "", `{"logs_url":"https://logs.example/archive","message":"Workflow run logs are available for download","note":"The logs_url provides a download link for the complete workflow run logs as a ZIP archive. You can download this archive to extract and examine individual job logs.","optimization_tip":"Use: get_job_logs with parameters {run_id: 7, failed_only: true} for more efficient failed job debugging","warning":"This downloads ALL logs as a ZIP file which can be large and expensive. For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id instead."}`, 302}, + {"actions_run_trigger", "run_workflow", map[string]any{"workflow_id": "ci.yml", "ref": "main"}, "/repos/owner/repo/actions/workflows/ci.yml/dispatches", "", "", `{"inputs":null,"message":"Workflow run has been queued","ref":"main","status":"204 No Content","status_code":204,"workflow_id":"ci.yml","workflow_type":"workflow_file"}`, 204}, + {"actions_run_trigger", "run_workflow", map[string]any{"workflow_id": "2", "ref": "main", "inputs": map[string]any{"flag": true, "nested": []any{nil, 42, map[string]any{"s": "x"}}}}, "/repos/owner/repo/actions/workflows/2/dispatches", "", "", `{"inputs":{"flag":true,"nested":[null,42,{"s":"x"}]},"message":"Workflow run has been queued","ref":"main","status":"204 No Content","status_code":204,"workflow_id":"2","workflow_type":"workflow_id"}`, 204}, + {"actions_run_trigger", "rerun_workflow_run", map[string]any{"run_id": "7.0", "workflow_id": false, "ref": nil}, "/repos/owner/repo/actions/runs/7/rerun", "", "", `{"message":"Workflow run has been queued for re-run","run_id":7,"status":"201 Created","status_code":201}`, 201}, + {"actions_run_trigger", "rerun_failed_jobs", map[string]any{"run_id": "7e0"}, "/repos/owner/repo/actions/runs/7/rerun-failed-jobs", "", "", `{"message":"Failed jobs have been queued for re-run","run_id":7,"status":"201 Created","status_code":201}`, 201}, + {"actions_run_trigger", "cancel_workflow_run", map[string]any{"run_id": 7}, "/repos/owner/repo/actions/runs/7/cancel", "", `{}`, `{"message":"Workflow run has been cancelled","run_id":7,"status":"202 Accepted","status_code":202}`, 202}, + {"actions_run_trigger", "delete_workflow_run_logs", map[string]any{"run_id": 7}, "/repos/owner/repo/actions/runs/7/logs", "", "", `{"message":"Workflow run logs have been deleted","run_id":7,"status":"204 No Content","status_code":204}`, 204}, + } +} + +func actionsCaseArgs(tc actionsWireCase) map[string]any { + args := map[string]any{"owner": "owner", "repo": "repo"} + if tc.method != "" { + args["method"] = tc.method + } + maps.Copy(args, tc.args) + return args +} + +func actionsCaseDeps(t *testing.T, current **actionsWireCase, apiError bool) BaseDeps { + t.Helper() + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Host == "logs.example" { + w.Header().Set("Location", "https://logs.example/archive") + w.WriteHeader(http.StatusFound) + return + } + tc := *current + assert.Equal(t, tc.path, r.URL.Path) + assert.Equal(t, tc.query, r.URL.RawQuery) + if apiError { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"Forbidden"}`)) + return + } + if tc.method == "run_workflow" { + var payload map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + assert.Equal(t, tc.args["ref"], payload["ref"]) + assert.JSONEq(t, mustMarshalJSON(t, tc.args["inputs"]), mustMarshalJSON(t, payload["inputs"])) + } + if tc.status == 302 { + w.Header().Set("Location", "https://logs.example/archive") + } + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })}} + return BaseDeps{Client: mustNewGHClient(t, client), ContentWindowSize: 100, + RepoAccessCache: stubRepoAccessCache(nil, time.Minute)} +} + +func assertActionsWireResult(t *testing.T, result *mcp.CallToolResult, schema *jsonschema.Resolved, text string, isError bool, projected ...string) { + t.Helper() + require.Equal(t, isError, result.IsError, mustMarshalJSON(t, result)) + require.Len(t, result.Content, 1) + if schema == nil || isError { + assert.Equal(t, text, getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + return + } + if text != "null" { + require.NotNil(t, result.StructuredContent) + } + var output any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, schema.Validate(output)) + expected := text + if len(projected) != 0 { + expected = projected[0] + } + assert.JSONEq(t, expected, mustMarshalJSON(t, output)) + assert.Equal(t, mustMarshalJSON(t, output), getTextResult(t, result).Text) +} + +func TestTypedActionsWireOutputs(t *testing.T) { + cases := append(actionsWireCases(), + actionsWireCase{name: "actions_list", method: "list_workflows", path: "/repos/owner/repo/actions/workflows", query: "page=1&per_page=30", body: "null", text: "null", status: 200}, + actionsWireCase{name: "actions_list", method: "list_workflows", path: "/repos/owner/repo/actions/workflows", query: "page=1&per_page=30", body: `{"total_count":0,"workflows":[]}`, text: `{"total_count":0}`, status: 200}, + actionsWireCase{name: "actions_list", method: "list_workflow_run_artifacts", args: map[string]any{"resource_id": "7"}, path: "/repos/owner/repo/actions/runs/7/artifacts", query: "page=1&per_page=30", body: "null", text: "null", status: 200}, + actionsWireCase{name: "actions_list", method: "list_workflow_run_artifacts", args: map[string]any{"resource_id": "7"}, path: "/repos/owner/repo/actions/runs/7/artifacts", query: "page=1&per_page=30", body: `{"total_count":0,"artifacts":[]}`, text: `{"total_count":0}`, status: 200}, + actionsWireCase{name: "actions_get", method: "get_workflow_job", args: map[string]any{"resource_id": "8"}, path: "/repos/owner/repo/actions/jobs/8", body: `{}`, text: `{}`, status: 200}, + actionsWireCase{name: "actions_get", method: "get_workflow", args: map[string]any{"resource_id": "ci.yml"}, path: "/repos/owner/repo/actions/workflows/ci.yml", body: `null`, text: `null`, status: 200}, + actionsWireCase{name: "actions_get", method: "get_workflow_run_usage", args: map[string]any{"resource_id": "7"}, path: "/repos/owner/repo/actions/runs/7/timing", body: `null`, text: `null`, status: 200}, + actionsWireCase{name: "actions_list", method: "list_workflows", args: map[string]any{"page": -1, "perPage": 101}, path: "/repos/owner/repo/actions/workflows", query: "page=-1&per_page=101", body: `{}`, text: `{}`, status: 200}, + actionsWireCase{name: "actions_list", method: "list_workflows", args: map[string]any{"page": 1, "perPage": -1}, path: "/repos/owner/repo/actions/workflows", query: "page=1&per_page=-1", body: `{}`, text: `{}`, status: 200}, + ) + for _, protocol := range typedActionsProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + var current *actionsWireCase + session, schemas := actionsTypedSession(t, actionsCaseDeps(t, ¤t, false), protocol) + for _, tc := range cases { + t.Run(tc.name+"/"+tc.method+"/"+mustMarshalJSON(t, tc.args), func(t *testing.T) { + current = &tc + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: actionsCaseArgs(tc)}) + require.NoError(t, err) + projected := tc.text + switch { + case tc.method == "list_workflow_jobs": + var legacy ActionsJobsOutput + require.NoError(t, json.Unmarshal([]byte(tc.text), &legacy)) + projected = mustMarshalJSON(t, legacy.Jobs) + case tc.method == "get_workflow_job" && tc.body == "{}": + projected = `{"id":0,"run_id":0,"name":"","status":""}` + case tc.method == "get_workflow_job" && tc.body != "null": + projected = `{"id":8,"run_id":0,"name":"","status":"","steps":[{"name":"test","number":1,"status":"","started_at":"2026-01-01T00:00:00Z"}],"labels":["linux"]}` + case tc.method == "get_workflow_run_usage" && tc.body != "{}" && tc.body != "null": + projected = `{"billable":[{"runner":"CUSTOM","total_ms":42,"jobs":1,"job_runs":[{"job_id":8,"duration_ms":42}]}],"run_duration_ms":42}` + } + projected = actionsProjectedEnvelope(t, tc.method, projected) + assertActionsWireResult(t, result, schemas[tc.name], tc.text, false, projected) + }) + } + }) + } +} + +func actionsProjectedEnvelope(t *testing.T, method, projected string) string { + t.Helper() + field := map[string]string{ + "list_workflows": "workflows", "list_workflow_runs": "workflow_runs", + "list_workflow_jobs": "workflow_jobs", "list_workflow_run_artifacts": "artifacts", + "get_workflow": "workflow", "get_workflow_run": "workflow_run", "get_workflow_job": "workflow_job", + "get_workflow_run_usage": "usage", "download_workflow_run_artifact": "artifact", "get_workflow_run_logs_url": "logs", + "run_workflow": "dispatch", "rerun_workflow_run": "rerun", "rerun_failed_jobs": "rerun_failed", + "cancel_workflow_run": "cancel", "delete_workflow_run_logs": "delete_logs", + }[method] + require.NotEmpty(t, field) + envelope := map[string]any{"method": method} + if projected != "null" { + envelope[field] = json.RawMessage(projected) + } + return mustMarshalJSON(t, envelope) +} + +func TestTypedActionsWireErrors(t *testing.T) { + cases := []actionsWireCase{ + {name: "actions_list", args: map[string]any{"owner": "", "method": false}, text: "missing required parameter: owner"}, + {name: "actions_list", method: "list_workflows", args: map[string]any{"resource_id": 7}, text: "parameter resource_id is not of type string, is float64"}, + {name: "actions_list", method: "list_workflows", args: map[string]any{"page": "bad"}, text: "parameter page is not a valid number: invalid numeric value: bad"}, + {name: "actions_list", method: "list_workflow_jobs", text: "missing required parameter for method list_workflow_jobs: resource_id"}, + {name: "actions_list", method: "list_workflow_jobs", args: map[string]any{"resource_id": "bad", "workflow_jobs_filter": false}, text: `invalid resource_id, must be an integer for method list_workflow_jobs: strconv.ParseInt: parsing "bad": invalid syntax`}, + {name: "actions_list", method: "list_workflow_runs", args: map[string]any{"workflow_runs_filter": false}, text: "parameter workflow_runs_filter is not of type map[string]interface {}, is bool"}, + {name: "actions_list", method: "list_workflow_jobs", args: map[string]any{"resource_id": "7", "workflow_jobs_filter": nil}, text: "parameter workflow_jobs_filter is not of type map[string]interface {}, is "}, + {name: "actions_list", method: "list_workflow_jobs", args: map[string]any{"workflow_jobs_filter": false}, text: "missing required parameter for method list_workflow_jobs: resource_id"}, + {name: "actions_list", method: "unknown", args: map[string]any{"resource_id": "7"}, text: "unknown method: unknown"}, + {name: "actions_get", method: "get_workflow", text: "missing required parameter: resource_id"}, + {name: "actions_get", method: "get_workflow_job", args: map[string]any{"resource_id": "7.0"}, text: `invalid resource_id, must be an integer for method get_workflow_job: strconv.ParseInt: parsing "7.0": invalid syntax`}, + {name: "actions_get", method: "unknown", args: map[string]any{"resource_id": "7"}, text: "unknown method: unknown"}, + {name: "actions_run_trigger", method: "run_workflow", args: map[string]any{"workflow_id": false}, text: "workflow_id is required for run_workflow action"}, + {name: "actions_run_trigger", method: "run_workflow", args: map[string]any{"workflow_id": "ci.yml", "ref": false}, text: "ref is required for run_workflow action"}, + {name: "actions_run_trigger", method: "rerun_workflow_run", args: map[string]any{"run_id": "bad"}, text: "missing required parameter: run_id"}, + {name: "actions_run_trigger", method: "run_workflow", args: map[string]any{"inputs": nil}, text: "parameter inputs is not of type map[string]interface {}, is "}, + {name: "get_job_logs", args: map[string]any{"job_id": 1.5}, text: "parameter job_id is not a valid number: non-integer numeric value: 1.5"}, + {name: "get_job_logs", args: map[string]any{"job_id": "8", "failed_only": nil}, text: "parameter failed_only is not of type bool, is "}, + {name: "get_job_logs", args: map[string]any{"failed_only": true}, text: "run_id is required when failed_only is true"}, + {name: "get_job_logs", args: map[string]any{"job_id": -1}, text: "Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"}, + } + + for _, protocol := range typedActionsProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + var current *actionsWireCase + session, schemas := actionsTypedSession(t, actionsCaseDeps(t, ¤t, false), protocol) + for _, tc := range cases { + t.Run(tc.name+"/"+tc.text, func(t *testing.T) { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: actionsCaseArgs(tc)}) + require.NoError(t, err) + assertActionsWireResult(t, result, schemas[tc.name], tc.text, true) + }) + } + session, _ = actionsTypedSession(t, actionsCaseDeps(t, ¤t, true), protocol) + for _, tc := range actionsWireCases() { + current = &tc + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: actionsCaseArgs(tc)}) + require.NoError(t, err) + require.True(t, result.IsError) + require.Len(t, result.Content, 1) + assert.Contains(t, getTextResult(t, result).Text, "Forbidden") + assert.Nil(t, result.StructuredContent) + } + }) + } +} + +func TestTypedActionsClientErrorPrecedence(t *testing.T) { + for _, protocol := range typedActionsProtocols { + session, _ := actionsTypedSession(t, failingSearchClientDeps{}, protocol) + for _, tc := range []actionsWireCase{ + {name: "actions_list", method: "list_workflow_jobs", args: map[string]any{"resource_id": "bad", "workflow_jobs_filter": false}}, + {name: "actions_list", method: "list_workflow_runs", args: map[string]any{"workflow_runs_filter": nil}}, + {name: "actions_get", method: "unknown", args: map[string]any{"resource_id": "bad"}}, + {name: "get_job_logs", args: map[string]any{"failed_only": true}}, + {name: "actions_run_trigger", method: "unknown", args: map[string]any{"run_id": 7}}, + } { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: actionsCaseArgs(tc)}) + if os.Getenv("ACTIONS_LEGACY_BASELINE") == "true" { + require.EqualError(t, err, `calling "tools/call": failed to get GitHub client: client unavailable`) + continue + } + require.NoError(t, err) + assertActionsWireResult(t, result, nil, "failed to get GitHub client: client unavailable", true) + } + } +} + +func TestTypedActionsIFCAndAnnotations(t *testing.T) { + for _, protocol := range typedActionsProtocols { + for _, private := range []bool{false, true} { + for _, enabled := range []bool{false, true} { + for _, tc := range []actionsWireCase{ + {name: "actions_list", method: "list_workflows"}, + {name: "actions_get", method: "get_workflow", args: map[string]any{"resource_id": "ci.yml"}}, + {name: "get_job_logs", args: map[string]any{"run_id": "7e0", "failed_only": true}}, + {name: "actions_run_trigger", method: "delete_workflow_run_logs", args: map[string]any{"run_id": 7}}, + } { + t.Run(fmt.Sprintf("%s/%s/private=%t/enabled=%t", tc.name, protocol, private, enabled), func(t *testing.T) { + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/repos/owner/repo" { + _, _ = fmt.Fprintf(w, `{"private":%t}`, private) + return + } + if r.Method == http.MethodDelete { + w.WriteHeader(http.StatusNoContent) + return + } + _, _ = w.Write([]byte(`{}`)) + })}} + deps := BaseDeps{Client: mustNewGHClient(t, client), + RepoAccessCache: stubRepoAccessCache(nil, time.Minute)} + if enabled { + deps.featureChecker = featureCheckerFor(FeatureFlagIFCLabels) + } + session, schemas := actionsTypedSession(t, deps, protocol) + listed, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + for _, tool := range listed.Tools { + require.NotNil(t, tool.Annotations) + assert.Equal(t, tool.Name != "actions_run_trigger", tool.Annotations.ReadOnlyHint) + if tool.Name == "actions_run_trigger" { + require.NotNil(t, tool.Annotations.DestructiveHint) + assert.True(t, *tool.Annotations.DestructiveHint) + } + } + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: actionsCaseArgs(tc)}) + require.NoError(t, err) + text := getTextResult(t, result).Text + projected := text + if tc.method != "" { + legacy := "{}" + if tc.method == "delete_workflow_run_logs" { + legacy = `{"message":"Workflow run logs have been deleted","run_id":7,"status":"204 No Content","status_code":204}` + } + projected = actionsProjectedEnvelope(t, tc.method, legacy) + text = legacy + } + assertActionsWireResult(t, result, schemas[tc.name], text, false, projected) + if !enabled || tc.name == "actions_run_trigger" { + assert.Nil(t, result.Meta["ifc"]) + return + } + label := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", label["integrity"]) + confidentiality := "public" + if private { + confidentiality = "private" + } + assert.Equal(t, confidentiality, label["confidentiality"]) + }) + } + } + } + } +} + +func TestTypedActionsMinimalProjection(t *testing.T) { + tests := []struct { + actionsWireCase + raw any + projected string + }{ + { + actionsWireCase: actionsWireCase{name: "actions_get", method: "get_workflow", args: map[string]any{"resource_id": "ci.yml"}, path: "/repos/owner/repo/actions/workflows/ci.yml", status: 200}, + raw: &github.Workflow{ + ID: new(int64(2)), Name: new("CI"), State: new("active"), Path: new(".github/workflows/ci.yml"), + NodeID: new("node"), URL: new("https://api.github.com/workflows/2"), + HTMLURL: new("https://github.com/owner/repo/actions/workflows/ci.yml"), BadgeURL: new("https://badge"), + }, + projected: `{"id":2,"name":"CI","state":"active","path":".github/workflows/ci.yml","html_url":"https://github.com/owner/repo/actions/workflows/ci.yml"}`, + }, + { + actionsWireCase: actionsWireCase{name: "actions_list", method: "list_workflows", path: "/repos/owner/repo/actions/workflows", query: "page=1&per_page=30", status: 200}, + raw: &github.Workflows{TotalCount: new(1), Workflows: []*github.Workflow{{ + ID: new(int64(2)), Name: new("CI"), URL: new("https://api.github.com/workflows/2"), BadgeURL: new("https://badge"), + }}}, + projected: `{"total_count":1,"workflows":[{"id":2,"name":"CI"}]}`, + }, + { + actionsWireCase: actionsWireCase{name: "actions_list", method: "list_workflow_run_artifacts", args: map[string]any{"resource_id": "7"}, path: "/repos/owner/repo/actions/runs/7/artifacts", query: "page=1&per_page=30", status: 200}, + raw: &github.ArtifactList{TotalCount: new(int64(1)), Artifacts: []*github.Artifact{{ + ID: new(int64(9)), Name: new("build"), SizeInBytes: new(int64(0)), Expired: new(false), + Digest: new("sha256:abc"), NodeID: new("node"), URL: new("https://api.github.com/artifacts/9"), + ArchiveDownloadURL: new("https://api.github.com/artifacts/9/zip"), + WorkflowRun: &github.ArtifactWorkflowRun{ID: new(int64(7)), HeadBranch: new("main"), HeadSHA: new("abc")}, + }}}, + projected: `{"total_count":1,"artifacts":[{"id":9,"name":"build","size_in_bytes":0,"expired":false,"digest":"sha256:abc","workflow_run":{"id":7,"head_branch":"main","head_sha":"abc"}}]}`, + }, + { + actionsWireCase: actionsWireCase{name: "actions_get", method: "get_workflow_job", args: map[string]any{"resource_id": "8"}, path: "/repos/owner/repo/actions/jobs/8", status: 200}, + raw: &github.WorkflowJob{ + ID: new(int64(8)), RunID: new(int64(7)), Name: new("test"), Status: new("completed"), Conclusion: new("failure"), + NodeID: new("node"), URL: new("https://api.github.com/jobs/8"), RunURL: new("https://api.github.com/runs/7"), + HTMLURL: new("https://github.com/owner/repo/actions/runs/7/job/8"), + }, + projected: `{"id":8,"run_id":7,"name":"test","status":"completed","conclusion":"failure","html_url":"https://github.com/owner/repo/actions/runs/7/job/8"}`, + }, + { + actionsWireCase: actionsWireCase{name: "actions_get", method: "get_workflow_run_usage", args: map[string]any{"resource_id": "7"}, path: "/repos/owner/repo/actions/runs/7/timing", status: 200}, + raw: &github.WorkflowRunUsage{RunDurationMS: new(int64(42)), Billable: &github.WorkflowRunBillMap{ + "CUSTOM": {TotalMS: new(int64(42)), Jobs: new(1), JobRuns: []*github.WorkflowRunJobRun{{JobID: new(8), DurationMS: new(int64(42))}}}, + "ANOTHER": nil, + }}, + projected: `{"billable":[{"runner":"ANOTHER"},{"runner":"CUSTOM","total_ms":42,"jobs":1,"job_runs":[{"job_id":8,"duration_ms":42}]}],"run_duration_ms":42}`, + }, + } + for _, protocol := range typedActionsProtocols { + for _, tc := range tests { + t.Run(protocol+"/"+tc.method, func(t *testing.T) { + current := tc.actionsWireCase + current.body = mustMarshalJSON(t, tc.raw) + current.text = current.body + currentCase := ¤t + session, schemas := actionsTypedSession(t, actionsCaseDeps(t, ¤tCase, false), protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: current.name, Arguments: actionsCaseArgs(current)}) + require.NoError(t, err) + assertActionsWireResult(t, result, schemas[current.name], current.text, false, actionsProjectedEnvelope(t, current.method, tc.projected)) + }) + } + } +} + +func TestActionsConcreteSchemas(t *testing.T) { + for name, schema := range map[string]*jsonschema.Schema{ + "list": actionsListOutputSchema(), "get": actionsGetOutputSchema(), + "trigger": actionsRunTriggerOutputSchema(), "logs": actionsJobLogsOutputSchema(), + } { + t.Run(name, func(t *testing.T) { + if name != "logs" { + assert.Equal(t, "object", schema.Type) + assert.Empty(t, schema.AnyOf) + assert.Empty(t, schema.OneOf) + require.NotEmpty(t, schema.Properties["method"].Enum) + } + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + if name != "logs" { + require.Error(t, resolved.Validate(nil)) + } + for _, raw := range []string{`[]`, `42`, `{"unknown":true}`, `{"id":"wrong"}`, `{"jobs":"wrong"}`, `{"logs_url":false}`, `{"method":"unknown"}`} { + var invalid any + require.NoError(t, json.Unmarshal([]byte(raw), &invalid)) + require.Error(t, resolved.Validate(invalid), raw) + } + }) + } + seen := make(map[reflect.Type]bool) + var check func(reflect.Type) + check = func(typ reflect.Type) { + if seen[typ] || typ == reflect.TypeFor[json.RawMessage]() { + return + } + seen[typ] = true + switch typ.Kind() { + case reflect.Interface: + t.Errorf("untyped Actions output: %s", typ) + case reflect.Map: + t.Errorf("map Actions output: %s", typ) + case reflect.Array, reflect.Slice, reflect.Pointer: + check(typ.Elem()) + case reflect.Struct: + assert.NotEqual(t, "github.com/google/go-github/v92/github", typ.PkgPath(), "raw GitHub output: %s", typ) + for field := range typ.Fields() { + if field.IsExported() { + check(field.Type) + } + } + } + } + check(reflect.TypeFor[ActionsListOutput]()) + check(reflect.TypeFor[ActionsGetOutput]()) + check(reflect.TypeFor[ActionsRunTriggerOutput]()) + check(reflect.TypeFor[ActionsJobLogsOutput]()) +} + +func TestActionsOutputSchemasCached(t *testing.T) { + for _, schema := range []func() *jsonschema.Schema{ + actionsListOutputSchema, actionsGetOutputSchema, actionsRunTriggerOutputSchema, actionsJobLogsOutputSchema, + } { + assert.Same(t, schema(), schema()) + } +} + +func TestActionsAdvertisedInputMetadata(t *testing.T) { + tr := translations.NullTranslationHelper + list := ActionsList(tr).Tool.InputSchema.(*jsonschema.Schema) + get := ActionsGet(tr).Tool.InputSchema.(*jsonschema.Schema) + trigger := ActionsRunTrigger(tr).Tool.InputSchema.(*jsonschema.Schema) + assert.Equal(t, []any{"list_workflows", "list_workflow_runs", "list_workflow_jobs", "list_workflow_run_artifacts"}, list.Properties["method"].Enum) + assert.Equal(t, []any{"get_workflow", "get_workflow_run", "get_workflow_job", "download_workflow_run_artifact", "get_workflow_run_usage", "get_workflow_run_logs_url"}, get.Properties["method"].Enum) + assert.Equal(t, []any{"run_workflow", "rerun_workflow_run", "rerun_failed_jobs", "cancel_workflow_run", "delete_workflow_run_logs"}, trigger.Properties["method"].Enum) + runs := list.Properties["workflow_runs_filter"] + assert.Equal(t, "object", runs.Type) + assert.Empty(t, runs.AnyOf) + assert.Len(t, runs.Properties["event"].Enum, 32) + assert.Equal(t, []any{"queued", "in_progress", "completed", "requested", "waiting"}, runs.Properties["status"].Enum) + assert.Equal(t, []any{"latest", "all"}, list.Properties["workflow_jobs_filter"].Properties["filter"].Enum) + require.NotNil(t, list.Properties["page"].Minimum) + require.NotNil(t, list.Properties["perPage"].Minimum) + require.NotNil(t, list.Properties["perPage"].Maximum) + assert.Equal(t, 1.0, *list.Properties["page"].Minimum) + assert.Equal(t, 1.0, *list.Properties["perPage"].Minimum) + assert.Equal(t, 100.0, *list.Properties["perPage"].Maximum) + assert.Equal(t, "The action to perform", list.Properties["method"].Description) +} + +func TestActionsOutputEnumValues(t *testing.T) { + get := actionsGetOutputSchema() + assert.Equal(t, []any{"active", "deleted", "disabled_fork", "disabled_inactivity", "disabled_manually"}, get.Properties["workflow"].Properties["state"].Enum) + assert.Equal(t, inventory.EnumSchema(append(WorkflowStatusValues(), "")...).Enum, get.Properties["workflow_job"].Properties["status"].Enum) + assert.Equal(t, inventory.EnumSchema(WorkflowConclusionValues()...).Enum, get.Properties["workflow_run"].Properties["conclusion"].Enum) + for _, name := range []string{"workflow", "workflow_run", "workflow_job"} { + schema := get.Properties[name] + assert.Equal(t, "date-time", schema.Properties["created_at"].Format) + assert.Equal(t, "Timestamp in RFC3339 format.", schema.Properties["created_at"].Description) + } + usage := get.Properties["usage"] + assert.Equal(t, "Duration in milliseconds.", usage.Properties["run_duration_ms"].Description) + assert.Equal(t, "Duration in milliseconds.", usage.Properties["billable"].Items.Properties["total_ms"].Description) +} + +func TestTypedActionsJobLogWireOutputs(t *testing.T) { + logServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("first\nsecond\nthird")) + })) + defer logServer.Close() + const note = "The logs_url provides a download link for the individual job logs in plain text format. Use return_content=true to get the actual log content." + for _, protocol := range typedActionsProtocols { + for _, outcome := range []string{"single", "failed", "partial", "none"} { + for _, content := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/%s/content=%t", protocol, outcome, content), func(t *testing.T) { + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/jobs"): + body := `{"total_count":2,"jobs":[{"id":8,"name":"test","conclusion":"failure"},{"id":9,"name":"build","conclusion":"failure"}]}` + if outcome == "none" { + body = `{"total_count":1,"jobs":[{"id":8,"conclusion":"success"}]}` + } + _, _ = w.Write([]byte(body)) + case outcome == "partial" && strings.HasSuffix(r.URL.Path, "/9/logs"): + w.WriteHeader(403) + _, _ = w.Write([]byte(`{"message":"Forbidden"}`)) + default: + w.Header().Set("Location", logServer.URL) + w.WriteHeader(302) + } + })}} + deps := BaseDeps{Client: mustNewGHClient(t, client), ContentWindowSize: 100, + RepoAccessCache: stubRepoAccessCache(nil, time.Minute)} + session, schemas := actionsTypedSession(t, deps, protocol) + args := map[string]any{"owner": "owner", "repo": "repo", "return_content": content, "tail_lines": "2.0"} + if outcome == "single" { + args["job_id"] = "8e0" + } else { + args["run_id"], args["failed_only"] = "7", true + } + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "get_job_logs", Arguments: args}) + require.NoError(t, err) + text := getTextResult(t, result).Text + assertActionsWireResult(t, result, schemas["get_job_logs"], text, false) + var payload map[string]any + require.NoError(t, json.Unmarshal([]byte(text), &payload)) + switch { + case outcome == "none": + assertActionsLogText(t, schemas["get_job_logs"], `{"failed_jobs":0,"message":"No failed jobs found in this workflow run","run_id":7,"total_jobs":1}`, text) + case outcome == "single" && content: + assertActionsLogText(t, schemas["get_job_logs"], `{"job_id":8,"logs_content":"second\nthird","message":"Job logs content retrieved successfully","original_length":3}`, text) + case outcome == "single": + assertActionsLogText(t, schemas["get_job_logs"], fmt.Sprintf(`{"job_id":8,"logs_url":%q,"message":"Job logs are available for download","note":%q}`, logServer.URL, note), text) + default: + logs := payload["logs"].([]any) + require.Len(t, logs, 2) + if outcome == "partial" { + assert.Contains(t, logs[1].(map[string]any)["error"], "Forbidden") + } + + require.Equal(t, float64(2), payload["failed_jobs"]) + require.Equal(t, "test", logs[0].(map[string]any)["job_name"]) + } + }) + } + } + } +} + +func assertActionsLogText(t *testing.T, schema *jsonschema.Resolved, expected, actual string) { + t.Helper() + if schema == nil { + require.Equal(t, expected, actual) + } else { + require.JSONEq(t, expected, actual) + } +}