From 21aa4684d97efa6994e1b22e928923b360eb5dab Mon Sep 17 00:00:00 2001 From: yp05327 <576951401@qq.com> Date: Mon, 27 Jul 2026 18:12:29 +0000 Subject: [PATCH] feat(cmd): add -S/--scope to load only selected tool scopes (#219) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `-O` / `--tools` can already narrow the exposed tool set, but it needs every tool spelled out by name. `-S` / `--scope` (`GITEA_SCOPES`) complements it by selecting whole scopes, using the same names the `Scope` column of the tool tables documents. ```bash gitea-mcp -S issue,pull_request # only those scopes gitea-mcp --scope repository,branch --tools get_me # those scopes plus one extra tool ``` ## What changed - Each scope is one `tool.Tool` registry carrying a canonical name, so `operation/repo` is split into the six scopes its files already imply: `repository` (`repo.go` + `tree.go`), `file`, `branch`, `tag`, `commit`, `release`. The other twelve packages map 1:1, giving 18 scopes. - The two allowlists combine as a **union**: with neither set every tool loads; with only `--tools` set behaviour is unchanged; with both set, the selected scopes' tools plus the individually named tools load. `-r` / `GITEA_READONLY` still hides write tools on top. - Scope names are normalized on input (case, spaces, hyphens), so `Pull Request`, `pull-request` and `PULL_REQUEST` all resolve to `pull_request`. Unknown names only warn and list the valid scopes, mirroring how `--tools` treats unknown tool names. - The `Scope` column of all three READMEs now uses the canonical names verbatim, so it doubles as the reference for `--scope`, and `TestReadmeToolTables` compares that column against the registry in both directions — no translation table needed. - Flag parsing moves from `cmd.init()` into `Execute()`. `main()` only ever calls `Execute()`, so this is behaviourally equivalent, and it makes the `cmd` package testable at all: previously the `init()` parse of `os.Args` hit `flag.CommandLine`'s `ExitOnError` on `go test`'s own `-test.*` flags. ## Verification `make fmt`, `make lint-go` (0 issues) and `go test ./...` all pass. New tests cover the filter matrix (no filters / scope-only / tools-only regression / union / read-only interaction / unknown scope), scope-name uniqueness across `domainTools`, and the flag+env parsing and normalization. Smoke-tested `tools/list` over stdio against the built binary: | flags | tools exposed | | :-- | :-- | | _none_ | 54 (identical to `main`) | | `-S branch` | `create_branch`, `delete_branch`, `list_branches` | | `--scope 'Pull Request,TAG'` | the 4 `pull_request` + 4 `tag` tools | | `-O get_me` | `get_me` (unchanged) | | `-S commit -O get_me` | `get_commit`, `list_commits`, `get_me` | | `-S file -r` | `get_dir_contents`, `get_file_contents` | | `-S bogus,issue` | the 4 `issue` tools, plus a warning naming the valid scopes | Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/219 Reviewed-by: Lunny Xiao Co-authored-by: yp05327 <576951401@qq.com> --- README.md | 117 +++++++++++----------- README.zh-cn.md | 121 ++++++++++++----------- README.zh-tw.md | 121 ++++++++++++----------- cmd/cmd.go | 32 +++++- cmd/cmd_test.go | 76 +++++++++++++++ operation/actions/actions.go | 2 +- operation/issue/issue.go | 2 +- operation/label/label.go | 2 +- operation/milestone/milestone.go | 2 +- operation/notification/notification.go | 2 +- operation/operation.go | 2 + operation/operation_test.go | 20 ++++ operation/packages/packages.go | 2 +- operation/pull/pull.go | 2 +- operation/readme_test.go | 35 ++++--- operation/repo/branch.go | 10 +- operation/repo/commit.go | 8 +- operation/repo/file.go | 12 ++- operation/repo/release.go | 14 ++- operation/repo/repo.go | 2 +- operation/repo/tag.go | 12 ++- operation/search/search.go | 2 +- operation/timetracking/timetracking.go | 2 +- operation/user/user.go | 2 +- operation/version/version.go | 2 +- operation/wiki/wiki.go | 2 +- pkg/flag/flag.go | 9 +- pkg/tool/tool.go | 71 +++++++++++--- pkg/tool/tool_test.go | 129 ++++++++++++++++++++++++- 29 files changed, 580 insertions(+), 235 deletions(-) create mode 100644 cmd/cmd_test.go diff --git a/README.md b/README.md index 3876e0d..35afed3 100644 --- a/README.md +++ b/README.md @@ -131,61 +131,68 @@ Once configured, try `list all my repositories` in the chat box. | 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, changed files, head commit status, reviews | -| 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 | -| 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 | +| 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, changed files, head commit status, reviews | +| 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 | +| 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 with `-O` / `--tools` (`GITEA_TOOLS`). +> **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`). + +With neither flag set, every tool loads. `--scope` limits loading to tools whose Scope column value is in the given list; `--tools` limits loading to the named tools; setting both loads the union of the selected scopes and the individually named tools. Unknown scope names are ignored with a startup warning. + +```bash +gitea-mcp -S issue,pull_request +gitea-mcp --scope repository,branch --tools get_me +``` Many tools accept `page` and `per_page` for pagination. The maximum effective page size is the Gitea server's `[api].MAX_RESPONSE_ITEMS` setting (default **50**), larger values are silently capped. diff --git a/README.zh-cn.md b/README.zh-cn.md index 8903cd6..bd98548 100644 --- a/README.zh-cn.md +++ b/README.zh-cn.md @@ -129,63 +129,70 @@ Cursor 等客户端可使用 stdio 命令: ## 可用工具 -| 工具 | 范围 | 访问 | 描述 | -| :--------------------------- | :------- | :--- | :------------------------------------------------------------------- | -| get_gitea_mcp_server_version | 版本 | 读取 | 获取 Gitea MCP 服务器版本 | -| get_me | 用户 | 读取 | 获取当前已认证用户 | -| get_user_orgs | 用户 | 读取 | 列出当前用户的组织 | -| search_users | 搜索 | 读取 | 搜索用户 | -| search_org_teams | 搜索 | 读取 | 搜索组织中的团队 | -| search_repos | 搜索 | 读取 | 搜索仓库 | -| search_issues | 搜索 | 读取 | 跨仓库搜索问题和拉取请求 | -| notification_read | 通知 | 读取 | 读取通知:列出(可限定仓库)或按 ID 获取会话 | -| notification_write | 通知 | 写入 | 将某条或全部通知标记为已读 | -| label_read | 标签 | 读取 | 读取仓库或组织标签 | -| label_write | 标签 | 写入 | 写入标签(仓库或组织):创建、编辑、删除 | -| milestone_read | 里程碑 | 读取 | 读取里程碑:获取单个或列出 | -| milestone_write | 里程碑 | 写入 | 写入里程碑:创建、更新、删除 | -| wiki_read | Wiki | 读取 | 读取 Wiki:列出页面、获取内容、修订历史 | -| wiki_write | Wiki | 写入 | 写入 Wiki 页面:创建、更新、删除 | -| timetracking_read | 时间跟踪 | 读取 | 读取时间跟踪:问题/仓库耗时、活动计时器、我的跟踪记录 | -| timetracking_write | 时间跟踪 | 写入 | 写入时间跟踪:计时器和记录条目 | -| package_read | 软件包 | 读取 | 读取软件包注册表:列出软件包、列出版本或获取某个版本 | -| package_write | 软件包 | 写入 | 删除软件包版本(不可恢复) | -| list_issues | 问题 | 读取 | 列出仓库问题 | -| attachment_read | 问题 | 读取 | 读取问题/评论附件:列出元数据、获取元数据或下载内容 | -| issue_read | 问题 | 读取 | 读取问题:详情、评论或标签 | -| issue_write | 问题 | 写入 | 写入问题:创建、更新、管理评论和标签 | -| list_pull_requests | 拉取请求 | 读取 | 列出仓库拉取请求 | -| pull_request_read | 拉取请求 | 读取 | 读取拉取请求:详情、差异、变更文件、头部提交状态、审查 | -| pull_request_write | 拉取请求 | 写入 | 写入拉取请求:创建、更新、关闭、重新打开、合并、更新分支、管理审查者 | -| pull_request_review_write | 拉取请求 | 写入 | 写入 PR 审查:创建、提交、删除、驳回 | -| actions_config_read | Actions | 读取 | 读取 Actions 密钥和变量 | -| actions_config_write | Actions | 写入 | 写入 Actions 密钥和变量:更新插入、创建、更新、删除 | -| actions_run_read | Actions | 读取 | 读取 Actions 工作流、运行、作业、日志和构件 | -| actions_run_write | Actions | 写入 | 写入 Actions 运行:触发、取消、重新运行 | -| create_repo | 仓库 | 写入 | 创建新仓库 | -| fork_repo | 仓库 | 写入 | 复刻仓库 | -| list_my_repos | 仓库 | 读取 | 列出当前用户拥有的仓库 | -| list_org_repos | 仓库 | 读取 | 列出组织中的仓库 | -| get_repository_tree | 仓库 | 读取 | 获取仓库文件树 | -| get_file_contents | 文件 | 读取 | 获取文件内容和元数据 | -| get_dir_contents | 文件 | 读取 | 获取目录中的条目 | -| create_or_update_file | 文件 | 写入 | 创建或更新文件(提供 sha 以更新现有文件) | -| delete_file | 文件 | 写入 | 删除文件 | -| create_branch | 分支 | 写入 | 创建新分支 | -| delete_branch | 分支 | 写入 | 删除分支 | -| list_branches | 分支 | 读取 | 列出仓库分支 | -| create_tag | Git 标签 | 写入 | 创建标签 | -| delete_tag | Git 标签 | 写入 | 删除标签 | -| get_tag | Git 标签 | 读取 | 获取标签详情 | -| list_tags | Git 标签 | 读取 | 列出仓库标签 | -| list_commits | 提交 | 读取 | 列出仓库提交 | -| get_commit | 提交 | 读取 | 获取提交详情 | -| create_release | 版本发布 | 写入 | 创建版本发布 | -| delete_release | 版本发布 | 写入 | 删除版本发布 | -| get_release | 版本发布 | 读取 | 按 ID 获取版本发布 | -| get_latest_release | 版本发布 | 读取 | 获取最新版本发布 | -| list_releases | 版本发布 | 读取 | 列出仓库版本发布 | +| 工具 | 范围 | 访问 | 描述 | +| :--------------------------- | :----------- | :- | :--------------------------------- | +| 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 | 读取 | 列出仓库版本发布 | -> **说明:** 部分工具是聚合的、基于操作的工具,单个工具通过 `method` 参数暴露多个操作。当服务器以只读模式运行时(`-r` / `GITEA_READONLY`),访问为「写入」的工具会被隐藏;可通过 `-O` / `--tools`(`GITEA_TOOLS`)过滤对外暴露的工具集合。 +> **说明:** 部分工具是聚合的、基于操作的工具,单个工具通过 `method` 参数暴露多个操作。当服务器以只读模式运行时(`-r` / `GITEA_READONLY`),访问为「写入」的工具会被隐藏;可通过 `-S` / `--scope`(`GITEA_SCOPES`)按范围过滤,或通过 `-O` / `--tools`(`GITEA_TOOLS`)按工具名称过滤对外暴露的工具集合。 + +未设置任一参数时,会加载所有工具;仅设置 `--scope` 时,会加载这些范围内的所有工具;仅设置 `--tools` 时,只会加载指定名称的工具;两者都设置时,会加载所选范围的工具与指定工具名称的并集。范围名称即上表「范围」列中的值,未知的范围名称仅会在启动时产生警告并被忽略。 + +```bash +gitea-mcp -S issue,pull_request +gitea-mcp --scope repository,branch --tools get_me +``` 许多工具支持 `page` 和 `per_page` 分页参数。最大有效页面大小由 Gitea 服务器的 `[api].MAX_RESPONSE_ITEMS` 设置决定(默认 **50**),超出的值会被静默截断。 diff --git a/README.zh-tw.md b/README.zh-tw.md index 0f40e69..1808c02 100644 --- a/README.zh-tw.md +++ b/README.zh-tw.md @@ -129,63 +129,70 @@ Cursor 等客戶端可使用 stdio 命令: ## 可用工具 -| 工具 | 範圍 | 存取 | 描述 | -| :--------------------------- | :------- | :--- | :------------------------------------------------------------------- | -| get_gitea_mcp_server_version | 版本 | 讀取 | 取得 Gitea MCP 伺服器版本 | -| get_me | 用戶 | 讀取 | 取得目前已認證用戶 | -| get_user_orgs | 用戶 | 讀取 | 列出目前用戶的組織 | -| search_users | 搜尋 | 讀取 | 搜尋用戶 | -| search_org_teams | 搜尋 | 讀取 | 搜尋組織中的團隊 | -| search_repos | 搜尋 | 讀取 | 搜尋倉庫 | -| search_issues | 搜尋 | 讀取 | 跨倉庫搜尋問題和拉取請求 | -| notification_read | 通知 | 讀取 | 讀取通知:列出(可限定倉庫)或依 ID 取得會話 | -| notification_write | 通知 | 寫入 | 將某條或全部通知標記為已讀 | -| label_read | 標籤 | 讀取 | 讀取倉庫或組織標籤 | -| label_write | 標籤 | 寫入 | 寫入標籤(倉庫或組織):創建、編輯、刪除 | -| milestone_read | 里程碑 | 讀取 | 讀取里程碑:取得單個或列出 | -| milestone_write | 里程碑 | 寫入 | 寫入里程碑:創建、更新、刪除 | -| wiki_read | Wiki | 讀取 | 讀取 Wiki:列出頁面、取得內容、修訂歷史 | -| wiki_write | Wiki | 寫入 | 寫入 Wiki 頁面:創建、更新、刪除 | -| timetracking_read | 時間追蹤 | 讀取 | 讀取時間追蹤:問題/倉庫耗時、活動計時器、我的追蹤記錄 | -| timetracking_write | 時間追蹤 | 寫入 | 寫入時間追蹤:計時器和記錄項目 | -| package_read | 軟體套件 | 讀取 | 讀取軟體套件註冊表:列出套件、列出版本或取得某個版本 | -| package_write | 軟體套件 | 寫入 | 刪除軟體套件版本(不可復原) | -| list_issues | 問題 | 讀取 | 列出倉庫問題 | -| attachment_read | 問題 | 讀取 | 讀取問題/評論附件:列出中繼資料、取得中繼資料或下載內容 | -| issue_read | 問題 | 讀取 | 讀取問題:詳情、評論或標籤 | -| issue_write | 問題 | 寫入 | 寫入問題:創建、更新、管理評論和標籤 | -| list_pull_requests | 拉取請求 | 讀取 | 列出倉庫拉取請求 | -| pull_request_read | 拉取請求 | 讀取 | 讀取拉取請求:詳情、差異、變更檔案、頭部提交狀態、審查 | -| pull_request_write | 拉取請求 | 寫入 | 寫入拉取請求:創建、更新、關閉、重新開啟、合併、更新分支、管理審查者 | -| pull_request_review_write | 拉取請求 | 寫入 | 寫入 PR 審查:創建、提交、刪除、駁回 | -| actions_config_read | Actions | 讀取 | 讀取 Actions 密鑰和變數 | -| actions_config_write | Actions | 寫入 | 寫入 Actions 密鑰和變數:更新插入、創建、更新、刪除 | -| actions_run_read | Actions | 讀取 | 讀取 Actions 工作流程、執行、作業、日誌和產物 | -| actions_run_write | Actions | 寫入 | 寫入 Actions 執行:觸發、取消、重新執行 | -| create_repo | 倉庫 | 寫入 | 創建新倉庫 | -| fork_repo | 倉庫 | 寫入 | 復刻倉庫 | -| list_my_repos | 倉庫 | 讀取 | 列出目前用戶擁有的倉庫 | -| list_org_repos | 倉庫 | 讀取 | 列出組織中的倉庫 | -| get_repository_tree | 倉庫 | 讀取 | 取得倉庫檔案樹 | -| get_file_contents | 文件 | 讀取 | 取得檔案內容與中繼資料 | -| get_dir_contents | 文件 | 讀取 | 取得目錄中的項目 | -| create_or_update_file | 文件 | 寫入 | 創建或更新檔案(提供 sha 以更新現有檔案) | -| delete_file | 文件 | 寫入 | 刪除檔案 | -| create_branch | 分支 | 寫入 | 創建新分支 | -| delete_branch | 分支 | 寫入 | 刪除分支 | -| list_branches | 分支 | 讀取 | 列出倉庫分支 | -| create_tag | Git 標籤 | 寫入 | 創建標籤 | -| delete_tag | Git 標籤 | 寫入 | 刪除標籤 | -| get_tag | Git 標籤 | 讀取 | 取得標籤詳情 | -| list_tags | Git 標籤 | 讀取 | 列出倉庫標籤 | -| list_commits | 提交 | 讀取 | 列出倉庫提交 | -| get_commit | 提交 | 讀取 | 取得提交詳情 | -| create_release | 版本發布 | 寫入 | 創建版本發布 | -| delete_release | 版本發布 | 寫入 | 刪除版本發布 | -| get_release | 版本發布 | 讀取 | 依 ID 取得版本發布 | -| get_latest_release | 版本發布 | 讀取 | 取得最新版本發布 | -| list_releases | 版本發布 | 讀取 | 列出倉庫版本發布 | +| 工具 | 範圍 | 存取 | 描述 | +| :--------------------------- | :----------- | :- | :--------------------------------- | +| 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 | 讀取 | 列出倉庫版本發布 | -> **說明:** 部分工具是聚合的、基於操作的工具,單個工具透過 `method` 參數暴露多個操作。當伺服器以唯讀模式執行時(`-r` / `GITEA_READONLY`),存取為「寫入」的工具會被隱藏;可透過 `-O` / `--tools`(`GITEA_TOOLS`)過濾對外暴露的工具集合。 +> **說明:** 部分工具是聚合的、基於操作的工具,單個工具透過 `method` 參數暴露多個操作。當伺服器以唯讀模式執行時(`-r` / `GITEA_READONLY`),存取為「寫入」的工具會被隱藏;可透過 `-S` / `--scope`(`GITEA_SCOPES`)依範圍過濾,或透過 `-O` / `--tools`(`GITEA_TOOLS`)依工具名稱過濾對外暴露的工具集合。 + +未設定任一參數時,會載入所有工具;僅設定 `--scope` 時,會載入這些範圍內的所有工具;僅設定 `--tools` 時,只會載入指定名稱的工具;兩者皆設定時,會載入所選範圍的工具與指定工具名稱的聯集。範圍名稱即上表「範圍」欄中的值,未知的範圍名稱僅會在啟動時發出警告並被忽略。 + +```bash +gitea-mcp -S issue,pull_request +gitea-mcp --scope repository,branch --tools get_me +``` 許多工具支援 `page` 和 `per_page` 分頁參數。最大有效頁面大小由 Gitea 伺服器的 `[api].MAX_RESPONSE_ITEMS` 設定決定(預設 **50**),超出的值會被靜默截斷。 diff --git a/cmd/cmd.go b/cmd/cmd.go index ba70f32..a8a3bc6 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -20,16 +20,13 @@ var ( port int token string tools string + scopes string version bool maxInlineAttachmentBytes int maxInlineAttachmentBytesFlagSet bool osExit = os.Exit ) -func init() { - initFlagSet(flag.CommandLine, os.Args[1:], os.Getenv, os.ReadFile, os.Stderr) -} - func initFlagSet(fs *flag.FlagSet, args []string, getenv func(string) string, readFile func(string) ([]byte, error), stderr io.Writer) { fs.StringVar(&flagPkg.Mode, "t", "stdio", "") fs.StringVar(&flagPkg.Mode, "transport", "stdio", "") @@ -44,6 +41,9 @@ func initFlagSet(fs *flag.FlagSet, args []string, getenv func(string) string, re defaultTools := getenv("GITEA_TOOLS") fs.StringVar(&tools, "O", defaultTools, "") fs.StringVar(&tools, "tools", defaultTools, "") + defaultScopes := getenv("GITEA_SCOPES") + fs.StringVar(&scopes, "S", defaultScopes, "") + fs.StringVar(&scopes, "scope", defaultScopes, "") fs.BoolVar(&flagPkg.Debug, "d", false, "") fs.BoolVar(&flagPkg.Debug, "debug", false, "") fs.BoolVar(&flagPkg.Insecure, "k", false, "") @@ -72,6 +72,7 @@ func initFlagSet(fs *flag.FlagSet, args []string, getenv func(string) string, re fmt.Fprintf(w, " -T, -token \tPersonal access token\n") fmt.Fprintf(w, " -r, -read-only\tExpose only read-only tools\n") fmt.Fprintf(w, " -O, -tools \tComma-separated list of tool names to expose\n") + fmt.Fprintf(w, " -S, -scope \tComma-separated list of tool scopes to expose\n") fmt.Fprintf(w, " -d, -debug\tEnable debug mode\n") fmt.Fprintf(w, " -k, -insecure\tIgnore TLS certificate errors\n") fmt.Fprintf(w, " -max-inline-attachment-bytes \tInline image attachments up to this size (default: 5242880)\n") @@ -85,6 +86,7 @@ func initFlagSet(fs *flag.FlagSet, args []string, getenv func(string) string, re fmt.Fprintf(w, " GITEA_INSECURE\tSet to 'true' to ignore TLS errors\n") fmt.Fprintf(w, " GITEA_MAX_INLINE_ATTACHMENT_BYTES\tOverride inline image attachment size limit in bytes\n") fmt.Fprintf(w, " GITEA_READONLY\tSet to 'true' for read-only mode\n") + fmt.Fprintf(w, " GITEA_SCOPES\tComma-separated list of tool scopes to expose\n") fmt.Fprintf(w, " GITEA_TOOLS\tComma-separated list of tool names to expose\n") fmt.Fprintf(w, " MCP_MODE\tOverride transport mode\n") _ = w.Flush() @@ -131,6 +133,16 @@ func initFlagSet(fs *flag.FlagSet, args []string, getenv func(string) string, re if len(allowed) > 0 { flagPkg.AllowedTools = allowed } + + allowedScopes := map[string]struct{}{} + for s := range strings.SplitSeq(scopes, ",") { + if s = normalizeScope(s); s != "" { + allowedScopes[s] = struct{}{} + } + } + if len(allowedScopes) > 0 { + flagPkg.AllowedScopes = allowedScopes + } if getenv("GITEA_DEBUG") == "true" { flagPkg.Debug = true } @@ -149,7 +161,19 @@ func initFlagSet(fs *flag.FlagSet, args []string, getenv func(string) string, re } } +// normalizeScope trims whitespace, lowercases, and converts internal spaces +// and hyphens to underscores, so "Pull Request", "pull-request", and +// "PULL_REQUEST" all normalize to "pull_request". +func normalizeScope(s string) string { + s = strings.TrimSpace(s) + s = strings.ToLower(s) + s = strings.ReplaceAll(s, " ", "_") + s = strings.ReplaceAll(s, "-", "_") + return s +} + func Execute() { + initFlagSet(flag.CommandLine, os.Args[1:], os.Getenv, os.ReadFile, os.Stderr) if version { fmt.Fprintln(os.Stdout, flagPkg.Version) return diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go new file mode 100644 index 0000000..194c718 --- /dev/null +++ b/cmd/cmd_test.go @@ -0,0 +1,76 @@ +package cmd + +import ( + "bytes" + "flag" + "maps" + "slices" + "testing" + + flagPkg "gitea.com/gitea/gitea-mcp/pkg/flag" +) + +func TestInitFlagSetScopes(t *testing.T) { + tests := []struct { + name string + args []string + env map[string]string + want []string + }{ + { + name: "no scope flag or env leaves AllowedScopes unset", + args: []string{}, + want: nil, + }, + { + name: "-S sets a single scope", + args: []string{"-S", "repository"}, + want: []string{"repository"}, + }, + { + name: "-scope sets a comma-separated list", + args: []string{"-scope", "repository,file"}, + want: []string{"file", "repository"}, + }, + { + name: "GITEA_SCOPES env sets the default", + args: []string{}, + env: map[string]string{"GITEA_SCOPES": "issue,pull_request"}, + want: []string{"issue", "pull_request"}, + }, + { + name: "-S flag takes precedence over GITEA_SCOPES env", + args: []string{"-S", "file"}, + env: map[string]string{"GITEA_SCOPES": "issue"}, + want: []string{"file"}, + }, + { + name: "normalizes case, whitespace, and hyphens/spaces to underscores", + args: []string{"-S", " Pull Request , pull-request , PULL_REQUEST "}, + want: []string{"pull_request"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + origScopes := flagPkg.AllowedScopes + t.Cleanup(func() { + flagPkg.AllowedScopes = origScopes + }) + flagPkg.AllowedScopes = nil + + getenv := func(key string) string { return tt.env[key] } + readFile := func(string) ([]byte, error) { return nil, nil } + fs := flag.NewFlagSet("test", flag.ContinueOnError) + var stderr bytes.Buffer + + initFlagSet(fs, tt.args, getenv, readFile, &stderr) + + got := slices.Sorted(maps.Keys(flagPkg.AllowedScopes)) + want := slices.Clone(tt.want) + slices.Sort(want) + if !slices.Equal(got, want) { + t.Errorf("AllowedScopes = %v, want %v", got, want) + } + }) + } +} diff --git a/operation/actions/actions.go b/operation/actions/actions.go index 3c314cc..3dd4a3c 100644 --- a/operation/actions/actions.go +++ b/operation/actions/actions.go @@ -5,4 +5,4 @@ import ( ) // Tool is the registry for all Actions-related MCP tools. -var Tool = tool.New() +var Tool = tool.New("actions") diff --git a/operation/issue/issue.go b/operation/issue/issue.go index d74c3dc..ef4a731 100644 --- a/operation/issue/issue.go +++ b/operation/issue/issue.go @@ -29,7 +29,7 @@ type commentWithAssets struct { Assets []*gitea_sdk.Attachment `json:"assets"` } -var Tool = tool.New() +var Tool = tool.New("issue") const ( ListRepoIssuesToolName = "list_issues" diff --git a/operation/label/label.go b/operation/label/label.go index 3fce252..8123a1f 100644 --- a/operation/label/label.go +++ b/operation/label/label.go @@ -16,7 +16,7 @@ import ( "github.com/mark3labs/mcp-go/server" ) -var Tool = tool.New() +var Tool = tool.New("label") const ( LabelReadToolName = "label_read" diff --git a/operation/milestone/milestone.go b/operation/milestone/milestone.go index 8b304f3..c1345c1 100644 --- a/operation/milestone/milestone.go +++ b/operation/milestone/milestone.go @@ -15,7 +15,7 @@ import ( "github.com/mark3labs/mcp-go/server" ) -var Tool = tool.New() +var Tool = tool.New("milestone") const ( MilestoneReadToolName = "milestone_read" diff --git a/operation/notification/notification.go b/operation/notification/notification.go index 6ed5a2f..f219d70 100644 --- a/operation/notification/notification.go +++ b/operation/notification/notification.go @@ -16,7 +16,7 @@ import ( "github.com/mark3labs/mcp-go/server" ) -var Tool = tool.New() +var Tool = tool.New("notification") const ( NotificationReadToolName = "notification_read" diff --git a/operation/operation.go b/operation/operation.go index c6454be..0732e1a 100644 --- a/operation/operation.go +++ b/operation/operation.go @@ -39,6 +39,7 @@ var ( user.Tool, actions.Tool, repo.Tool, notification.Tool, issue.Tool, label.Tool, milestone.Tool, packages.Tool, pull.Tool, search.Tool, version.Tool, wiki.Tool, timetracking.Tool, + repo.FileTool, repo.BranchTool, repo.TagTool, repo.CommitTool, repo.ReleaseTool, } ) @@ -47,6 +48,7 @@ func RegisterTool(s *server.MCPServer) { s.AddTools(t.Tools()...) } tool.WarnUnmatchedAllowedTools(domainTools...) + tool.WarnUnmatchedAllowedScopes(domainTools...) } // parseAuthToken extracts the token from an Authorization header. diff --git a/operation/operation_test.go b/operation/operation_test.go index d3c28a5..49e7d70 100644 --- a/operation/operation_test.go +++ b/operation/operation_test.go @@ -32,6 +32,26 @@ func TestAllToolsHaveDescriptions(t *testing.T) { } } +// TestDomainToolsScopesAreUniqueAndNonEmpty ensures every entry registered in +// domainTools has a canonical, non-empty scope name and that no two domains +// share the same scope (each domain.Tools() call is filtered by exactly one +// scope name via flag.AllowedScopes). +func TestDomainToolsScopesAreUniqueAndNonEmpty(t *testing.T) { + seen := map[string]struct{}{} + for _, d := range domainTools { + scope := d.Scope() + if scope == "" { + t.Errorf("domainTools contains a domain with an empty scope") + continue + } + if _, ok := seen[scope]; ok { + t.Errorf("domainTools contains a duplicate scope %q", scope) + continue + } + seen[scope] = struct{}{} + } +} + func TestParseAuthToken(t *testing.T) { tests := []struct { name string diff --git a/operation/packages/packages.go b/operation/packages/packages.go index 335bb77..cba0aac 100644 --- a/operation/packages/packages.go +++ b/operation/packages/packages.go @@ -17,7 +17,7 @@ import ( "github.com/mark3labs/mcp-go/server" ) -var Tool = tool.New() +var Tool = tool.New("packages") const ( PackageReadToolName = "package_read" diff --git a/operation/pull/pull.go b/operation/pull/pull.go index eb725d5..3983f6e 100644 --- a/operation/pull/pull.go +++ b/operation/pull/pull.go @@ -19,7 +19,7 @@ import ( "github.com/mark3labs/mcp-go/server" ) -var Tool = tool.New() +var Tool = tool.New("pull_request") const ( ListRepoPullRequestsToolName = "list_pull_requests" diff --git a/operation/readme_test.go b/operation/readme_test.go index 7bc5f54..51406ec 100644 --- a/operation/readme_test.go +++ b/operation/readme_test.go @@ -11,9 +11,9 @@ import ( ) // toolTableRow matches a row of the "Available Tools" table in the README -// files, capturing the tool name and the access cell, e.g. -// "| get_me | User | Read | Get the current authenticated user |". -var toolTableRow = regexp.MustCompile(`^\|\s*([a-z_]+)\s*\|[^|]*\|\s*(\S+)\s*\|`) +// files, capturing the tool name, the scope cell and the access cell, e.g. +// "| get_me | user | Read | Get the current authenticated user |". +var toolTableRow = regexp.MustCompile(`^\|\s*([a-z_]+)\s*\|\s*([a-z_]+)\s*\|\s*(\S+)\s*\|`) // readmeAccessLabels maps each README to the access-column labels it uses. var readmeAccessLabels = map[string]map[string]string{ @@ -22,18 +22,28 @@ var readmeAccessLabels = map[string]map[string]string{ "../README.zh-tw.md": {"讀取": "read", "寫入": "write"}, } +// toolInfo is what TestReadmeToolTables tracks per tool, both as registered +// in code and as documented in a README, so the two can be compared. +type toolInfo struct { + scope string + access string +} + // TestReadmeToolTables ensures the tool tables in the README files stay in sync // with the registered tools, in both directions and for every translation. // The tables listed tools that no longer existed for several releases before // anyone noticed. +// The scope names in the README are the canonical, lowercase snake_case names +// returned by (*tool.Tool).Scope(), so no translation is needed to compare them. func TestReadmeToolTables(t *testing.T) { - registered := map[string]string{} + registered := map[string]toolInfo{} for _, d := range domainTools { + scope := d.Scope() for _, st := range d.ReadTools() { - registered[st.Tool.Name] = "read" + registered[st.Tool.Name] = toolInfo{scope: scope, access: "read"} } for _, st := range d.WriteTools() { - registered[st.Tool.Name] = "write" + registered[st.Tool.Name] = toolInfo{scope: scope, access: "write"} } } @@ -43,20 +53,23 @@ func TestReadmeToolTables(t *testing.T) { if err != nil { t.Fatal(err) } - documented := map[string]string{} + documented := map[string]toolInfo{} for line := range strings.SplitSeq(string(content), "\n") { if match := toolTableRow.FindStringSubmatch(line); match != nil { - documented[match[1]] = labels[match[2]] + documented[match[1]] = toolInfo{scope: match[2], access: labels[match[3]]} } } for _, name := range slices.Sorted(maps.Keys(registered)) { - access, ok := documented[name] + got, ok := documented[name] + want := registered[name] switch { case !ok: t.Errorf("tool %q is registered but missing from the tool table", name) - case access != registered[name]: - t.Errorf("tool %q is documented with %q access, want %q", name, access, registered[name]) + case got.access != want.access: + t.Errorf("tool %q is documented with %q access, want %q", name, got.access, want.access) + case got.scope != want.scope: + t.Errorf("tool %q is documented with scope %q, want %q", name, got.scope, want.scope) } } for _, name := range slices.Sorted(maps.Keys(documented)) { diff --git a/operation/repo/branch.go b/operation/repo/branch.go index 0775b9b..d40254c 100644 --- a/operation/repo/branch.go +++ b/operation/repo/branch.go @@ -8,12 +8,16 @@ import ( "gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/params" "gitea.com/gitea/gitea-mcp/pkg/to" + "gitea.com/gitea/gitea-mcp/pkg/tool" gitea_sdk "gitea.dev/sdk" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) +// BranchTool holds the branch-related tools (scope "branch"). +var BranchTool = tool.New("branch") + const ( CreateBranchToolName = "create_branch" DeleteBranchToolName = "delete_branch" @@ -52,15 +56,15 @@ var ( ) func init() { - Tool.RegisterWrite(server.ServerTool{ + BranchTool.RegisterWrite(server.ServerTool{ Tool: CreateBranchTool, Handler: CreateBranchFn, }) - Tool.RegisterWrite(server.ServerTool{ + BranchTool.RegisterWrite(server.ServerTool{ Tool: DeleteBranchTool, Handler: DeleteBranchFn, }) - Tool.RegisterRead(server.ServerTool{ + BranchTool.RegisterRead(server.ServerTool{ Tool: ListBranchesTool, Handler: ListBranchesFn, }) diff --git a/operation/repo/commit.go b/operation/repo/commit.go index c3aa9d3..6abc895 100644 --- a/operation/repo/commit.go +++ b/operation/repo/commit.go @@ -8,12 +8,16 @@ import ( "gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/params" "gitea.com/gitea/gitea-mcp/pkg/to" + "gitea.com/gitea/gitea-mcp/pkg/tool" gitea_sdk "gitea.dev/sdk" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) +// CommitTool holds the commit-related tools (scope "commit"). +var CommitTool = tool.New("commit") + const ( ListRepoCommitsToolName = "list_commits" GetCommitToolName = "get_commit" @@ -43,11 +47,11 @@ var ( ) func init() { - Tool.RegisterRead(server.ServerTool{ + CommitTool.RegisterRead(server.ServerTool{ Tool: ListRepoCommitsTool, Handler: ListRepoCommitsFn, }) - Tool.RegisterRead(server.ServerTool{ + CommitTool.RegisterRead(server.ServerTool{ Tool: GetCommitTool, Handler: GetCommitFn, }) diff --git a/operation/repo/file.go b/operation/repo/file.go index c6f20a3..a04af73 100644 --- a/operation/repo/file.go +++ b/operation/repo/file.go @@ -12,12 +12,16 @@ import ( "gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/params" "gitea.com/gitea/gitea-mcp/pkg/to" + "gitea.com/gitea/gitea-mcp/pkg/tool" gitea_sdk "gitea.dev/sdk" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) +// FileTool holds the file-related tools (scope "file"). +var FileTool = tool.New("file") + const ( GetFileToolName = "get_file_contents" GetDirToolName = "get_dir_contents" @@ -75,19 +79,19 @@ var ( ) func init() { - Tool.RegisterRead(server.ServerTool{ + FileTool.RegisterRead(server.ServerTool{ Tool: GetFileContentTool, Handler: GetFileContentFn, }) - Tool.RegisterRead(server.ServerTool{ + FileTool.RegisterRead(server.ServerTool{ Tool: GetDirContentTool, Handler: GetDirContentFn, }) - Tool.RegisterWrite(server.ServerTool{ + FileTool.RegisterWrite(server.ServerTool{ Tool: CreateOrUpdateFileTool, Handler: CreateOrUpdateFileFn, }) - Tool.RegisterWrite(server.ServerTool{ + FileTool.RegisterWrite(server.ServerTool{ Tool: DeleteFileTool, Handler: DeleteFileFn, }) diff --git a/operation/repo/release.go b/operation/repo/release.go index a61c575..b632025 100644 --- a/operation/repo/release.go +++ b/operation/repo/release.go @@ -8,12 +8,16 @@ import ( "gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/params" "gitea.com/gitea/gitea-mcp/pkg/to" + "gitea.com/gitea/gitea-mcp/pkg/tool" gitea_sdk "gitea.dev/sdk" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) +// ReleaseTool holds the release-related tools (scope "release"). +var ReleaseTool = tool.New("release") + const ( CreateReleaseToolName = "create_release" DeleteReleaseToolName = "delete_release" @@ -77,23 +81,23 @@ var ( ) func init() { - Tool.RegisterWrite(server.ServerTool{ + ReleaseTool.RegisterWrite(server.ServerTool{ Tool: CreateReleaseTool, Handler: CreateReleaseFn, }) - Tool.RegisterWrite(server.ServerTool{ + ReleaseTool.RegisterWrite(server.ServerTool{ Tool: DeleteReleaseTool, Handler: DeleteReleaseFn, }) - Tool.RegisterRead(server.ServerTool{ + ReleaseTool.RegisterRead(server.ServerTool{ Tool: GetReleaseTool, Handler: GetReleaseFn, }) - Tool.RegisterRead(server.ServerTool{ + ReleaseTool.RegisterRead(server.ServerTool{ Tool: GetLatestReleaseTool, Handler: GetLatestReleaseFn, }) - Tool.RegisterRead(server.ServerTool{ + ReleaseTool.RegisterRead(server.ServerTool{ Tool: ListReleasesTool, Handler: ListReleasesFn, }) diff --git a/operation/repo/repo.go b/operation/repo/repo.go index 2f1f025..1183859 100644 --- a/operation/repo/repo.go +++ b/operation/repo/repo.go @@ -16,7 +16,7 @@ import ( "github.com/mark3labs/mcp-go/server" ) -var Tool = tool.New() +var Tool = tool.New("repository") const ( CreateRepoToolName = "create_repo" diff --git a/operation/repo/tag.go b/operation/repo/tag.go index 14c2579..ba6f176 100644 --- a/operation/repo/tag.go +++ b/operation/repo/tag.go @@ -8,12 +8,16 @@ import ( "gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/params" "gitea.com/gitea/gitea-mcp/pkg/to" + "gitea.com/gitea/gitea-mcp/pkg/tool" gitea_sdk "gitea.dev/sdk" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) +// TagTool holds the tag-related tools (scope "tag"). +var TagTool = tool.New("tag") + const ( CreateTagToolName = "create_tag" DeleteTagToolName = "delete_tag" @@ -63,19 +67,19 @@ var ( ) func init() { - Tool.RegisterWrite(server.ServerTool{ + TagTool.RegisterWrite(server.ServerTool{ Tool: CreateTagTool, Handler: CreateTagFn, }) - Tool.RegisterWrite(server.ServerTool{ + TagTool.RegisterWrite(server.ServerTool{ Tool: DeleteTagTool, Handler: DeleteTagFn, }) - Tool.RegisterRead(server.ServerTool{ + TagTool.RegisterRead(server.ServerTool{ Tool: GetTagTool, Handler: GetTagFn, }) - Tool.RegisterRead(server.ServerTool{ + TagTool.RegisterRead(server.ServerTool{ Tool: ListTagsTool, Handler: ListTagsFn, }) diff --git a/operation/search/search.go b/operation/search/search.go index 8de308b..3696f4c 100644 --- a/operation/search/search.go +++ b/operation/search/search.go @@ -17,7 +17,7 @@ import ( "github.com/mark3labs/mcp-go/server" ) -var Tool = tool.New() +var Tool = tool.New("search") const ( SearchUsersToolName = "search_users" diff --git a/operation/timetracking/timetracking.go b/operation/timetracking/timetracking.go index a708819..3e7354e 100644 --- a/operation/timetracking/timetracking.go +++ b/operation/timetracking/timetracking.go @@ -16,7 +16,7 @@ import ( "github.com/mark3labs/mcp-go/server" ) -var Tool = tool.New() +var Tool = tool.New("timetracking") const ( TimetrackingReadToolName = "timetracking_read" diff --git a/operation/user/user.go b/operation/user/user.go index de330c8..78c9b15 100644 --- a/operation/user/user.go +++ b/operation/user/user.go @@ -21,7 +21,7 @@ const ( GetUserOrgsToolName = "get_user_orgs" ) -var Tool = tool.New() +var Tool = tool.New("user") var ( GetMyUserInfoTool = mcp.NewTool( diff --git a/operation/version/version.go b/operation/version/version.go index 5f4d4e3..9a929b4 100644 --- a/operation/version/version.go +++ b/operation/version/version.go @@ -13,7 +13,7 @@ import ( "github.com/mark3labs/mcp-go/server" ) -var Tool = tool.New() +var Tool = tool.New("version") const ( GetGiteaMCPServerVersion = "get_gitea_mcp_server_version" diff --git a/operation/wiki/wiki.go b/operation/wiki/wiki.go index a8edee5..dde4892 100644 --- a/operation/wiki/wiki.go +++ b/operation/wiki/wiki.go @@ -16,7 +16,7 @@ import ( "github.com/mark3labs/mcp-go/server" ) -var Tool = tool.New() +var Tool = tool.New("wiki") const ( WikiReadToolName = "wiki_read" diff --git a/pkg/flag/flag.go b/pkg/flag/flag.go index fe796a3..058a65a 100644 --- a/pkg/flag/flag.go +++ b/pkg/flag/flag.go @@ -9,8 +9,9 @@ var ( MaxInlineAttachmentBytes int - Insecure bool - ReadOnly bool - Debug bool - AllowedTools map[string]struct{} + Insecure bool + ReadOnly bool + Debug bool + AllowedTools map[string]struct{} + AllowedScopes map[string]struct{} ) diff --git a/pkg/tool/tool.go b/pkg/tool/tool.go index ce9bd4b..4eb0329 100644 --- a/pkg/tool/tool.go +++ b/pkg/tool/tool.go @@ -11,17 +11,24 @@ import ( ) type Tool struct { + scope string write []server.ServerTool read []server.ServerTool } -func New() *Tool { +func New(scope string) *Tool { return &Tool{ + scope: scope, write: make([]server.ServerTool, 0, 100), read: make([]server.ServerTool, 0, 100), } } +// Scope returns the canonical scope name this domain of tools was registered under. +func (t *Tool) Scope() string { + return t.scope +} + func (t *Tool) RegisterWrite(s server.ServerTool) { t.write = append(t.write, s) } @@ -42,31 +49,55 @@ func (t *Tool) WriteTools() []server.ServerTool { return t.write } +// Tools returns the tools registered on this domain after applying the +// read-only filter and the scope/tool allowlists (union semantics: a tool is +// kept if its domain's scope is in AllowedScopes OR its name is in +// AllowedTools). With no allowlists set, all tools pass through unchanged. func (t *Tool) Tools() []server.ServerTool { all := make([]server.ServerTool, 0, len(t.write)+len(t.read)) if !flag.ReadOnly { all = append(all, t.write...) } all = append(all, t.read...) - if len(flag.AllowedTools) == 0 { + if len(flag.AllowedScopes) == 0 && len(flag.AllowedTools) == 0 { return all } + _, scopeAllowed := flag.AllowedScopes[t.scope] filtered := make([]server.ServerTool, 0, len(all)) for _, st := range all { - if _, ok := flag.AllowedTools[st.Tool.Name]; ok { + _, toolAllowed := flag.AllowedTools[st.Tool.Name] + if scopeAllowed || toolAllowed { filtered = append(filtered, st) } } return filtered } +// warnUnmatched logs the names present in allowlist but absent from known, +// via logUnmatched, so WarnUnmatchedAllowedTools and WarnUnmatchedAllowedScopes +// share the same "collect, sort, no-op when empty" logic and can't drift. +// No-op if allowlist is empty or every name in it is known. +func warnUnmatched(allowlist, known map[string]struct{}, logUnmatched func(unmatched []string)) { + if len(allowlist) == 0 { + return + } + var unmatched []string + for name := range allowlist { + if _, ok := known[name]; !ok { + unmatched = append(unmatched, name) + } + } + if len(unmatched) == 0 { + return + } + slices.Sort(unmatched) + logUnmatched(unmatched) +} + // WarnUnmatchedAllowedTools logs any names in flag.AllowedTools that don't // match a tool registered on any of the given domains. No-op if the allowlist // is empty. func WarnUnmatchedAllowedTools(domains ...*Tool) { - if len(flag.AllowedTools) == 0 { - return - } known := map[string]struct{}{} for _, d := range domains { for _, st := range d.read { @@ -76,15 +107,25 @@ func WarnUnmatchedAllowedTools(domains ...*Tool) { known[st.Tool.Name] = struct{}{} } } - var unmatched []string - for name := range flag.AllowedTools { - if _, ok := known[name]; !ok { - unmatched = append(unmatched, name) + warnUnmatched(flag.AllowedTools, known, func(unmatched []string) { + log.Warnf("Unknown tools in --tools allowlist (ignored): %s", strings.Join(unmatched, ", ")) + }) +} + +// WarnUnmatchedAllowedScopes logs any names in flag.AllowedScopes that don't +// match the scope of any of the given domains. No-op if the allowlist is +// empty. +func WarnUnmatchedAllowedScopes(domains ...*Tool) { + knownSet := map[string]struct{}{} + known := make([]string, 0, len(domains)) + for _, d := range domains { + if _, ok := knownSet[d.scope]; !ok { + knownSet[d.scope] = struct{}{} + known = append(known, d.scope) } } - if len(unmatched) == 0 { - return - } - slices.Sort(unmatched) - log.Warnf("Unknown tools in --tools allowlist (ignored): %s", strings.Join(unmatched, ", ")) + warnUnmatched(flag.AllowedScopes, knownSet, func(unmatched []string) { + slices.Sort(known) + log.Warnf("Unknown scopes in --scope allowlist (ignored): %s. Valid scopes: %s", strings.Join(unmatched, ", "), strings.Join(known, ", ")) + }) } diff --git a/pkg/tool/tool_test.go b/pkg/tool/tool_test.go index 7b62857..1fcb761 100644 --- a/pkg/tool/tool_test.go +++ b/pkg/tool/tool_test.go @@ -83,7 +83,7 @@ func TestTools(t *testing.T) { flag.ReadOnly = tt.readOnly flag.AllowedTools = tt.allowed - tr := New() + tr := New("scope1") for _, n := range tt.read { tr.RegisterRead(makeTool(n)) } @@ -98,3 +98,130 @@ func TestTools(t *testing.T) { }) } } + +func TestToolsScopeFiltering(t *testing.T) { + tests := []struct { + name string + toolScope string + readOnly bool + allowedScopes map[string]struct{} + allowedTools map[string]struct{} + read []string + write []string + want []string + }{ + { + name: "no scope or tool filters returns all", + toolScope: "repository", + read: []string{"r1"}, + write: []string{"w1"}, + want: []string{"w1", "r1"}, + }, + { + name: "matching scope keeps everything", + toolScope: "repository", + allowedScopes: map[string]struct{}{"repository": {}}, + read: []string{"r1"}, + write: []string{"w1"}, + want: []string{"w1", "r1"}, + }, + { + name: "non-matching scope drops everything not in allowed tools", + toolScope: "repository", + allowedScopes: map[string]struct{}{"file": {}}, + read: []string{"r1"}, + write: []string{"w1"}, + want: []string{}, + }, + { + name: "tools-only allowlist behaves as before scopes existed", + toolScope: "repository", + allowedTools: map[string]struct{}{"r1": {}}, + read: []string{"r1", "r2"}, + write: []string{"w1"}, + want: []string{"r1"}, + }, + { + name: "scope and tools allowlists are unioned", + toolScope: "repository", + allowedScopes: map[string]struct{}{"file": {}}, + allowedTools: map[string]struct{}{"r1": {}}, + read: []string{"r1", "r2"}, + write: []string{"w1"}, + want: []string{"r1"}, + }, + { + name: "matching scope combined with read-only drops write entries", + toolScope: "repository", + readOnly: true, + allowedScopes: map[string]struct{}{"repository": {}}, + read: []string{"r1"}, + write: []string{"w1"}, + want: []string{"r1"}, + }, + { + name: "unknown scope name matches nothing", + toolScope: "repository", + allowedScopes: map[string]struct{}{"unknown_scope": {}}, + read: []string{"r1"}, + write: []string{"w1"}, + want: []string{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + origRO, origAllowScopes, origAllowTools := flag.ReadOnly, flag.AllowedScopes, flag.AllowedTools + t.Cleanup(func() { + flag.ReadOnly, flag.AllowedScopes, flag.AllowedTools = origRO, origAllowScopes, origAllowTools + }) + flag.ReadOnly = tt.readOnly + flag.AllowedScopes = tt.allowedScopes + flag.AllowedTools = tt.allowedTools + + tr := New(tt.toolScope) + for _, n := range tt.read { + tr.RegisterRead(makeTool(n)) + } + for _, n := range tt.write { + tr.RegisterWrite(makeTool(n)) + } + + got := names(tr.Tools()) + if !slices.Equal(got, tt.want) { + t.Errorf("Tools() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestScope(t *testing.T) { + tr := New("repository") + if got := tr.Scope(); got != "repository" { + t.Errorf("Scope() = %q, want %q", got, "repository") + } +} + +func TestWarnUnmatchedAllowedScopes(t *testing.T) { + origAllowScopes := flag.AllowedScopes + t.Cleanup(func() { + flag.AllowedScopes = origAllowScopes + }) + + repoTool := New("repository") + fileTool := New("file") + + t.Run("empty allowlist is a no-op", func(t *testing.T) { + flag.AllowedScopes = nil + WarnUnmatchedAllowedScopes(repoTool, fileTool) + }) + + t.Run("known scopes produce no warning", func(t *testing.T) { + flag.AllowedScopes = map[string]struct{}{"repository": {}, "file": {}} + WarnUnmatchedAllowedScopes(repoTool, fileTool) + }) + + t.Run("unknown scope is tolerated", func(t *testing.T) { + flag.AllowedScopes = map[string]struct{}{"not_a_real_scope": {}} + WarnUnmatchedAllowedScopes(repoTool, fileTool) + }) +}