- Rust 92.7%
- Just 4.3%
- Dockerfile 2%
- Nushell 1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
Check / fmt + clippy + build + tests (push) Successful in 38s
Build binary (Linux) / Build and publish forgejo-mcp binary (Linux x86_64) (push) Successful in 1m20s
Build binary (Windows) / Build and publish forgejo-mcp binary (Windows x86_64) (push) Successful in 1m24s
Build binary (Linux static musl) / Build and publish forgejo-mcp binary (Linux x86_64 static musl) (push) Successful in 2m11s
Reviewed-on: #31 |
||
| .forgejo/workflows | ||
| oci-build | ||
| src | ||
| .dockerignore | ||
| .gitignore | ||
| build.rs | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CLAUDE.md | ||
| justfile | ||
| LICENSE-APACHE | ||
| LICENSE-MIT | ||
| README.md | ||
forgejo-mcp
A Model Context Protocol server that exposes Forgejo operations to MCP clients. It is built on the forgejo-cli crates (fj-client, fj-core), so authentication and API calls reuse the same code path as the fj CLI rather than reimplementing them.
What it does
Speaks MCP over stdio (default) or Streamable HTTP (MCP_TRANSPORT=http, see Transports) and exposes a small set of tools:
Read-only tools (always available):
| Tool | Arguments | Returns |
|---|---|---|
whoami |
none | The authenticated user for the configured host. |
get_repo |
owner, repo |
Repository metadata. |
list_issues |
owner, repo, state? (open/closed/all, default open), labels?, milestones?, assignee? |
One page of issues. |
list_all_issues |
owner, repo, state?, labels?, milestones?, assignee? |
Every issue, following pagination. |
get_issue |
owner, repo, index |
A single issue by number. |
list_issue_comments |
owner, repo, index, since?, before? (RFC 3339) |
An issue's comments. |
get_issue_templates |
owner, repo |
The repo's issue templates. |
get_issue_config |
owner, repo |
Blank-issue policy and contact links. |
list_issue_attachments |
owner, repo, index |
An issue's attachments (each with id, name, size, browser_download_url). |
download_issue_attachment |
owner, repo, index, asset_id, dest_path? |
Writes the attachment to a file; returns { path, bytes, filename } (not the bytes). |
get_pull_request |
owner, repo, index |
A pull request by number. |
get_pull_request_by_base_head |
owner, repo, base, head |
A pull request by base/head labels. |
find_pull_request_by_branch |
owner, repo, head |
The open PR from a branch, or null. |
list_pull_request_files |
owner, repo, index |
A PR's changed files (paginated). |
list_pull_request_commits |
owner, repo, index |
A PR's commits (paginated). |
compare_refs |
owner, repo, basehead (base...head) |
A diff comparison of two refs. |
get_pull_request_diff |
owner, repo, index, diff_type? (diff/patch, default diff) |
Raw diff/patch text (not JSON). |
list_pull_request_reviews |
owner, repo, index |
A PR's reviews (paginated). |
get_pull_request_review_comments |
owner, repo, index, review_id |
A review's inline comments. |
get_pull_request_commit_statuses |
owner, repo, sha |
Combined commit statuses for a SHA. |
get_pull_request_template |
owner, repo |
Decoded PR template text plus is_yaml, or null. |
list_repo_contents |
owner, repo, ref? |
The repo's top-level contents. |
get_raw_file |
owner, repo, filepath, ref? |
A file's raw UTF-8 text (a note for binary files). |
list_repo_labels |
owner, repo |
The repo's issue labels. |
list_deploy_keys |
owner, repo |
The repo's deploy keys. |
get_deploy_key |
owner, repo, id |
A single deploy key. |
list_branch_protections |
owner, repo |
The repo's branch protection rules. |
get_branch_protection |
owner, repo, name |
A single branch protection rule. |
list_collaborators |
owner, repo |
The repo's collaborators. |
get_collaborator_permission |
owner, repo, collaborator |
A collaborator's effective permission. |
list_hooks |
owner, repo |
The repo's webhooks. |
get_hook |
owner, repo, id |
A single webhook. |
list_releases |
owner, repo, draft?, pre_release?, q? |
The repo's releases. |
get_release_by_tag |
owner, repo, tag |
A release by tag name. |
find_release |
owner, repo, name |
A release by display name. |
get_release_archive |
owner, repo, archive (e.g. v1.0.0.zip), dest_path? |
Writes the archive to a file; returns { path, bytes, filename } (not the bytes). |
download_release_attachment |
owner, repo, release_id, asset_id, dest_path? |
Writes the attachment to a file; returns { path, bytes, filename } (not the bytes). |
list_tags |
owner, repo, page? (1-based, 20/page) |
One page of tags. |
get_tag |
owner, repo, name |
A single tag. |
list_packages |
owner, type? (cargo/generic/container/npm/...), q? |
The owner's packages (one entry per version). |
get_package |
owner, type, name, version |
A package version's metadata. |
list_package_files |
owner, type, name, version |
A package version's files. |
list_orgs |
page? (1-based) |
One page of all organizations. |
list_my_orgs |
none | Organizations the authenticated user belongs to. |
get_org |
org |
An organization by name. |
list_org_activity |
org, date? (YYYY-MM-DD) |
An organization's activity feed. |
get_org_member_count |
org |
{ org, member_count }. |
get_org_team_count |
org |
{ org, team_count }. |
list_org_members |
org, page? (1-based) |
One page of an org's members. |
list_org_public_members |
org, page? (1-based) |
One page of an org's public members. |
is_org_member |
org, username |
{ org, username, is_member }. |
is_org_public_member |
org, username |
{ org, username, is_public_member }. |
list_org_repos |
org, page? (1-based) |
One page of an org's repositories. |
list_org_labels |
org |
An organization's issue labels. |
list_teams |
org |
All teams in an organization. |
list_team_repos |
id, page? (1-based, 20/page) |
One page of the repos a team can access. |
list_team_members |
id, page? (1-based, 20/page) |
One page of a team's members. |
search_users |
q?, uid?, sort? |
Matching users. |
get_user |
username |
A user by username. |
list_user_following |
username |
Everyone a user follows. |
list_my_following |
none | Everyone the authenticated user follows. |
list_user_followers |
username |
A user's followers. |
list_my_followers |
none | The authenticated user's followers. |
list_user_starred |
username, page? (1-based, 50/page) |
One page of a user's starred repos. |
list_my_starred |
page? (1-based, 50/page) |
One page of the authenticated user's starred repos. |
list_user_repos |
username, page? (1-based, 50/page) |
One page of a user's repositories. |
list_my_repos |
page? (1-based, 50/page), order_by? |
One page of the authenticated user's repositories. |
list_user_orgs |
username |
The organizations a user belongs to. |
list_my_orgs_for_user |
none | The authenticated user's organizations (same result as list_my_orgs). |
list_user_activity |
username, date? (YYYY-MM-DD), only_performed_by? |
A user's activity feed. |
list_my_ssh_keys |
none | The authenticated user's SSH keys. |
get_ssh_key |
id |
One of the authenticated user's SSH keys. |
list_my_gpg_keys |
none | The authenticated user's GPG keys. |
get_gpg_key |
id |
One of the authenticated user's GPG keys. |
get_gpg_verification_token |
none | { token } for proving GPG key ownership. |
list_action_tasks |
owner, repo, page? (1-based, 20/page) |
One page of action tasks (recent workflow runs). |
list_repo_variables |
owner, repo |
The repo's action variables. |
get_repo_variable |
owner, repo, name |
A repo action variable, including its value. |
list_org_variables |
org |
An organization's action variables. |
get_org_variable |
org, name |
An org action variable, including its value. |
list_repo_secrets |
owner, repo |
The repo's action secrets (names only; values are never exposed). |
list_wiki_pages |
owner, repo |
The repo's wiki page metadata. |
get_wiki_page |
owner, repo, page |
A single wiki page by name. |
get_wiki_repo |
owner, repo |
Repository metadata (used to derive the wiki clone URL). |
EDIT-tier tools (require --allow-edit; see Write permissions):
| Tool | Arguments | Returns |
|---|---|---|
create_issue |
owner, repo, title, body?, assignees?, labels?, milestone? |
The created issue. |
edit_issue |
owner, repo, index, title?, body?, state?, assignees?, milestone? |
The updated issue. |
create_issue_comment |
owner, repo, index, body |
The created comment. |
edit_issue_comment |
owner, repo, id, body |
The updated comment. |
set_issue_labels |
owner, repo, index, add?, rm? (label names) |
unknown_labels: the add names that don't exist. |
add_issue_attachment |
owner, repo, index, file_path? or content? (base64) + filename?, name? |
The created attachment (uploads a local file or base64 bytes). |
create_pull_request |
owner, repo, title, head, base, body?, assignees?, labels?, milestone? |
The created pull request. |
merge_pull_request |
owner, repo, index, style? (merge/rebase/rebase-merge/squash/fast-forward-only/manually-merged, default merge), message? |
{ merged: true, index }. |
edit_repo |
owner, repo, name?, description?, website?, private?, archived?, default_branch?, has_issues?, has_wiki?, has_pull_requests? |
The updated repository. |
create_org_repo |
org, name, description?, private?, auto_init?, default_branch?, readme?, license?, gitignores? |
The created repository. |
create_user_repo |
name, description?, private?, auto_init?, default_branch?, readme?, license?, gitignores? |
The created repository. |
fork_repo |
owner, repo, name?, organization? |
The created fork. |
migrate_repo |
clone_addr, repo_name, repo_owner?, description?, private?, mirror?, auth_username?, auth_password?, auth_token? |
The migrated repository. |
star_repo |
owner, repo |
{ starred: true, owner, repo }. |
unstar_repo |
owner, repo |
{ starred: false, owner, repo }. |
create_repo_label |
owner, repo, name, color, description?, exclusive? |
The created label. |
edit_repo_label |
owner, repo, id, name?, color?, description?, exclusive? |
The updated label. |
create_deploy_key |
owner, repo, title, key, read_only? |
The created deploy key. |
create_branch_protection |
owner, repo, rule_name, enable_push?, enable_status_check?, status_check_contexts?, required_approvals?, block_on_outdated_branch?, require_signed_commits? |
The created rule. |
edit_branch_protection |
owner, repo, name, enable_push?, enable_status_check?, status_check_contexts?, required_approvals?, block_on_outdated_branch?, require_signed_commits? |
The updated rule. |
add_collaborator |
owner, repo, collaborator, permission? (read/write/admin, default write) |
{ added: true, collaborator }. |
create_hook |
owner, repo, type, url, content_type? (json/form, default json), events?, active?, branch_filter? |
The created webhook. |
edit_hook |
owner, repo, id, url?, content_type?, events?, active?, branch_filter? |
The updated webhook. |
test_hook |
owner, repo, id, ref? |
{ tested: true, id }. |
create_release |
owner, repo, tag_name, target_commitish?, name?, body?, draft?, prerelease?, hide_archive_links? |
The created release. |
edit_release |
owner, repo, id, tag_name?, target_commitish?, name?, body?, draft?, prerelease?, hide_archive_links? |
The updated release. |
create_release_attachment |
owner, repo, id, file_path, name? |
The created attachment (uploads a local file). |
create_tag |
owner, repo, tag_name, target?, message? |
The created tag. |
create_org |
username, full_name?, description?, email?, location?, website?, visibility? (public/limited/private), repo_admin_change_team_access? |
The created organization. |
edit_org |
org, full_name?, description?, email?, location?, website?, visibility?, repo_admin_change_team_access? |
The updated organization. |
conceal_org_member |
org, username |
{ concealed: true, org, username }. |
create_org_label |
org, name, color, description?, exclusive? |
The created label. |
edit_org_label |
org, id, name?, color?, description?, exclusive? |
The updated label. |
create_team |
org, name, description?, permission? (read/write/admin), can_create_org_repo?, includes_all_repositories? |
The created team. |
edit_team |
id, name, description?, permission?, can_create_org_repo?, includes_all_repositories? |
The updated team. |
add_team_repo |
id, org, repo |
{ added: true, id, org, repo }. |
add_team_member |
id, username |
{ added: true, id, username }. |
follow_user |
username |
{ followed: true, username }. |
unfollow_user |
username |
{ followed: false, username }. |
block_user |
username |
{ blocked: true, username }. |
unblock_user |
username |
{ blocked: false, username }. |
update_user_settings |
full_name?, description?, website?, location?, language?, theme?, diff_view_style?, hide_email?, hide_activity?, hide_pronouns?, pronouns?, enable_repo_unit_hints? |
The updated settings. |
add_email |
emails |
The account's emails after adding. |
upload_ssh_key |
title, key, read_only? |
The created SSH key. |
upload_gpg_key |
armored_public_key, armored_signature? |
The created GPG key. |
verify_gpg_key |
key_id, armored_signature? |
The verified GPG key. |
create_repo_variable |
owner, repo, name, value |
{ created: true, name }. |
update_repo_variable |
owner, repo, name, value |
{ updated: true, name }. |
create_org_variable |
org, name, value |
{ created: true, name }. |
update_org_variable |
org, name, value |
{ updated: true, name }. |
create_repo_secret |
owner, repo, name, data (sensitive credential; stored write-only) |
{ created: true, name }. |
dispatch_workflow |
owner, repo, workflow, ref, inputs?, return_run_info? |
The dispatched run info, or null. |
DELETE-tier tools (require --allow-delete; see Write permissions):
| Tool | Arguments | Returns |
|---|---|---|
delete_repo |
owner, repo |
{ deleted: true, owner, repo }. |
delete_repo_label |
owner, repo, id |
{ deleted: true, id }. |
delete_issue_attachment |
owner, repo, index, asset_id |
{ deleted: true, index, asset_id }. |
delete_deploy_key |
owner, repo, id |
{ deleted: true, id }. |
delete_branch_protection |
owner, repo, name |
{ deleted: true, name }. |
remove_collaborator |
owner, repo, collaborator |
{ removed: true, collaborator }. |
delete_hook |
owner, repo, id |
{ deleted: true, id }. |
delete_release |
owner, repo, id |
{ deleted: true, id }. |
delete_release_by_tag |
owner, repo, tag |
{ deleted: true, tag }. |
delete_release_attachment |
owner, repo, release_id, asset_id |
{ deleted: true, release_id, asset_id }. |
delete_tag |
owner, repo, name |
{ deleted: true, name }. |
delete_package |
owner, type, name, version |
{ deleted: true, type, name, version }. |
delete_org_label |
org, id |
{ deleted: true, org, id }. |
delete_team |
id |
{ deleted: true, id }. |
remove_team_repo |
id, org, repo |
{ removed: true, id, org, repo }. |
remove_team_member |
id, username |
{ removed: true, id, username }. |
delete_email |
emails |
{ deleted: true, emails }. |
delete_ssh_key |
id |
{ deleted: true, id }. |
delete_gpg_key |
id |
{ deleted: true, id }. |
delete_repo_variable |
owner, repo, name |
{ deleted: true, name }. |
delete_org_variable |
org, name |
{ deleted: true, name }. |
delete_repo_secret |
owner, repo, name |
{ deleted: true, name }. |
Each tool returns the raw forgejo-api struct as pretty JSON text, except get_pull_request_diff, which returns the raw diff/patch string, get_raw_file, which returns the file's raw text, and get_release_archive / download_release_attachment / download_issue_attachment, which write bytes to a local file and return { path, bytes, filename }. Label arguments take label names; unknown names are dropped (or surfaced, for set_issue_labels). list_issues returns a single page; list_all_issues follows pagination.
Authentication
The server authenticates with one of two sources, in precedence order:
-
FORGEJO_TOKEN(preferred for deployments). When set and non-empty, the server uses it as the bearer token forFORGEJO_HOSTdirectly, with nofjCLI install orkeys.jsonrequired. This is the supported path for the HTTP service mode. Generate a token in the Forgejo UI under Settings -> Applications -> Generate New Token.$env.FORGEJO_TOKEN = "<your-forgejo-access-token>" -
fjCLIkeys.jsonfallback (convenient for local development). WhenFORGEJO_TOKENis unset, credentials come from thefjCLI'skeys.json(managed bydirectoriesunder theCyborus/forgejo-clidata dir). Authenticate once with the CLI:fj auth add-key --host dev.a8n.runThe server loads
keys.jsonon each call, so token refreshes performed by the CLI are picked up automatically. Hosts with no stored login fall back to an unauthenticated client.
Configuration
| Variable | Default | Purpose |
|---|---|---|
FORGEJO_HOST |
https://dev.a8n.run |
Base URL of the target Forgejo instance. |
FORGEJO_TOKEN |
(none) | Forgejo access token. When set and non-empty, authenticates directly and takes precedence over the fj CLI's keys.json; when unset, the server falls back to keys.json. |
RUST_LOG |
info |
Log filter (logs go to stderr; over stdio, stdout carries the JSON-RPC stream). |
MCP_TRANSPORT |
stdio |
Transport to start: stdio or http. Unset or unrecognized values use stdio. |
MCP_HTTP_ADDR |
127.0.0.1:8080 |
HTTP-mode bind address. Override to 0.0.0.0:<port> for a container or daemon. |
MCP_HTTP_TOKEN |
(none) | HTTP-mode bearer token. Required in http mode: the server refuses to start without it. |
MCP_ALLOW_EDIT |
(unset) | Truthy (1/true) grants the EDIT write tier; env equivalent of --allow-edit. |
MCP_ALLOW_DELETE |
(unset) | Truthy (1/true) grants the DELETE write tier; env equivalent of --allow-delete. |
MCP_ALLOW_GLOBAL_WRITE |
(unset) | Truthy (1/true) grants both tiers; env equivalent of --allow-global-write. |
Write permissions
Write access is default-deny and split into three tiers, modelled on Deno's --allow-* switches. With no flag set the server runs the read-only tools and refuses the EDIT-tier ones (create_*, edit_*, set_issue_labels, merge_pull_request) with an error naming the flag to set.
| Tier | Always on? | Enabled by | Covers |
|---|---|---|---|
| READ | yes | (no flag) | get_*, list_*, search, view, contents/raw reads, ... |
| EDIT | no | --allow-edit / MCP_ALLOW_EDIT=1 |
create_*, edit_*, update_*, merge, fork, reversible self-toggles, access grants, ... |
| DELETE | no | --allow-delete / MCP_ALLOW_DELETE=1 |
delete_*, access/security revocations, destructive removals, ... |
--allow-global-write (env MCP_ALLOW_GLOBAL_WRITE=1) is the master switch and implies both EDIT and DELETE.
A CLI flag, when present, grants its tier; when absent, the matching env var's truthiness (1 or true, case-insensitive) decides. A flag can only grant a tier, never revoke it (default-deny). The effective policy is logged at info on startup in both transports.
Gated tools stay visible in tools/list regardless of policy. Calling one whose tier is disabled returns an error naming the exact flag and env var to set, rather than silently hiding the tool.
On the HTTP transport the bearer token gates all access (see Streamable HTTP), so granting write tiers on an HTTP deployment widens the blast radius of a leaked token.
# stdio, allow creates/edits but not deletes:
./target/release/forgejo-mcp serve --allow-edit
# HTTP, grant everything via env:
$env.MCP_ALLOW_GLOBAL_WRITE = "1"
./target/release/forgejo-mcp serve
Transports
The serve subcommand selects its transport from MCP_TRANSPORT at startup.
stdio (default)
The server reads JSON-RPC from stdin and writes it to stdout; an MCP client launches one process per session and it exits when the client disconnects. This is the mode the client registration example below uses.
Streamable HTTP
MCP_TRANSPORT=http runs a long-lived, network-reachable process that serves the Streamable HTTP MCP endpoint at POST/GET/DELETE {MCP_HTTP_ADDR}/mcp. It binds MCP_HTTP_ADDR and shuts down gracefully on SIGINT/SIGTERM.
Because the server holds Forgejo credentials (FORGEJO_TOKEN, or the fj CLI's keys.json), any reachable endpoint grants authenticated Forgejo access. The HTTP listener is therefore authenticated by default and fails closed: every request must carry Authorization: Bearer <MCP_HTTP_TOKEN> or it gets 401 Unauthorized, and the server refuses to start when MCP_HTTP_TOKEN is unset or empty. TLS is not terminated in-process; put a reverse proxy in front for a public deployment. Supply the upstream credential with FORGEJO_TOKEN so the deployment needs no fj CLI install.
$env.FORGEJO_HOST = "https://dev.a8n.run"
$env.FORGEJO_TOKEN = "<your-forgejo-access-token>"
$env.MCP_TRANSPORT = "http"
$env.MCP_HTTP_ADDR = "127.0.0.1:8080"
$env.MCP_HTTP_TOKEN = "a-long-random-secret"
./target/release/forgejo-mcp serve
Build and run
cargo build --release
$env.FORGEJO_HOST = "https://dev.a8n.run"
./target/release/forgejo-mcp serve
The server reads JSON-RPC from stdin and writes to stdout, so run it directly only for a smoke test. Normally an MCP client launches it. A bare forgejo-mcp with no subcommand prints usage and exits; the stdio server lives behind serve.
CLI
forgejo-mcp --help # usage for all subcommands
forgejo-mcp --version # forgejo-mcp <semver> (<git-hash>, built <build-date>)
forgejo-mcp serve # start the MCP stdio server (read-only by default)
forgejo-mcp serve --allow-edit # also allow create/edit/merge (EDIT tier)
forgejo-mcp serve --allow-global-write # allow all writes incl. deletes
forgejo-mcp version # same banner as --version (alias: version show)
forgejo-mcp version check # ask the registry whether a newer build is published
forgejo-mcp version update # download + verify + self-replace the running binary
version update accepts --dry-run (probe + report, no download), --force (reinstall even when not newer), and --url <base> (override the baked registry URL). It verifies a published .sha256 sidecar when present (hard-fail on mismatch, skip when absent) and smoke-tests the swapped binary with --version before reporting success.
Register with an MCP client
Example claude registration (stdio transport):
claude mcp add forgejo --env FORGEJO_HOST=https://dev.a8n.run -- /absolute/path/to/forgejo-mcp/target/release/forgejo-mcp serve
For a running Streamable HTTP server, register the URL transport and pass the bearer token as a header:
claude mcp add --transport http forgejo http://127.0.0.1:8080/mcp --header "Authorization: Bearer a-long-random-secret"
Adding a tool
- Add a
#[derive(Deserialize, JsonSchema)]params struct insrc/server.rs. - Add a
#[tool]method on the#[tool_router]impl that builds the client withself.api(), calls the matchingfj_core::*wrapper, and returnsjson(&result). - If the operation does not yet exist in
fj-core, add it there first rather than callingforgejo-apidirectly, so the CLI and the MCP server stay in sync.
Development
This repo uses a justfile for the standard dev workflow. Run just (or just --list) to see every recipe.
just install-hooks # one-time per clone: install the pre-commit hook
just check # fmt + clippy + build + builder-stage docker compile
just test # cargo test
just build # release binary
just build-docker-export # extract the dynamic glibc Linux binary via the OCI build
just build-static # build the fully static musl Linux binary via Docker
just build-windows # cross-compile the Windows .exe
just create-release minor # bump version, push release branch, open the PR via fj
just pre-commit runs the same fmt + clippy + build + test steps as .forgejo/workflows/check.yml, inside the rust-builder-glibc image so the toolchain matches CI. Conventions follow the a8n-run/governance repo; see CLAUDE.md for forgejo-mcp-specific notes.
Releases
CI publishes binaries to the Forgejo Generic Packages registry on every push to main and on v* tags:
| Artifact | Target | Linking |
|---|---|---|
forgejo-mcp-linux-x86_64 |
x86_64-unknown-linux-gnu |
Dynamic glibc (needs libssl3 at runtime). |
forgejo-mcp-linux-x86_64-static |
x86_64-unknown-linux-musl |
Fully static (vendored OpenSSL/libgit2/libssh2; runs on any Linux). |
forgejo-mcp-windows-x86_64.exe |
x86_64-pc-windows-gnu |
mingw cross-compile. |
just create-release <major|minor|hotfix> opens the release PR; once merged, .forgejo/workflows/create-release.yml tags and publishes automatically.
Other implementations
Unrelated projects that also implement an MCP server for Forgejo under the same name:
License
Dual-licensed under Apache-2.0 OR MIT (LICENSE-APACHE, LICENSE-MIT), matching the forgejo-cli crates this depends on.