feat!: support protocol 2026-07-28 over HTTP and add --bind (#227)

Adds MCP protocol `2026-07-28` over HTTP through the official Go SDK, and validates the `Origin` header on every request as the spec requires.

Tool and Gitea failures now come back as an ordinary `tools/call` result carrying `result.isError: true`, the way the SDK's own tool wrapper reports them. Malformed requests, unknown tools or methods, and server faults stay JSON-RPC errors.

Adds `-b, --bind` to narrow the listen address. The default still accepts every interface, so this is opt-in hardening. It matters because a request that omits `Authorization` falls back to the server's own token.

**Breaking: the HTTP endpoint no longer keeps a session per client.** What changes for a client:

1. `/mcp` accepts `POST` only, and answers `405` to `GET` or `DELETE`.
2. The server neither sends nor accepts `Mcp-Session-Id`, so there is no session handshake to perform.
3. There is no standalone SSE stream and no `Last-Event-ID` resumption. If a response stream breaks, send the whole request again under a new JSON-RPC id.

Clients that already speak current streamable HTTP need no changes. Anything relying on the session handshake or the standalone SSE stream should stay on the previous release.

---------

Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/227
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Bo-Yi Wu <appleboy.tw@gmail.com>
This commit is contained in:
Bo-Yi Wu
2026-08-07 15:14:26 +00:00
committed by silverwind
parent efcbdbb17f
commit 75f1adf979
13 changed files with 806 additions and 223 deletions
+61 -55
View File
@@ -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 <token>` and `Authorization: token <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`).
+60 -54
View File
@@ -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`)按工具名称过滤对外暴露的工具集合。
+60 -54
View File
@@ -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`)依工具名稱過濾對外暴露的工具集合。
+5
View File
@@ -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 <type>\tTransport type: stdio or http (default: stdio)\n")
fmt.Fprintf(w, " -H, -host <url>\tGitea host URL (default: https://gitea.com)\n")
fmt.Fprintf(w, " -b, -bind <address>\tHTTP listen address, e.g. 127.0.0.1 (default: all interfaces)\n")
fmt.Fprintf(w, " -p, -port <number>\tHTTP server port (default: 8080)\n")
fmt.Fprintf(w, " -T, -token <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
+21
View File
@@ -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
+35 -14
View File
@@ -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
+14 -19
View File
@@ -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)
}
})
}
}
+514 -11
View File
@@ -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:")
}
+1
View File
@@ -2,6 +2,7 @@ package flag
var (
Host string
Bind string
Port int
Token string
Version string
+3 -1
View File
@@ -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
}
+11 -2
View File
@@ -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)
}
}
+20 -11
View File
@@ -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)
})
}
}
+1 -2
View File
@@ -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
}