A Model Context Protocol server that gives AI clients tools to work with a Forgejo or Gitea instance over its REST API: repositories, issues, comments, files, pull requests, and CI status.
It speaks MCP over stdio and calls the Forgejo/Gitea REST API with fetch. The
whole server bundles to a single dist/index.js with no runtime framework.
Tools are tiered by blast radius. The default surface is reads plus writes whose damage is visible and cheap to undo. There are deliberately no merge, delete, or admin tools in it: merging and deleting live behind an opt-in elevated tier, and user, organisation, permission, secret and token administration is not exposed at all, at any tier. Repository lifecycle — creating and deleting repositories — is the exception: those exist, gated behind the elevated tier rather than permanently excluded. This keeps the server safe for unattended use and caps the blast radius of the API token. Pair it with a least-privilege token (repository R/W, issue R/W, user Read).
A small set of high-blast-radius operations — destroying work, and creating a repository whose visibility the caller chooses — is available behind an opt-in, off-by-default elevated tier. With no elevated environment variables set, the tool surface is byte-identical to the safe default described above.
| Tool | Kind | Purpose |
|---|---|---|
list_repositories |
read | List a user's repositories (default: authenticated user); paginated |
get_repository |
read | Repository metadata, including default branch |
list_issues |
read | List issues; filter by state, labels, type (default issues), text q, milestones, author, assignee, mention, and date; sortable; paginated |
get_issue |
read | A single issue with body, labels, assignees |
create_issue |
write | Open an issue (labels by id, assignees) |
list_issue_comments |
read | Comments on an issue or PR; paginated |
create_issue_comment |
write | Add a comment to an issue or PR |
get_file_content |
read | Decoded file content (default branch if no ref) |
list_directory |
read | List a directory's entries (root if no path); fails on a file path; paginated |
create_file |
write | Create a file from plain-text content (optional new branch) |
delete_file |
write | Delete a file; requires the blob sha, commits like any change |
update_file |
write | Replace a file's content (sha required — the blob SHA being replaced) |
list_pull_requests |
read | List PRs; filter by state, milestone id, label ids, and poster; sortable; paginated |
get_pull_request |
read | A single PR with merge state |
get_pull_request_diff |
read | Unified diff for a PR as plain text |
get_pull_request_files |
read | Files a PR changes, with status and line counts; paginated |
create_pull_request |
write | Open a PR from a head branch into a base branch |
get_commit_status |
read | Combined CI/commit status for a ref |
list_branches |
read | List branches with latest commit and protection status; paginated |
get_branch |
read | A single branch by name |
create_branch |
write | Create a branch from a source ref (default branch if none) |
list_commits |
read | List commits; filter by start ref and path; paginated |
get_commit |
read | A single commit by SHA or ref |
list_releases |
read | List releases; paginated |
get_release_by_tag |
read | A release by tag name rather than numeric id |
get_latest_release |
read | The current non-draft, non-prerelease release |
edit_release |
write | Edit release notes, name, tag, draft/prerelease flags |
get_release |
read | A single release by ID, with notes and draft/prerelease flags |
create_release |
write | Create a release for a tag (draft, prerelease, notes) |
list_tags |
read | List tags with their target commits; paginated |
get_tag |
read | A single tag by name |
create_tag |
write | Create a tag on a branch or commit (optionally annotated) |
list_pull_request_reviews |
read | Reviews on a PR (approvals, change requests, comments); paginated |
create_pull_request_review |
write | Submit a review (APPROVED, REQUEST_CHANGES, or COMMENT), with optional inline comments pinned to a commit_id |
request_pull_request_reviewers |
write | Request reviews from users (and org teams) on a PR |
create_commit_status |
write | Report a commit status (pending, success, error, failure, warning) |
list_commit_statuses |
read | Individual statuses on a ref; paginated |
edit_issue_comment |
write | Replace the text of a comment (by comment id) |
delete_issue_comment |
write | Delete a comment (by comment id); not recoverable here |
list_milestones |
read | Milestones in a repository (id, title, state, issue counts); paginated |
get_milestone |
read | One milestone by id, or by exact title |
create_milestone |
write | Create a milestone (title, description, due date, state) |
edit_milestone |
write | Edit a milestone; only the fields you pass change |
delete_milestone |
write | Delete a milestone; issues survive but lose it |
list_labels |
read | Labels defined in a repository (id, name, color); paginated |
get_label |
read | One label definition by id |
create_label |
write | Define a new label (name and colour required) |
edit_label |
write | Edit a label definition; renames it everywhere it is used |
add_labels |
write | Add labels (by name or id) to an issue or PR; existing labels kept |
remove_label |
write | Remove one label (by name or id) from an issue or PR |
add_assignees |
write | Assign users to an issue or PR; existing assignees kept |
set_issue_state |
write | Close or reopen an issue or PR (state only — edit_issue owns title/body) |
edit_issue |
write | Edit the title or body of an issue or PR; only the fields you pass change |
edit_pull_request |
write | Edit a PR's title, body, state, assignees, labels, milestone, due date or maintainer-edit flag; the base branch is not editable here |
Every list_* tool except list_directory returns
{ "total_count": 51, "count": 30, "page": 1, "items": [ … ] }rather than a bare array. Forgejo serves list endpoints a page at a time and
caps the page size regardless of the limit asked for, so a bare array cannot
be told apart from the first slice of a much longer list. When count is short
of total_count, ask for the next page. total_count is omitted when the
server does not report one.
list_directory is excluded because its endpoint answers with a single object,
not a list, when the path names a file.
Supply the target and token at runtime via environment variables:
FORGEJO_BASE_URL— e.g.https://git.example.comFORGEJO_TOKEN— a Forgejo/Gitea API token
Never hardcode the token; inject it from a secret store at launch.
Two setups, both good — pick by whether you value portability or keeping the token out of Claude's config file.
Once the package is on npm, no local checkout or build is needed:
claude mcp add forgejo \
--env FORGEJO_BASE_URL=https://git.example.com \
--env FORGEJO_TOKEN=... \
-- npx -y @rubicontv/forgejo-mcpPortable and self-updating. Note this stores the token in Claude Code's config
(~/.claude.json). If you would rather the token never touch that file, use
Option B.
A small launcher sources a chmod 600 env file at startup, so the token lives
only in that file (ideally hydrated from a secret manager) and never in
~/.claude.json:
#!/bin/sh
# ~/.config/forgejo-mcp/launch.sh
set -eu
. "$HOME/.config/forgejo-mcp/forgejo-mcp.env" # sets FORGEJO_BASE_URL, FORGEJO_TOKEN
export FORGEJO_BASE_URL FORGEJO_TOKEN
exec npx -y @rubicontv/forgejo-mcp # or: exec node /path/to/forgejo-mcp/dist/index.jsPoint Claude Code at the wrapper — no env block, so no secret in the config:
{
"mcpServers": {
"forgejo": {
"command": "/Users/you/.config/forgejo-mcp/launch.sh"
}
}
}If you are working from a checkout instead of the published package:
{
"mcpServers": {
"forgejo": {
"command": "node",
"args": ["/path/to/forgejo-mcp/dist/index.js"],
"env": {
"FORGEJO_BASE_URL": "https://git.example.com",
"FORGEJO_TOKEN": "..."
}
}
}
}To let an agent use the server without a prompt on every call, allowlist it. On the safe default surface (no elevated tier), blanket-allowlisting the whole server is fine:
mcp__forgejo
If you turn on the elevated tier, do not do this — a blanket allowlist
would let every elevated tool run without a prompt: merge_pull_request,
delete_branch, delete_repo, delete_label, delete_release and
delete_tag. Allowlist named default tools
only, never an elevated one, and never delete_repo under any circumstances —
its confirm argument catches a malformed call, not a misled one. For example:
mcp__forgejo__create_issue
mcp__forgejo__create_pull_request
See the allowlist warning in the Elevated tier section below.
The server can optionally expose a minimal set of high-blast-radius tools: operations that destroy work in ways this server cannot undo. This tier is off by default and must be deliberately enabled by the operator.
| Tool | Kind | Operation |
|---|---|---|
merge_pull_request |
elevated | Merge a PR (merge / rebase / squash) into its base branch, pinned to a required head_commit_id; pass delete_branch_after_merge to clean up the head branch |
delete_branch |
elevated | Permanently delete a branch |
delete_repo |
elevated | Permanently delete a repository; confirm must equal owner/repo |
delete_label |
elevated | Permanently delete a label definition, stripping it from every issue and pull request that carried it |
delete_release |
elevated | Permanently delete a release; its notes and uploaded assets do not exist in git |
delete_tag |
elevated | Permanently delete a tag; if its commits are unreachable from any branch, deleting it orphans them |
Every elevated tool is prefixed [ELEVATED — DESTRUCTIVE] and carries
destructiveHint: true. The tier holds nothing else: a tool that does not destroy
something this server cannot restore belongs in the default surface.
delete_repo requires confirm to equal owner/repo exactly. Be clear about
what that buys: it catches a malformed or half-specified call, and nothing more.
Both confirm and the target come from the same tool-call arguments, so text
injected into an issue can satisfy it as easily as a careful caller. Deleting a
repository is the only operation here with no undo of any kind, so never
allowlist delete_repo — the per-call approval prompt is the actual guard.
merge_pull_request requires head_commit_id — the head SHA from
get_pull_request. Forgejo refuses the merge if the branch has moved since that
SHA, so commits pushed after the review cannot be swept in. Be precise about what
that is: a freshness guard, not proof of provenance. Nothing verifies the SHA
came from reading the pull request, so it constrains what gets merged, not
whether anyone looked. The per-call approval prompt is what covers the latter.
This server hands tools to an LLM that reads untrusted content — issue bodies, PR text, file contents. That is a prompt-injection surface: a misled agent should never be able to merge or delete anything. So the elevated tier is double-gated and uses a separate token.
Elevated tools are registered only when all three of these hold:
FORGEJO_MCP_ELEVATED=1— an explicit opt-in flag, andFORGEJO_MCP_ELEVATED_TOKEN— a token distinct fromFORGEJO_TOKEN. This is checked, not merely asked for: if the two are identical the tier stays off and the server logs why. A second token that equals the first is not a second token.FORGEJO_TOKENmust itself be set. Elevation is additive on top of the default surface, so without it the tier stays off rather than leaving the destructive tools as the only ones that work.
The default FORGEJO_TOKEN never performs an elevated operation — elevated
calls use FORGEJO_MCP_ELEVATED_TOKEN exclusively. Scope that token to only the
repositories and permissions the destructive tools actually need.
If FORGEJO_MCP_ELEVATED=1 is set but FORGEJO_MCP_ELEVATED_TOKEN is missing,
the server fails closed: the elevated tools are not registered and a warning
is logged to stderr.
{
"mcpServers": {
"forgejo": {
"command": "node",
"args": ["/path/to/forgejo-mcp/dist/index.js"],
"env": {
"FORGEJO_BASE_URL": "https://git.example.com",
"FORGEJO_TOKEN": "…read/write token…",
"FORGEJO_MCP_ELEVATED": "1",
"FORGEJO_MCP_ELEVATED_TOKEN": "…distinct, narrowly-scoped token…"
}
}
}
}This MCP is commonly whole-server allowlisted (e.g. mcp__forgejo) so an
agent can use it autonomously without per-call prompts. If you enable the
elevated tier under a blanket allowlist, the destructive tools also run without
any prompt. A single prompt-injection payload in an issue or PR could then
merge or delete on your behalf.
Therefore, when the elevated tier is on:
- Do NOT blanket-allow the whole server (
mcp__forgejo). - Allowlist only the specific safe tools you want to run autonomously
(e.g.
mcp__forgejo__create_issue), and leave every elevated tool —merge_pull_request,delete_branch,delete_repo,delete_label,delete_releaseanddelete_tag— prompting on every call.delete_reposhould never be allowlisted at all: itsconfirmargument catches a malformed call, not a misled one.
The server cannot enforce the client's allowlist — distinct tool naming and this warning are the mitigation. Enabling elevated tools is a decision the operator owns.
The elevated tier is intentionally tiny. The following are never exposed, regardless of any flag: user management, secret/variable writes, permission or collaborator changes, and org/repo admin. Those are not appropriate to hand to an LLM that reads untrusted content, so they stay out by design. Adding any destructive tool requires explicit owner sign-off and a matching token-scope review.
npm install
npm run build # esbuild -> dist/index.js
npm run smoke # builds, then runs a token-free MCP handshake + tools/list
npm run typecheck # tsc --noEmitSee CONTRIBUTING.md to get started, the Code of Conduct for community expectations, and SECURITY.md to report a vulnerability privately.
Licensed under the Apache License 2.0. This is an independent,
clean-room implementation; see NOTICE. It was inspired by
nsvk13/forgejo-mcp-server,
with thanks, but shares no source code with it.