diff --git a/README.md b/README.md index 471b731..91311de 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,12 @@ make install Pass the Gitea host and access token as command-line flags or environment variables, flags take precedence. Run `gitea-mcp --help` for the full list of flags and environment variables. Logs are written to `$HOME/.gitea-mcp/gitea-mcp.log`, add `-d` for debug logging. +### MCP protocol and HTTP transport + +The server supports MCP up to `2026-07-28` and negotiates down to the client's version, advertising only the `tools` capability. Tool and Gitea failures return a `tools/call` result with `result.isError: true`, while malformed requests and server faults stay JSON-RPC errors. + +HTTP is always stateless: `/mcp` accepts POST only, without `Mcp-Session-Id`, standalone SSE or `Last-Event-ID` resumability. Origins are validated, and reverse proxies must forward `Mcp-Protocol-Version`, `Mcp-Method` and `Mcp-Name` unchanged. `Authorization: Bearer ` and `Authorization: token ` pass a Gitea credential per request, which is credential passthrough rather than MCP OAuth. + ### Claude Code Runs the server through `go run` and requires [Go](https://go.dev): @@ -129,62 +135,62 @@ Once configured, try `list all my repositories` in the chat box. ## Available Tools -| Tool | Scope | Access | Description | -| :--------------------------- | :----------- | :----- | :----------------------------------------------------------------------------------------- | -| get_gitea_mcp_server_version | version | Read | Get the Gitea MCP server version | -| get_me | user | Read | Get the current authenticated user | -| get_user_orgs | user | Read | List the current user's organizations | -| search_users | search | Read | Search for users | -| search_org_teams | search | Read | Search teams within an organization | -| search_repos | search | Read | Search for repositories | -| search_issues | search | Read | Search issues and pull requests across repositories | -| notification_read | notification | Read | Read notifications: list (optionally scoped to a repo) or get a thread by ID | -| notification_write | notification | Write | Mark a notification or all notifications as read | -| label_read | label | Read | Read repository or organization labels | -| label_write | label | Write | Write labels (repo or org): create, edit, delete | -| milestone_read | milestone | Read | Read milestones: get one or list | -| milestone_write | milestone | Write | Write milestones: create, update, delete | -| wiki_read | wiki | Read | Read wiki: list pages, get content, revision history | -| wiki_write | wiki | Write | Write wiki pages: create, update, delete | -| timetracking_read | timetracking | Read | Read time tracking: issue/repo times, active stopwatches, your tracked times | -| timetracking_write | timetracking | Write | Write time tracking: stopwatches and entries | -| package_read | packages | Read | Read package registry: list packages, list versions, or get a version | -| package_write | packages | Write | Delete a package version (irreversible) | -| list_issues | issue | Read | List repository issues | -| attachment_read | issue | Read | Read issue/comment attachments: list metadata, get metadata, or download content | -| issue_read | issue | Read | Read issue: details, comments, or labels | -| issue_write | issue | Write | Write issues: create, update, manage comments and labels | -| list_pull_requests | pull_request | Read | List repository pull requests | -| pull_request_read | pull_request | Read | Read pull request: details, diff, files, status, reviews, review comments | +| Tool | Scope | Access | Description | +| :--------------------------- | :----------- | :----- | :---------- | +| get_gitea_mcp_server_version | version | Read | Get the Gitea MCP server version | +| get_me | user | Read | Get the current authenticated user | +| get_user_orgs | user | Read | List the current user's organizations | +| search_users | search | Read | Search for users | +| search_org_teams | search | Read | Search teams within an organization | +| search_repos | search | Read | Search for repositories | +| search_issues | search | Read | Search issues and pull requests across repositories | +| notification_read | notification | Read | Read notifications: list (optionally scoped to a repo) or get a thread by ID | +| notification_write | notification | Write | Mark a notification or all notifications as read | +| label_read | label | Read | Read repository or organization labels | +| label_write | label | Write | Write labels (repo or org): create, edit, delete | +| milestone_read | milestone | Read | Read milestones: get one or list | +| milestone_write | milestone | Write | Write milestones: create, update, delete | +| wiki_read | wiki | Read | Read wiki: list pages, get content, revision history | +| wiki_write | wiki | Write | Write wiki pages: create, update, delete | +| timetracking_read | timetracking | Read | Read time tracking: issue/repo times, active stopwatches, your tracked times | +| timetracking_write | timetracking | Write | Write time tracking: stopwatches and entries | +| package_read | packages | Read | Read package registry: list packages, list versions, or get a version | +| package_write | packages | Write | Delete a package version (irreversible) | +| list_issues | issue | Read | List repository issues | +| attachment_read | issue | Read | Read issue/comment attachments: list metadata, get metadata, or download content | +| issue_read | issue | Read | Read issue: details, comments, or labels | +| issue_write | issue | Write | Write issues: create, update, manage comments and labels | +| list_pull_requests | pull_request | Read | List repository pull requests | +| pull_request_read | pull_request | Read | Read pull request: details, diff, files, status, reviews, review comments | | pull_request_write | pull_request | Write | Write pull requests: create, update, close, reopen, merge, update branch, manage reviewers | -| pull_request_review_write | pull_request | Write | Write PR reviews: create, submit, delete, dismiss, reply to and resolve review comments | -| actions_config_read | actions | Read | Read Actions secrets and variables | -| actions_config_write | actions | Write | Write Actions secrets and variables: upsert, create, update, delete | -| actions_run_read | actions | Read | Read Actions workflows, runs, jobs, logs, and artifacts | -| actions_run_write | actions | Write | Write Actions runs: dispatch, cancel, rerun | -| create_repo | repository | Write | Create a new repository | -| fork_repo | repository | Write | Fork a repository | -| list_my_repos | repository | Read | List repositories owned by the current user | -| list_org_repos | repository | Read | List repositories in an organization | -| get_repository_tree | repository | Read | Get the repository file tree | -| get_file_contents | file | Read | Get file content and metadata | -| get_dir_contents | file | Read | Get the entries in a directory | -| create_or_update_file | file | Write | Create or update a file (provide sha to update an existing file) | -| delete_file | file | Write | Delete a file | -| create_branch | branch | Write | Create a new branch | -| delete_branch | branch | Write | Delete a branch | -| list_branches | branch | Read | List repository branches | -| create_tag | tag | Write | Create a tag | -| delete_tag | tag | Write | Delete a tag | -| get_tag | tag | Read | Get tag details | -| list_tags | tag | Read | List repository tags | -| list_commits | commit | Read | List repository commits | -| get_commit | commit | Read | Get commit details | -| create_release | release | Write | Create a release | -| delete_release | release | Write | Delete a release | -| get_release | release | Read | Get a release by ID | -| get_latest_release | release | Read | Get the latest release | -| list_releases | release | Read | List repository releases | +| pull_request_review_write | pull_request | Write | Write PR reviews: create, submit, delete, dismiss, reply to and resolve review comments | +| actions_config_read | actions | Read | Read Actions secrets and variables | +| actions_config_write | actions | Write | Write Actions secrets and variables: upsert, create, update, delete | +| actions_run_read | actions | Read | Read Actions workflows, runs, jobs, logs, and artifacts | +| actions_run_write | actions | Write | Write Actions runs: dispatch, cancel, rerun | +| create_repo | repository | Write | Create a new repository | +| fork_repo | repository | Write | Fork a repository | +| list_my_repos | repository | Read | List repositories owned by the current user | +| list_org_repos | repository | Read | List repositories in an organization | +| get_repository_tree | repository | Read | Get the repository file tree | +| get_file_contents | file | Read | Get file content and metadata | +| get_dir_contents | file | Read | Get the entries in a directory | +| create_or_update_file | file | Write | Create or update a file (provide sha to update an existing file) | +| delete_file | file | Write | Delete a file | +| create_branch | branch | Write | Create a new branch | +| delete_branch | branch | Write | Delete a branch | +| list_branches | branch | Read | List repository branches | +| create_tag | tag | Write | Create a tag | +| delete_tag | tag | Write | Delete a tag | +| get_tag | tag | Read | Get tag details | +| list_tags | tag | Read | List repository tags | +| list_commits | commit | Read | List repository commits | +| get_commit | commit | Read | Get commit details | +| create_release | release | Write | Create a release | +| delete_release | release | Write | Delete a release | +| get_release | release | Read | Get a release by ID | +| get_latest_release | release | Read | Get the latest release | +| list_releases | release | Read | List repository releases | > **Note:** Several tools are consolidated, action-based tools, a single tool exposes multiple operations through a `method` parameter. Tools with `Write` access are hidden when the server runs in read-only mode (`-r` / `GITEA_READONLY`), and the exposed tool set can be filtered by scope with `-S` / `--scope` (`GITEA_SCOPES`) and/or by individual tool name with `-O` / `--tools` (`GITEA_TOOLS`). diff --git a/README.zh-cn.md b/README.zh-cn.md index 5e64bfb..deb926a 100644 --- a/README.zh-cn.md +++ b/README.zh-cn.md @@ -20,6 +20,12 @@ make install Gitea 主机和访问令牌可通过命令行参数或环境变量提供,命令行参数优先。运行 `gitea-mcp --help` 可查看完整的参数与环境变量列表。日志写入 `$HOME/.gitea-mcp/gitea-mcp.log`,加上 `-d` 可启用调试日志。 +### MCP 协议与 HTTP 传输 + +服务器支持最高至 `2026-07-28` 的 MCP 协议,并向下协商到客户端的版本,仅声明 `tools` 能力。工具和 Gitea 执行失败会在 `tools/call` 结果中返回并设置 `result.isError: true`,格式错误的请求和服务器故障仍返回 JSON-RPC 错误。 + +HTTP 传输固定为无状态:`/mcp` 仅接受 POST,没有 `Mcp-Session-Id`、独立 SSE 和 `Last-Event-ID` 断点续传。服务器会验证来源,反向代理必须原样转发 `Mcp-Protocol-Version`、`Mcp-Method` 和 `Mcp-Name`。`Authorization: Bearer <令牌>` 和 `Authorization: token <令牌>` 会在每个请求中传递 Gitea 凭据,这是凭据透传,而不是 MCP OAuth。 + ### Claude Code 通过 `go run` 运行服务器,需要安装 [Go](https://go.dev): @@ -129,62 +135,62 @@ Cursor 等客户端可使用 stdio 命令: ## 可用工具 -| 工具 | 范围 | 访问 | 描述 | -| :--------------------------- | :----------- | :- | :--------------------------------- | -| get_gitea_mcp_server_version | version | 读取 | 获取 Gitea MCP 服务器版本 | -| get_me | user | 读取 | 获取当前已认证用户 | -| get_user_orgs | user | 读取 | 列出当前用户的组织 | -| search_users | search | 读取 | 搜索用户 | -| search_org_teams | search | 读取 | 搜索组织中的团队 | -| search_repos | search | 读取 | 搜索仓库 | -| search_issues | search | 读取 | 跨仓库搜索问题和拉取请求 | -| notification_read | notification | 读取 | 读取通知:列出(可限定仓库)或按 ID 获取会话 | -| notification_write | notification | 写入 | 将某条或全部通知标记为已读 | -| label_read | label | 读取 | 读取仓库或组织标签 | -| label_write | label | 写入 | 写入标签(仓库或组织):创建、编辑、删除 | -| milestone_read | milestone | 读取 | 读取里程碑:获取单个或列出 | -| milestone_write | milestone | 写入 | 写入里程碑:创建、更新、删除 | -| wiki_read | wiki | 读取 | 读取 Wiki:列出页面、获取内容、修订历史 | -| wiki_write | wiki | 写入 | 写入 Wiki 页面:创建、更新、删除 | -| timetracking_read | timetracking | 读取 | 读取时间跟踪:问题/仓库耗时、活动计时器、我的跟踪记录 | -| timetracking_write | timetracking | 写入 | 写入时间跟踪:计时器和记录条目 | -| package_read | packages | 读取 | 读取软件包注册表:列出软件包、列出版本或获取某个版本 | -| package_write | packages | 写入 | 删除软件包版本(不可恢复) | -| list_issues | issue | 读取 | 列出仓库问题 | -| attachment_read | issue | 读取 | 读取问题/评论附件:列出元数据、获取元数据或下载内容 | -| issue_read | issue | 读取 | 读取问题:详情、评论或标签 | -| issue_write | issue | 写入 | 写入问题:创建、更新、管理评论和标签 | -| list_pull_requests | pull_request | 读取 | 列出仓库拉取请求 | -| pull_request_read | pull_request | 读取 | 读取拉取请求:详情、差异、变更文件、头部提交状态、审查、审查评论 | +| 工具 | 范围 | 访问 | 描述 | +| :--------------------------- | :----------- | :--- | :--- | +| get_gitea_mcp_server_version | version | 读取 | 获取 Gitea MCP 服务器版本 | +| get_me | user | 读取 | 获取当前已认证用户 | +| get_user_orgs | user | 读取 | 列出当前用户的组织 | +| search_users | search | 读取 | 搜索用户 | +| search_org_teams | search | 读取 | 搜索组织中的团队 | +| search_repos | search | 读取 | 搜索仓库 | +| search_issues | search | 读取 | 跨仓库搜索问题和拉取请求 | +| notification_read | notification | 读取 | 读取通知:列出(可限定仓库)或按 ID 获取会话 | +| notification_write | notification | 写入 | 将某条或全部通知标记为已读 | +| label_read | label | 读取 | 读取仓库或组织标签 | +| label_write | label | 写入 | 写入标签(仓库或组织):创建、编辑、删除 | +| milestone_read | milestone | 读取 | 读取里程碑:获取单个或列出 | +| milestone_write | milestone | 写入 | 写入里程碑:创建、更新、删除 | +| wiki_read | wiki | 读取 | 读取 Wiki:列出页面、获取内容、修订历史 | +| wiki_write | wiki | 写入 | 写入 Wiki 页面:创建、更新、删除 | +| timetracking_read | timetracking | 读取 | 读取时间跟踪:问题/仓库耗时、活动计时器、我的跟踪记录 | +| timetracking_write | timetracking | 写入 | 写入时间跟踪:计时器和记录条目 | +| package_read | packages | 读取 | 读取软件包注册表:列出软件包、列出版本或获取某个版本 | +| package_write | packages | 写入 | 删除软件包版本(不可恢复) | +| list_issues | issue | 读取 | 列出仓库问题 | +| attachment_read | issue | 读取 | 读取问题/评论附件:列出元数据、获取元数据或下载内容 | +| issue_read | issue | 读取 | 读取问题:详情、评论或标签 | +| issue_write | issue | 写入 | 写入问题:创建、更新、管理评论和标签 | +| list_pull_requests | pull_request | 读取 | 列出仓库拉取请求 | +| pull_request_read | pull_request | 读取 | 读取拉取请求:详情、差异、变更文件、头部提交状态、审查、审查评论 | | pull_request_write | pull_request | 写入 | 写入拉取请求:创建、更新、关闭、重新打开、合并、更新分支、管理审查者 | | pull_request_review_write | pull_request | 写入 | 写入 PR 审查:创建、提交、删除、驳回、回复和解决审查评论 | -| actions_config_read | actions | 读取 | 读取 Actions 密钥和变量 | -| actions_config_write | actions | 写入 | 写入 Actions 密钥和变量:更新插入、创建、更新、删除 | -| actions_run_read | actions | 读取 | 读取 Actions 工作流、运行、作业、日志和构件 | -| actions_run_write | actions | 写入 | 写入 Actions 运行:触发、取消、重新运行 | -| create_repo | repository | 写入 | 创建新仓库 | -| fork_repo | repository | 写入 | 复刻仓库 | -| list_my_repos | repository | 读取 | 列出当前用户拥有的仓库 | -| list_org_repos | repository | 读取 | 列出组织中的仓库 | -| get_repository_tree | repository | 读取 | 获取仓库文件树 | -| get_file_contents | file | 读取 | 获取文件内容和元数据 | -| get_dir_contents | file | 读取 | 获取目录中的条目 | -| create_or_update_file | file | 写入 | 创建或更新文件(提供 sha 以更新现有文件) | -| delete_file | file | 写入 | 删除文件 | -| create_branch | branch | 写入 | 创建新分支 | -| delete_branch | branch | 写入 | 删除分支 | -| list_branches | branch | 读取 | 列出仓库分支 | -| create_tag | tag | 写入 | 创建标签 | -| delete_tag | tag | 写入 | 删除标签 | -| get_tag | tag | 读取 | 获取标签详情 | -| list_tags | tag | 读取 | 列出仓库标签 | -| list_commits | commit | 读取 | 列出仓库提交 | -| get_commit | commit | 读取 | 获取提交详情 | -| create_release | release | 写入 | 创建版本发布 | -| delete_release | release | 写入 | 删除版本发布 | -| get_release | release | 读取 | 按 ID 获取版本发布 | -| get_latest_release | release | 读取 | 获取最新版本发布 | -| list_releases | release | 读取 | 列出仓库版本发布 | +| actions_config_read | actions | 读取 | 读取 Actions 密钥和变量 | +| actions_config_write | actions | 写入 | 写入 Actions 密钥和变量:更新插入、创建、更新、删除 | +| actions_run_read | actions | 读取 | 读取 Actions 工作流、运行、作业、日志和构件 | +| actions_run_write | actions | 写入 | 写入 Actions 运行:触发、取消、重新运行 | +| create_repo | repository | 写入 | 创建新仓库 | +| fork_repo | repository | 写入 | 复刻仓库 | +| list_my_repos | repository | 读取 | 列出当前用户拥有的仓库 | +| list_org_repos | repository | 读取 | 列出组织中的仓库 | +| get_repository_tree | repository | 读取 | 获取仓库文件树 | +| get_file_contents | file | 读取 | 获取文件内容和元数据 | +| get_dir_contents | file | 读取 | 获取目录中的条目 | +| create_or_update_file | file | 写入 | 创建或更新文件(提供 sha 以更新现有文件) | +| delete_file | file | 写入 | 删除文件 | +| create_branch | branch | 写入 | 创建新分支 | +| delete_branch | branch | 写入 | 删除分支 | +| list_branches | branch | 读取 | 列出仓库分支 | +| create_tag | tag | 写入 | 创建标签 | +| delete_tag | tag | 写入 | 删除标签 | +| get_tag | tag | 读取 | 获取标签详情 | +| list_tags | tag | 读取 | 列出仓库标签 | +| list_commits | commit | 读取 | 列出仓库提交 | +| get_commit | commit | 读取 | 获取提交详情 | +| create_release | release | 写入 | 创建版本发布 | +| delete_release | release | 写入 | 删除版本发布 | +| get_release | release | 读取 | 按 ID 获取版本发布 | +| get_latest_release | release | 读取 | 获取最新版本发布 | +| list_releases | release | 读取 | 列出仓库版本发布 | > **说明:** 部分工具是聚合的、基于操作的工具,单个工具通过 `method` 参数暴露多个操作。当服务器以只读模式运行时(`-r` / `GITEA_READONLY`),访问为「写入」的工具会被隐藏;可通过 `-S` / `--scope`(`GITEA_SCOPES`)按范围过滤,或通过 `-O` / `--tools`(`GITEA_TOOLS`)按工具名称过滤对外暴露的工具集合。 diff --git a/README.zh-tw.md b/README.zh-tw.md index d9d8e98..9dc812e 100644 --- a/README.zh-tw.md +++ b/README.zh-tw.md @@ -20,6 +20,12 @@ make install Gitea 主機與存取令牌可透過命令列參數或環境變數提供,命令列參數優先。執行 `gitea-mcp --help` 可查看完整的參數與環境變數列表。日誌寫入 `$HOME/.gitea-mcp/gitea-mcp.log`,加上 `-d` 可啟用除錯日誌。 +### MCP 協定與 HTTP 傳輸 + +伺服器支援最高至 `2026-07-28` 的 MCP 協定,並向下協商到客戶端的版本,僅宣告 `tools` 能力。工具與 Gitea 執行失敗會在 `tools/call` 結果中回傳並設定 `result.isError: true`,格式錯誤的請求與伺服器故障仍回傳 JSON-RPC 錯誤。 + +HTTP 傳輸固定為無狀態:`/mcp` 只接受 POST,沒有 `Mcp-Session-Id`、獨立 SSE 與 `Last-Event-ID` 斷點續傳。伺服器會驗證來源,反向代理必須原樣轉發 `Mcp-Protocol-Version`、`Mcp-Method` 與 `Mcp-Name`。`Authorization: Bearer <令牌>` 與 `Authorization: token <令牌>` 會在每次請求中傳遞 Gitea 憑證,這是憑證透傳,而不是 MCP OAuth。 + ### Claude Code 透過 `go run` 執行伺服器,需要安裝 [Go](https://go.dev): @@ -129,62 +135,62 @@ Cursor 等客戶端可使用 stdio 命令: ## 可用工具 -| 工具 | 範圍 | 存取 | 描述 | -| :--------------------------- | :----------- | :- | :--------------------------------- | -| get_gitea_mcp_server_version | version | 讀取 | 取得 Gitea MCP 伺服器版本 | -| get_me | user | 讀取 | 取得目前已認證用戶 | -| get_user_orgs | user | 讀取 | 列出目前用戶的組織 | -| search_users | search | 讀取 | 搜尋用戶 | -| search_org_teams | search | 讀取 | 搜尋組織中的團隊 | -| search_repos | search | 讀取 | 搜尋倉庫 | -| search_issues | search | 讀取 | 跨倉庫搜尋問題和拉取請求 | -| notification_read | notification | 讀取 | 讀取通知:列出(可限定倉庫)或依 ID 取得會話 | -| notification_write | notification | 寫入 | 將某條或全部通知標記為已讀 | -| label_read | label | 讀取 | 讀取倉庫或組織標籤 | -| label_write | label | 寫入 | 寫入標籤(倉庫或組織):創建、編輯、刪除 | -| milestone_read | milestone | 讀取 | 讀取里程碑:取得單個或列出 | -| milestone_write | milestone | 寫入 | 寫入里程碑:創建、更新、刪除 | -| wiki_read | wiki | 讀取 | 讀取 Wiki:列出頁面、取得內容、修訂歷史 | -| wiki_write | wiki | 寫入 | 寫入 Wiki 頁面:創建、更新、刪除 | -| timetracking_read | timetracking | 讀取 | 讀取時間追蹤:問題/倉庫耗時、活動計時器、我的追蹤記錄 | -| timetracking_write | timetracking | 寫入 | 寫入時間追蹤:計時器和記錄項目 | -| package_read | packages | 讀取 | 讀取軟體套件註冊表:列出套件、列出版本或取得某個版本 | -| package_write | packages | 寫入 | 刪除軟體套件版本(不可復原) | -| list_issues | issue | 讀取 | 列出倉庫問題 | -| attachment_read | issue | 讀取 | 讀取問題/評論附件:列出中繼資料、取得中繼資料或下載內容 | -| issue_read | issue | 讀取 | 讀取問題:詳情、評論或標籤 | -| issue_write | issue | 寫入 | 寫入問題:創建、更新、管理評論和標籤 | -| list_pull_requests | pull_request | 讀取 | 列出倉庫拉取請求 | -| pull_request_read | pull_request | 讀取 | 讀取拉取請求:詳情、差異、變更檔案、頭部提交狀態、審查、審查評論 | +| 工具 | 範圍 | 存取 | 描述 | +| :--------------------------- | :----------- | :--- | :--- | +| get_gitea_mcp_server_version | version | 讀取 | 取得 Gitea MCP 伺服器版本 | +| get_me | user | 讀取 | 取得目前已認證用戶 | +| get_user_orgs | user | 讀取 | 列出目前用戶的組織 | +| search_users | search | 讀取 | 搜尋用戶 | +| search_org_teams | search | 讀取 | 搜尋組織中的團隊 | +| search_repos | search | 讀取 | 搜尋倉庫 | +| search_issues | search | 讀取 | 跨倉庫搜尋問題和拉取請求 | +| notification_read | notification | 讀取 | 讀取通知:列出(可限定倉庫)或依 ID 取得會話 | +| notification_write | notification | 寫入 | 將某條或全部通知標記為已讀 | +| label_read | label | 讀取 | 讀取倉庫或組織標籤 | +| label_write | label | 寫入 | 寫入標籤(倉庫或組織):創建、編輯、刪除 | +| milestone_read | milestone | 讀取 | 讀取里程碑:取得單個或列出 | +| milestone_write | milestone | 寫入 | 寫入里程碑:創建、更新、刪除 | +| wiki_read | wiki | 讀取 | 讀取 Wiki:列出頁面、取得內容、修訂歷史 | +| wiki_write | wiki | 寫入 | 寫入 Wiki 頁面:創建、更新、刪除 | +| timetracking_read | timetracking | 讀取 | 讀取時間追蹤:問題/倉庫耗時、活動計時器、我的追蹤記錄 | +| timetracking_write | timetracking | 寫入 | 寫入時間追蹤:計時器和記錄項目 | +| package_read | packages | 讀取 | 讀取軟體套件註冊表:列出套件、列出版本或取得某個版本 | +| package_write | packages | 寫入 | 刪除軟體套件版本(不可復原) | +| list_issues | issue | 讀取 | 列出倉庫問題 | +| attachment_read | issue | 讀取 | 讀取問題/評論附件:列出中繼資料、取得中繼資料或下載內容 | +| issue_read | issue | 讀取 | 讀取問題:詳情、評論或標籤 | +| issue_write | issue | 寫入 | 寫入問題:創建、更新、管理評論和標籤 | +| list_pull_requests | pull_request | 讀取 | 列出倉庫拉取請求 | +| pull_request_read | pull_request | 讀取 | 讀取拉取請求:詳情、差異、變更檔案、頭部提交狀態、審查、審查評論 | | pull_request_write | pull_request | 寫入 | 寫入拉取請求:創建、更新、關閉、重新開啟、合併、更新分支、管理審查者 | | pull_request_review_write | pull_request | 寫入 | 寫入 PR 審查:創建、提交、刪除、駁回、回覆和解決審查評論 | -| actions_config_read | actions | 讀取 | 讀取 Actions 密鑰和變數 | -| actions_config_write | actions | 寫入 | 寫入 Actions 密鑰和變數:更新插入、創建、更新、刪除 | -| actions_run_read | actions | 讀取 | 讀取 Actions 工作流程、執行、作業、日誌和產物 | -| actions_run_write | actions | 寫入 | 寫入 Actions 執行:觸發、取消、重新執行 | -| create_repo | repository | 寫入 | 創建新倉庫 | -| fork_repo | repository | 寫入 | 復刻倉庫 | -| list_my_repos | repository | 讀取 | 列出目前用戶擁有的倉庫 | -| list_org_repos | repository | 讀取 | 列出組織中的倉庫 | -| get_repository_tree | repository | 讀取 | 取得倉庫檔案樹 | -| get_file_contents | file | 讀取 | 取得檔案內容與中繼資料 | -| get_dir_contents | file | 讀取 | 取得目錄中的項目 | -| create_or_update_file | file | 寫入 | 創建或更新檔案(提供 sha 以更新現有檔案) | -| delete_file | file | 寫入 | 刪除檔案 | -| create_branch | branch | 寫入 | 創建新分支 | -| delete_branch | branch | 寫入 | 刪除分支 | -| list_branches | branch | 讀取 | 列出倉庫分支 | -| create_tag | tag | 寫入 | 創建標籤 | -| delete_tag | tag | 寫入 | 刪除標籤 | -| get_tag | tag | 讀取 | 取得標籤詳情 | -| list_tags | tag | 讀取 | 列出倉庫標籤 | -| list_commits | commit | 讀取 | 列出倉庫提交 | -| get_commit | commit | 讀取 | 取得提交詳情 | -| create_release | release | 寫入 | 創建版本發布 | -| delete_release | release | 寫入 | 刪除版本發布 | -| get_release | release | 讀取 | 依 ID 取得版本發布 | -| get_latest_release | release | 讀取 | 取得最新版本發布 | -| list_releases | release | 讀取 | 列出倉庫版本發布 | +| actions_config_read | actions | 讀取 | 讀取 Actions 密鑰和變數 | +| actions_config_write | actions | 寫入 | 寫入 Actions 密鑰和變數:更新插入、創建、更新、刪除 | +| actions_run_read | actions | 讀取 | 讀取 Actions 工作流程、執行、作業、日誌和產物 | +| actions_run_write | actions | 寫入 | 寫入 Actions 執行:觸發、取消、重新執行 | +| create_repo | repository | 寫入 | 創建新倉庫 | +| fork_repo | repository | 寫入 | 復刻倉庫 | +| list_my_repos | repository | 讀取 | 列出目前用戶擁有的倉庫 | +| list_org_repos | repository | 讀取 | 列出組織中的倉庫 | +| get_repository_tree | repository | 讀取 | 取得倉庫檔案樹 | +| get_file_contents | file | 讀取 | 取得檔案內容與中繼資料 | +| get_dir_contents | file | 讀取 | 取得目錄中的項目 | +| create_or_update_file | file | 寫入 | 創建或更新檔案(提供 sha 以更新現有檔案) | +| delete_file | file | 寫入 | 刪除檔案 | +| create_branch | branch | 寫入 | 創建新分支 | +| delete_branch | branch | 寫入 | 刪除分支 | +| list_branches | branch | 讀取 | 列出倉庫分支 | +| create_tag | tag | 寫入 | 創建標籤 | +| delete_tag | tag | 寫入 | 刪除標籤 | +| get_tag | tag | 讀取 | 取得標籤詳情 | +| list_tags | tag | 讀取 | 列出倉庫標籤 | +| list_commits | commit | 讀取 | 列出倉庫提交 | +| get_commit | commit | 讀取 | 取得提交詳情 | +| create_release | release | 寫入 | 創建版本發布 | +| delete_release | release | 寫入 | 刪除版本發布 | +| get_release | release | 讀取 | 依 ID 取得版本發布 | +| get_latest_release | release | 讀取 | 取得最新版本發布 | +| list_releases | release | 讀取 | 列出倉庫版本發布 | > **說明:** 部分工具是聚合的、基於操作的工具,單個工具透過 `method` 參數暴露多個操作。當伺服器以唯讀模式執行時(`-r` / `GITEA_READONLY`),存取為「寫入」的工具會被隱藏;可透過 `-S` / `--scope`(`GITEA_SCOPES`)依範圍過濾,或透過 `-O` / `--tools`(`GITEA_TOOLS`)依工具名稱過濾對外暴露的工具集合。 diff --git a/cmd/cmd.go b/cmd/cmd.go index a8a3bc6..70566c4 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -17,6 +17,7 @@ import ( var ( host string + bind string port int token string tools string @@ -32,6 +33,8 @@ func initFlagSet(fs *flag.FlagSet, args []string, getenv func(string) string, re fs.StringVar(&flagPkg.Mode, "transport", "stdio", "") fs.StringVar(&host, "H", getenv("GITEA_HOST"), "") fs.StringVar(&host, "host", getenv("GITEA_HOST"), "") + fs.StringVar(&bind, "b", "", "") + fs.StringVar(&bind, "bind", "", "") fs.IntVar(&port, "p", 8080, "") fs.IntVar(&port, "port", 8080, "") fs.StringVar(&token, "T", "", "") @@ -68,6 +71,7 @@ func initFlagSet(fs *flag.FlagSet, args []string, getenv func(string) string, re fmt.Fprintln(stderr, "Options:") fmt.Fprintf(w, " -t, -transport \tTransport type: stdio or http (default: stdio)\n") fmt.Fprintf(w, " -H, -host \tGitea host URL (default: https://gitea.com)\n") + fmt.Fprintf(w, " -b, -bind
\tHTTP listen address, e.g. 127.0.0.1 (default: all interfaces)\n") fmt.Fprintf(w, " -p, -port \tHTTP server port (default: 8080)\n") fmt.Fprintf(w, " -T, -token \tPersonal access token\n") fmt.Fprintf(w, " -r, -read-only\tExpose only read-only tools\n") @@ -99,6 +103,7 @@ func initFlagSet(fs *flag.FlagSet, args []string, getenv func(string) string, re flagPkg.Host = "https://gitea.com" } + flagPkg.Bind = bind flagPkg.Port = port flagPkg.MaxInlineAttachmentBytes = maxInlineAttachmentBytes diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index 194c718..24ed0fb 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -10,6 +10,27 @@ import ( flagPkg "gitea.com/gitea/gitea-mcp/pkg/flag" ) +func TestInitFlagSetBind(t *testing.T) { + for _, test := range []struct { + name string + args []string + want string + }{ + {name: "default is empty, meaning all interfaces", args: []string{}}, + {name: "-b sets the address", args: []string{"-b", "127.0.0.1"}, want: "127.0.0.1"}, + {name: "-bind sets an IPv6 literal", args: []string{"-bind", "::1"}, want: "::1"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Cleanup(func() { flagPkg.Bind = "" }) + fs := flag.NewFlagSet("test", flag.ContinueOnError) + initFlagSet(fs, test.args, func(string) string { return "" }, func(string) ([]byte, error) { return nil, nil }, &bytes.Buffer{}) + if flagPkg.Bind != test.want { + t.Errorf("Bind = %q, want %q", flagPkg.Bind, test.want) + } + }) + } +} + func TestInitFlagSetScopes(t *testing.T) { tests := []struct { name string diff --git a/operation/operation.go b/operation/operation.go index 561cd58..610d081 100644 --- a/operation/operation.go +++ b/operation/operation.go @@ -4,9 +4,11 @@ import ( "context" "errors" "fmt" + "net" "net/http" "os" "os/signal" + "strconv" "strings" "syscall" "time" @@ -36,11 +38,6 @@ import ( // base64 file content create_or_update_file accepts. const maxRequestBodyBytes = 32 << 20 -// sessionTimeout expires idle sessions, which the SDK otherwise keeps for the -// process lifetime: a client that goes away without DELETE /mcp leaks its -// session, and initialize takes no token. Clients re-initialize on the 404. -const sessionTimeout = 30 * time.Minute - // httpReadHeaderTimeout bounds slow header reads without limiting SSE writes. const httpReadHeaderTimeout = 10 * time.Second @@ -107,17 +104,35 @@ func authTokenMiddleware(next mcp.MethodHandler) mcp.MethodHandler { } } +func protectMCPOrigin(next http.Handler) http.Handler { + protection := http.NewCrossOriginProtection() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check exempts safe methods, but MCP requires Origin validation on every request. + checkRequest := r + switch r.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + checkRequest = r.Clone(r.Context()) + checkRequest.Method = http.MethodPost + } + if err := protection.Check(checkRequest); err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + func newHTTPServer(addr string, s *mcp.Server) *http.Server { mux := http.NewServeMux() - mux.Handle("/mcp", mcp.NewStreamableHTTPHandler( + mux.Handle("/mcp", protectMCPOrigin(mcp.NewStreamableHTTPHandler( func(*http.Request) *mcp.Server { return s }, &mcp.StreamableHTTPOptions{ - Logger: log.Slog(), - MaxRequestBodyBytes: maxRequestBodyBytes, - Stateless: false, // SessionTimeout requires stateful sessions. - SessionTimeout: sessionTimeout, + Logger: log.Slog(), + MaxRequestBodyBytes: maxRequestBodyBytes, + Stateless: true, + PropagateRequestCancellation: true, }, - )) + ))) return &http.Server{ Addr: addr, Handler: mux, @@ -134,8 +149,9 @@ func Run() error { return err } case "http": - httpServer := newHTTPServer(fmt.Sprintf(":%d", flag.Port), mcpServer) - log.Infof("Gitea MCP HTTP server listening on :%d", flag.Port) + addr := net.JoinHostPort(flag.Bind, strconv.Itoa(flag.Port)) + httpServer := newHTTPServer(addr, mcpServer) + log.Infof("Gitea MCP HTTP server listening on %s (stateless, protocol up to 2026-07-28)", addr) // Graceful shutdown setup sigCh := make(chan os.Signal, 1) @@ -171,7 +187,12 @@ func newMCPServer(version string) *mcp.Server { Name: "Gitea MCP Server", Version: version, }, - &mcp.ServerOptions{Logger: log.Slog()}, + &mcp.ServerOptions{ + Logger: log.Slog(), + Capabilities: &mcp.ServerCapabilities{ + Tools: &mcp.ToolCapabilities{}, + }, + }, ) s.AddReceivingMiddleware(authTokenMiddleware) return s diff --git a/operation/packages/packages_test.go b/operation/packages/packages_test.go index 424381f..34bfe3b 100644 --- a/operation/packages/packages_test.go +++ b/operation/packages/packages_test.go @@ -299,25 +299,20 @@ func TestPackageWriteDelete(t *testing.T) { } } -func TestPackageReadUnknownMethod(t *testing.T) { - ctx := context.Background() - args := map[string]any{ - "method": "bogus", - "owner": "test-org", - } - if _, err := packageReadFn(ctx, args); err == nil { - t.Fatal("expected error for unknown method") - } -} - -func TestPackageWriteUnknownMethod(t *testing.T) { - ctx := context.Background() - args := map[string]any{ - "method": "bogus", - "owner": "test-org", - } - if _, err := packageWriteFn(ctx, args); err == nil { - t.Fatal("expected error for unknown method") +func TestPackageUnknownMethod(t *testing.T) { + for name, fn := range map[string]func(context.Context, map[string]any) (*mcp.CallToolResult, error){ + "packageReadFn": packageReadFn, + "packageWriteFn": packageWriteFn, + } { + t.Run(name, func(t *testing.T) { + result, err := fn(context.Background(), map[string]any{"method": "bogus", "owner": "test-org"}) + if err != nil { + t.Fatalf("%s() error = %v", name, err) + } + if result == nil || !result.IsError { + t.Fatalf("%s() result = %#v, want an error result", name, result) + } + }) } } diff --git a/operation/sdk_integration_test.go b/operation/sdk_integration_test.go index f1f52e2..cc6fed2 100644 --- a/operation/sdk_integration_test.go +++ b/operation/sdk_integration_test.go @@ -1,7 +1,9 @@ package operation import ( + "bytes" "context" + "encoding/json" "errors" "fmt" "io" @@ -10,6 +12,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" "sync" "testing" @@ -17,15 +20,17 @@ import ( mcpContext "gitea.com/gitea/gitea-mcp/pkg/context" "gitea.com/gitea/gitea-mcp/pkg/flag" + projectTo "gitea.com/gitea/gitea-mcp/pkg/to" + projectTool "gitea.com/gitea/gitea-mcp/pkg/tool" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/modelcontextprotocol/go-sdk/mcp" ) // Pin negotiated versions so SDK upgrades require compatibility review. const ( - testServerVersion = "test-version" - expectedProtocolVersion = "2026-07-28" - expectedStatefulHTTPProtocolVersion = "2025-11-25" + testServerVersion = "test-version" + expectedProtocolVersion = "2026-07-28" ) func exposeAllTools(t *testing.T) { @@ -86,7 +91,7 @@ func textContent(t *testing.T, result *mcp.CallToolResult) string { // listAndCallVersion is the round trip every transport must support. wantText // differs per transport: the stdio subprocess resolves its version from the VCS // build info (main.go:14), so only the in-process servers have a known one. -func listAndCallVersion(ctx context.Context, t *testing.T, session *mcp.ClientSession, wantText string) { +func listAndCallVersion(ctx context.Context, t *testing.T, session *mcp.ClientSession, wantText string) *mcp.ListToolsResult { t.Helper() result, err := session.ListTools(ctx, nil) if err != nil { @@ -104,6 +109,128 @@ func listAndCallVersion(ctx context.Context, t *testing.T, session *mcp.ClientSe if got := textContent(t, callResult); !strings.Contains(got, wantText) { t.Errorf("version tool result = %q, want it to contain %q", got, wantText) } + return result +} + +func assertToolsOnlyCapabilities(t *testing.T, capabilities *mcp.ServerCapabilities) { + t.Helper() + wireCapabilities, err := json.Marshal(capabilities) + if err != nil { + t.Fatalf("Marshal(server capabilities) error = %v", err) + } + // Any extra capability, or listChanged, changes these bytes. + if want := `{"tools":{}}`; string(wireCapabilities) != want { + t.Errorf("server capabilities = %s, want %s", wireCapabilities, want) + } +} + +type rawRPCResponse struct { + status int + header http.Header + body []byte +} + +type rpcRequest struct { + protocolVersion string + methodHeader string + nameHeader string + method string + params map[string]any +} + +// postRPCRequest exists because the SDK client cannot be pinned to an old +// protocol version or made to send mismatched headers. +func postRPCRequest(t *testing.T, server *httptest.Server, call rpcRequest) rawRPCResponse { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": call.method, + "params": call.params, + }) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL+"/mcp", bytes.NewReader(body)) + if err != nil { + t.Fatalf("NewRequest() error = %v", err) + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json, text/event-stream") + if call.protocolVersion != "" { + request.Header.Set("Mcp-Protocol-Version", call.protocolVersion) + } + if call.methodHeader != "" { + request.Header.Set("Mcp-Method", call.methodHeader) + } + if call.nameHeader != "" { + request.Header.Set("Mcp-Name", call.nameHeader) + } + response, err := server.Client().Do(request) + if err != nil { + t.Fatalf("POST %s error = %v", call.method, err) + } + defer response.Body.Close() + responseBody, err := io.ReadAll(response.Body) + if err != nil { + t.Fatalf("ReadAll() error = %v", err) + } + return rawRPCResponse{status: response.StatusCode, header: response.Header.Clone(), body: responseBody} +} + +func modernRequestMeta(protocolVersion string) map[string]any { + return map[string]any{ + mcp.MetaKeyProtocolVersion: protocolVersion, + mcp.MetaKeyClientInfo: map[string]any{"name": "gitea-mcp-wire-test", "version": "1"}, + mcp.MetaKeyClientCapabilities: map[string]any{}, + } +} + +func rpcPayload(response rawRPCResponse) []byte { + payload := bytes.TrimSpace(response.body) + for line := range bytes.SplitSeq(payload, []byte("\n")) { + if data, ok := bytes.CutPrefix(line, []byte("data: ")); ok { + return data + } + } + return payload +} + +func rpcResult(t *testing.T, response rawRPCResponse) json.RawMessage { + t.Helper() + var wire struct { + Result json.RawMessage `json:"result"` + Error *json.RawMessage `json:"error"` + } + if err := json.Unmarshal(rpcPayload(response), &wire); err != nil { + t.Fatalf("Unmarshal(JSON-RPC response) error = %v; body = %s", err, response.body) + } + if wire.Error != nil { + t.Fatalf("JSON-RPC response has error %s", *wire.Error) + } + if len(wire.Result) == 0 { + t.Fatalf("JSON-RPC response has no result: %s", response.body) + } + return wire.Result +} + +func rpcErrorCode(t *testing.T, response rawRPCResponse) int { + t.Helper() + payload := rpcPayload(response) + var wire struct { + Error *struct { + Code int `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(payload, &wire); err != nil { + t.Fatalf("Unmarshal(%q) error = %v", payload, err) + } + if wire.Error == nil { + t.Fatalf("response has no JSON-RPC error: %s", response.body) + } + return wire.Error.Code } func TestOfficialSDKInMemory(t *testing.T) { @@ -127,6 +254,7 @@ func TestOfficialSDKInMemory(t *testing.T) { if got := session.InitializeResult().ProtocolVersion; got != expectedProtocolVersion { t.Errorf("protocol version = %q, want %q", got, expectedProtocolVersion) } + assertToolsOnlyCapabilities(t, session.InitializeResult().Capabilities) listAndCallVersion(ctx, t, session, testServerVersion) if err := session.Close(); err != nil { t.Fatalf("Close() error = %v", err) @@ -141,7 +269,7 @@ func TestOfficialSDKInMemory(t *testing.T) { } } -func TestStreamableHTTPStateful(t *testing.T) { +func TestStreamableHTTP(t *testing.T) { exposeAllTools(t) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -162,13 +290,25 @@ func TestStreamableHTTPStateful(t *testing.T) { t.Fatalf("Connect() error = %v", err) } defer session.Close() - // Stateful Streamable HTTP cannot negotiate the sessionless 2026 protocol. - if got := session.InitializeResult().ProtocolVersion; got != expectedStatefulHTTPProtocolVersion { - t.Errorf("protocol version = %q, want %q", got, expectedStatefulHTTPProtocolVersion) + if got := session.InitializeResult().ProtocolVersion; got != expectedProtocolVersion { + t.Errorf("protocol version = %q, want %q", got, expectedProtocolVersion) } - listAndCallVersion(ctx, t, session, testServerVersion) + assertToolsOnlyCapabilities(t, session.InitializeResult().Capabilities) + firstList := listAndCallVersion(ctx, t, session, testServerVersion) - response, err := httpTestServer.Client().Get(httpTestServer.URL + "/not-mcp") + secondList, err := session.ListTools(ctx, nil) + if err != nil { + t.Fatalf("second ListTools() error = %v", err) + } + if !slices.EqualFunc(firstList.Tools, secondList.Tools, func(a, b *mcp.Tool) bool { return a.Name == b.Name }) { + t.Error("tools/list order changed between requests") + } + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, httpTestServer.URL+"/not-mcp", nil) + if err != nil { + t.Fatalf("NewRequest(GET outside /mcp) error = %v", err) + } + response, err := httpTestServer.Client().Do(request) if err != nil { t.Fatalf("GET outside /mcp error = %v", err) } @@ -176,6 +316,264 @@ func TestStreamableHTTPStateful(t *testing.T) { if response.StatusCode != http.StatusNotFound { t.Errorf("GET outside /mcp status = %d, want %d", response.StatusCode, http.StatusNotFound) } + + for _, method := range []string{http.MethodGet, http.MethodDelete} { + request, err := http.NewRequestWithContext(ctx, method, httpTestServer.URL+"/mcp", nil) + if err != nil { + t.Fatalf("NewRequest(%s) error = %v", method, err) + } + response, err := httpTestServer.Client().Do(request) + if err != nil { + t.Fatalf("%s /mcp error = %v", method, err) + } + _ = response.Body.Close() + if response.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("%s /mcp status = %d, want %d", method, response.StatusCode, http.StatusMethodNotAllowed) + } + if allow := response.Header.Get("Allow"); allow != http.MethodPost { + t.Errorf("%s /mcp Allow = %q, want %q", method, allow, http.MethodPost) + } + } +} + +func TestStreamableHTTP20260728Wire(t *testing.T) { + invoked := make(chan struct{}, 1) + server := newMCPServer(testServerVersion) + definition := &mcp.Tool{ + Name: "test_execution_error", + Description: "Record valid calls and return a controlled tool execution error.", + InputSchema: map[string]any{"type": "object", "properties": map[string]any{}}, + } + server.AddTool(definition, projectTool.ServerTool{ + Tool: definition, + Handler: func(context.Context, map[string]any) (*mcp.CallToolResult, error) { + invoked <- struct{}{} + return projectTo.ErrorResult(errors.New("controlled execution failure")) + }, + }.MCPHandler()) + httpTestServer := httptest.NewServer(newHTTPServer("", server).Handler) + defer httpTestServer.Close() + + callParams := map[string]any{ + "_meta": modernRequestMeta(expectedProtocolVersion), + "name": definition.Name, + "arguments": map[string]any{}, + } + + t.Run("discover", func(t *testing.T) { + discover := postRPCRequest(t, httpTestServer, rpcRequest{ + protocolVersion: expectedProtocolVersion, + methodHeader: "server/discover", + method: "server/discover", + params: map[string]any{"_meta": modernRequestMeta(expectedProtocolVersion)}, + }) + if discover.status != http.StatusOK { + t.Fatalf("status = %d, want %d; body = %s", discover.status, http.StatusOK, discover.body) + } + if sessionID := discover.header.Get("Mcp-Session-Id"); sessionID != "" { + t.Errorf("Mcp-Session-Id = %q, want empty", sessionID) + } + var result struct { + Meta map[string]json.RawMessage `json:"_meta"` + SupportedVersions []string `json:"supportedVersions"` + Capabilities *mcp.ServerCapabilities `json:"capabilities"` + } + if err := json.Unmarshal(rpcResult(t, discover), &result); err != nil { + t.Fatalf("Unmarshal(server/discover) error = %v", err) + } + if !slices.Contains(result.SupportedVersions, expectedProtocolVersion) { + t.Errorf("supportedVersions = %v, want %q", result.SupportedVersions, expectedProtocolVersion) + } + assertToolsOnlyCapabilities(t, result.Capabilities) + var serverInfo mcp.Implementation + if err := json.Unmarshal(result.Meta[mcp.MetaKeyServerInfo], &serverInfo); err != nil { + t.Fatalf("Unmarshal(%s) error = %v", mcp.MetaKeyServerInfo, err) + } + if serverInfo.Name != "Gitea MCP Server" || serverInfo.Version != testServerVersion { + t.Errorf("serverInfo = %+v, want Gitea MCP Server %s", serverInfo, testServerVersion) + } + }) + + t.Run("tool execution error", func(t *testing.T) { + call := postRPCRequest(t, httpTestServer, rpcRequest{ + protocolVersion: expectedProtocolVersion, + methodHeader: "tools/call", + nameHeader: definition.Name, + method: "tools/call", + params: callParams, + }) + if call.status != http.StatusOK { + t.Fatalf("status = %d, want %d; body = %s", call.status, http.StatusOK, call.body) + } + if sessionID := call.header.Get("Mcp-Session-Id"); sessionID != "" { + t.Errorf("Mcp-Session-Id = %q, want empty", sessionID) + } + var result struct { + IsError bool `json:"isError"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } + if err := json.Unmarshal(rpcResult(t, call), &result); err != nil { + t.Fatalf("Unmarshal(tools/call) error = %v", err) + } + if !result.IsError { + t.Error("isError = false, want true") + } + if len(result.Content) != 1 || result.Content[0].Type != "text" || result.Content[0].Text != "controlled execution failure" { + t.Errorf("content = %+v, want controlled execution failure text", result.Content) + } + select { + case <-invoked: + default: + t.Error("valid tools/call did not invoke the tool handler") + } + }) + + for _, test := range []struct { + name string + protocolVersion string + methodHeader string + nameHeader string + }{ + {name: "missing protocol", methodHeader: "tools/call", nameHeader: "test_execution_error"}, + {name: "mismatched protocol", protocolVersion: "2025-11-25", methodHeader: "tools/call", nameHeader: "test_execution_error"}, + {name: "missing method", protocolVersion: expectedProtocolVersion, nameHeader: "test_execution_error"}, + {name: "mismatched method", protocolVersion: expectedProtocolVersion, methodHeader: "tools/list", nameHeader: "test_execution_error"}, + {name: "missing name", protocolVersion: expectedProtocolVersion, methodHeader: "tools/call"}, + {name: "mismatched name", protocolVersion: expectedProtocolVersion, methodHeader: "tools/call", nameHeader: "wrong_tool"}, + } { + t.Run(test.name, func(t *testing.T) { + response := postRPCRequest(t, httpTestServer, rpcRequest{ + protocolVersion: test.protocolVersion, + methodHeader: test.methodHeader, + nameHeader: test.nameHeader, + method: "tools/call", + params: callParams, + }) + if response.status != http.StatusBadRequest { + t.Errorf("status = %d, want %d", response.status, http.StatusBadRequest) + } + if code := rpcErrorCode(t, response); code != mcp.CodeHeaderMismatch { + t.Errorf("error code = %d, want %d", code, mcp.CodeHeaderMismatch) + } + select { + case <-invoked: + t.Error("invalid headers invoked the tool handler") + default: + } + }) + } + + t.Run("unsupported protocol", func(t *testing.T) { + futureVersion := "2027-01-01" + response := postRPCRequest(t, httpTestServer, rpcRequest{ + protocolVersion: futureVersion, + methodHeader: "server/discover", + method: "server/discover", + params: map[string]any{"_meta": modernRequestMeta(futureVersion)}, + }) + if response.status != http.StatusBadRequest { + t.Errorf("status = %d, want %d", response.status, http.StatusBadRequest) + } + if code := rpcErrorCode(t, response); code != mcp.CodeUnsupportedProtocolVersion { + t.Errorf("error code = %d, want %d", code, mcp.CodeUnsupportedProtocolVersion) + } + }) + + t.Run("unknown method", func(t *testing.T) { + response := postRPCRequest(t, httpTestServer, rpcRequest{ + protocolVersion: expectedProtocolVersion, + methodHeader: "test/unknown", + method: "test/unknown", + params: map[string]any{"_meta": modernRequestMeta(expectedProtocolVersion)}, + }) + if response.status != http.StatusNotFound { + t.Errorf("status = %d, want %d", response.status, http.StatusNotFound) + } + if code := rpcErrorCode(t, response); code != jsonrpc.CodeMethodNotFound { + t.Errorf("error code = %d, want %d", code, jsonrpc.CodeMethodNotFound) + } + }) +} + +func TestStreamableHTTPLegacyCore(t *testing.T) { + exposeAllTools(t) + server := newMCPServer(testServerVersion) + RegisterTool(server) + httpTestServer := httptest.NewServer(newHTTPServer("", server).Handler) + defer httpTestServer.Close() + + for _, protocolVersion := range []string{"2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"} { + t.Run(protocolVersion, func(t *testing.T) { + initialize := postRPCRequest(t, httpTestServer, rpcRequest{ + method: "initialize", + params: map[string]any{ + "protocolVersion": protocolVersion, + "clientInfo": map[string]any{"name": "gitea-mcp-legacy-test", "version": "1"}, + "capabilities": map[string]any{}, + }, + }) + if initialize.status != http.StatusOK { + t.Fatalf("initialize status = %d, want %d; body = %s", initialize.status, http.StatusOK, initialize.body) + } + var initializeResult struct { + ProtocolVersion string `json:"protocolVersion"` + } + if err := json.Unmarshal(rpcResult(t, initialize), &initializeResult); err != nil { + t.Fatalf("Unmarshal(initialize result) error = %v", err) + } + if initializeResult.ProtocolVersion != protocolVersion { + t.Errorf("initialize protocolVersion = %q, want %q", initializeResult.ProtocolVersion, protocolVersion) + } + if sessionID := initialize.header.Get("Mcp-Session-Id"); sessionID != "" { + t.Errorf("initialize Mcp-Session-Id = %q, want empty", sessionID) + } + + list := postRPCRequest(t, httpTestServer, rpcRequest{ + protocolVersion: protocolVersion, + method: "tools/list", + params: map[string]any{}, + }) + if list.status != http.StatusOK { + t.Fatalf("tools/list status = %d, want %d; body = %s", list.status, http.StatusOK, list.body) + } + var listResult struct { + Tools []json.RawMessage `json:"tools"` + } + if err := json.Unmarshal(rpcResult(t, list), &listResult); err != nil { + t.Fatalf("Unmarshal(tools/list result) error = %v", err) + } + if len(listResult.Tools) != registeredToolCount() { + t.Errorf("tools/list count = %d, want %d", len(listResult.Tools), registeredToolCount()) + } + }) + } + + // The call path does not vary by version, unlike the two requests above. + call := postRPCRequest(t, httpTestServer, rpcRequest{ + protocolVersion: "2025-11-25", + method: "tools/call", + params: map[string]any{ + "name": "get_gitea_mcp_server_version", + "arguments": map[string]any{}, + }, + }) + if call.status != http.StatusOK { + t.Fatalf("tools/call status = %d, want %d; body = %s", call.status, http.StatusOK, call.body) + } + var callResult struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + } + if err := json.Unmarshal(rpcResult(t, call), &callResult); err != nil { + t.Fatalf("Unmarshal(tools/call result) error = %v", err) + } + if len(callResult.Content) != 1 || !strings.Contains(callResult.Content[0].Text, testServerVersion) { + t.Errorf("tools/call content = %+v, want version %q", callResult.Content, testServerVersion) + } } // spaceReader yields an endless run of spaces, so oversized bodies can be sent @@ -190,6 +588,8 @@ func (spaceReader) Read(p []byte) (int, error) { } func TestStreamableHTTPRequestBodyLimit(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() server := newMCPServer(testServerVersion) httpTestServer := httptest.NewServer(newHTTPServer("", server).Handler) defer httpTestServer.Close() @@ -203,7 +603,7 @@ func TestStreamableHTTPRequestBodyLimit(t *testing.T) { {name: "above our own limit", size: maxRequestBodyBytes + 1, tooLarge: true}, } { t.Run(test.name, func(t *testing.T) { - request, err := http.NewRequest(http.MethodPost, httpTestServer.URL+"/mcp", io.LimitReader(spaceReader{}, test.size)) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, httpTestServer.URL+"/mcp", io.LimitReader(spaceReader{}, test.size)) if err != nil { t.Fatalf("NewRequest() error = %v", err) } @@ -222,6 +622,108 @@ func TestStreamableHTTPRequestBodyLimit(t *testing.T) { } } +func TestStreamableHTTPOriginProtection(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + httpTestServer := httptest.NewServer(newHTTPServer("", newMCPServer(testServerVersion)).Handler) + defer httpTestServer.Close() + + for _, test := range []struct { + name string + method string + origin string + wantForbidden bool + }{ + {name: "native client without origin", method: http.MethodPost}, + {name: "same origin", method: http.MethodPost, origin: httpTestServer.URL}, + {name: "cross origin POST", method: http.MethodPost, origin: "https://attacker.example", wantForbidden: true}, + {name: "cross origin GET", method: http.MethodGet, origin: "https://attacker.example", wantForbidden: true}, + } { + t.Run(test.name, func(t *testing.T) { + request, err := http.NewRequestWithContext(ctx, test.method, httpTestServer.URL+"/mcp", strings.NewReader("{}")) + if err != nil { + t.Fatalf("NewRequest() error = %v", err) + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json, text/event-stream") + if test.origin != "" { + request.Header.Set("Origin", test.origin) + } + response, err := httpTestServer.Client().Do(request) + if err != nil { + t.Fatalf("%s /mcp error = %v", test.method, err) + } + _, _ = io.Copy(io.Discard, response.Body) + _ = response.Body.Close() + if forbidden := response.StatusCode == http.StatusForbidden; forbidden != test.wantForbidden { + t.Errorf("status = %d, want forbidden = %v", response.StatusCode, test.wantForbidden) + } + }) + } +} + +func TestStreamableHTTPCancellation(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + started := make(chan struct{}) + handlerCanceled := make(chan struct{}) + server := newMCPServer(testServerVersion) + server.AddTool( + &mcp.Tool{ + Name: "test_cancellation", + Description: "Wait for the request context to be canceled.", + InputSchema: map[string]any{"type": "object", "properties": map[string]any{}}, + }, + func(ctx context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + close(started) + <-ctx.Done() + close(handlerCanceled) + return nil, ctx.Err() + }, + ) + httpTestServer := httptest.NewServer(newHTTPServer("", server).Handler) + defer httpTestServer.Close() + + client := mcp.NewClient(&mcp.Implementation{Name: "gitea-mcp-cancellation-test", Version: "1"}, nil) + session, err := client.Connect(ctx, &mcp.StreamableClientTransport{ + Endpoint: httpTestServer.URL + "/mcp", + HTTPClient: httpTestServer.Client(), + DisableStandaloneSSE: true, + MaxRetries: -1, + }, nil) + if err != nil { + t.Fatalf("Connect() error = %v", err) + } + defer session.Close() + + callCtx, cancelCall := context.WithCancel(ctx) + callDone := make(chan error, 1) + go func() { + _, err := session.CallTool(callCtx, &mcp.CallToolParams{Name: "test_cancellation"}) + callDone <- err + }() + select { + case <-started: + case <-ctx.Done(): + t.Fatal("tool handler did not start") + } + cancelCall() + select { + case <-handlerCanceled: + case <-ctx.Done(): + t.Fatal("HTTP request cancellation did not reach the tool handler") + } + select { + case err := <-callDone: + if err == nil { + t.Error("CallTool() error = nil after cancellation") + } + case <-ctx.Done(): + t.Fatal("CallTool() did not return after cancellation") + } +} + type authorizationTransport struct { base http.RoundTripper mu sync.RWMutex @@ -400,5 +902,6 @@ func TestStdioCommandTransport(t *testing.T) { if got := session.InitializeResult().ProtocolVersion; got != expectedProtocolVersion { t.Errorf("protocol version = %q, want %q", got, expectedProtocolVersion) } + assertToolsOnlyCapabilities(t, session.InitializeResult().Capabilities) listAndCallVersion(ctx, t, session, "Gitea MCP Server version:") } diff --git a/pkg/flag/flag.go b/pkg/flag/flag.go index 058a65a..1790ee8 100644 --- a/pkg/flag/flag.go +++ b/pkg/flag/flag.go @@ -2,6 +2,7 @@ package flag var ( Host string + Bind string Port int Token string Version string diff --git a/pkg/to/to.go b/pkg/to/to.go index 72c617a..edb1b78 100644 --- a/pkg/to/to.go +++ b/pkg/to/to.go @@ -25,5 +25,7 @@ func TextResult(v any) (*mcp.CallToolResult, error) { func ErrorResult(err error) (*mcp.CallToolResult, error) { log.Errorf("%s", err.Error()) - return nil, err + var result mcp.CallToolResult + result.SetError(err) + return &result, nil } diff --git a/pkg/to/to_test.go b/pkg/to/to_test.go index 27add52..ec3258a 100644 --- a/pkg/to/to_test.go +++ b/pkg/to/to_test.go @@ -27,7 +27,16 @@ func TestTextResult(t *testing.T) { func TestErrorResult(t *testing.T) { want := errors.New("failed") result, err := ErrorResult(want) - if result != nil || !errors.Is(err, want) { - t.Errorf("ErrorResult() = (%#v, %v), want (nil, %v)", result, err, want) + if err != nil { + t.Fatalf("ErrorResult() error = %v", err) + } + if !result.IsError { + t.Error("IsError = false, want true") + } + if len(result.Content) != 1 { + t.Fatalf("len(Content) = %d, want 1", len(result.Content)) + } + if content, ok := result.Content[0].(*mcp.TextContent); !ok || content.Text != want.Error() { + t.Errorf("Content[0] = %#v, want text %q", result.Content[0], want) } } diff --git a/pkg/tool/handler_test.go b/pkg/tool/handler_test.go index 3da572f..70fae03 100644 --- a/pkg/tool/handler_test.go +++ b/pkg/tool/handler_test.go @@ -68,27 +68,36 @@ func TestMCPHandlerAcceptsAbsentArguments(t *testing.T) { } } -func TestMCPHandlerConvertsErrorsAndRecoversPanics(t *testing.T) { +func TestMCPHandlerErrorClassification(t *testing.T) { for _, test := range []struct { - name string - handler Handler + name string + handler Handler + wantCode int64 }{ { - name: "handler error", - handler: func(context.Context, map[string]any) (*mcp.CallToolResult, error) { - return nil, errors.New("failed") - }, + name: "server error", + handler: func(context.Context, map[string]any) (*mcp.CallToolResult, error) { return nil, errors.New("failed") }, + wantCode: jsonrpc.CodeInternalError, }, { - name: "panic", + name: "protocol error", handler: func(context.Context, map[string]any) (*mcp.CallToolResult, error) { - panic("failed") + return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "failed"} }, + wantCode: jsonrpc.CodeInvalidParams, + }, + { + name: "panic", + handler: func(context.Context, map[string]any) (*mcp.CallToolResult, error) { panic("failed") }, + wantCode: jsonrpc.CodeInternalError, }, } { t.Run(test.name, func(t *testing.T) { - _, err := callTool(test.handler, nil) - assertProtocolErrorCode(t, err, jsonrpc.CodeInternalError) + result, err := callTool(test.handler, nil) + if result != nil { + t.Errorf("result = %#v, want nil", result) + } + assertProtocolErrorCode(t, err, test.wantCode) }) } } diff --git a/pkg/tool/tool.go b/pkg/tool/tool.go index fa52d1c..782c52a 100644 --- a/pkg/tool/tool.go +++ b/pkg/tool/tool.go @@ -107,8 +107,7 @@ func (s ServerTool) MCPHandler() mcp.ToolHandler { if errors.As(err, &protocolErr) { return nil, err } - // Preserve mcp-go behavior; tool-result errors are a separate change. - return nil, internalError(err) + return nil, internalError(err) // Expected failures never reach here, handlers use CallToolResult. } return result, nil }