Skip to main content
Glama

alasin-mcp

A remote, multi-tenant MCP server that gives AI agents branch-and-commit access to a Forgejo or Gitea instance.

An agent can read repositories, create a work branch and commit several files to it in one commit. It can't merge, delete, change settings or touch secrets: a person reviews and merges the branch. Alasin is Finnish for "anvil".

  • Transport: Streamable HTTP (stateless) at /mcp. Designed to sit behind an OAuth gateway that forwards a bearer token, or to verify JWTs from your identity provider.

  • Multi-tenant: one deployment serves many tenants, each with its own Forgejo. The tenant comes only from the verified token.

  • Audited and traced: every tool call is an OpenTelemetry span and an append-only audit event; commits carry a Trace-Id trailer.

Licence: Apache-2.0.

Tools

Tool

Kind

What it does

begin_change

start

Opens a work session. Returns session_id (required by every other tool) and the trace_id stamped into commits.

list_repos

read

Repositories the tenant may reach (the tenant's allowlist ∩ what the Forgejo account can see).

list_branches

read

Branches, with whether each is protected.

list_folder

read

A folder's entries at a ref (default branch by default).

read_file

read

A file at a ref; text as UTF-8, anything else as base64; size-capped.

diff_branch

read

A branch's commits and a unified diff per file against the default branch (or base), from the merge base.

create_branch

write

A new branch from the default branch (or from_ref).

commit_files

write

One commit with several files (POST /repos/{owner}/{repo}/contents), each created or replaced in full.

Not provided, by design: merging, deleting branches, files or repositories, repository settings, administration, webhooks and Actions secrets. File deletion and renames are not exposed either (the contents API's delete operation and from_path are never sent).

Commit messages get trailers added by the server:

Update the headline

Co-Authored-By: Claude <noreply@anthropic.com>
Trace-Id: 0af7651916cd43dd8448eb211c80319c
Alasin-Session: ws_7ZBUuDKkAf-wwjrEiPQ15Uz5

The model can't set Trace-Id or Alasin-Session itself; a message containing them is refused.

Related MCP server: forgejo-mcp

Security model

Identity. Every request to /mcp needs Authorization: Bearer <token>. A TokenVerifier turns the token into a Principal(tenant, subject, name, email). The principal is attached to that HTTP request only; tools read it from the request that carried their call, so concurrent requests can't see each other's identity. No tool has a tenant, host or URL argument, and headers can't change the principal.

Tenancy. A TenantResolver maps the principal to the tenant's settings: Forgejo base URL, auth mode, credentials and policy. The Forgejo URL comes only from there. A repository argument is validated as owner/name (no URLs, no ..) and checked against the tenant's allowlist, so naming another tenant's repository or host reaches nothing outside the caller's own Forgejo.

Isolation.

  • Work sessions, stored tokens and cache entries are keyed by (tenant, subject). A session ID presented by any other principal is "unknown".

  • Stored tokens (user-delegated mode) are encrypted with AES-256-GCM under a per-tenant data key. Each data key is wrapped by a master key (envelope encryption). The associated data binds every ciphertext to its tenant and subject, so a row copied to another tenant or subject fails to decrypt. Swap LocalKeyWrapper for a KMS client by implementing KeyWrapper.

  • Rate limits are per tenant. Audit files are written per tenant.

  • tests/test_cross_tenant.py fails if a valid token for tenant A can reach tenant B's Forgejo, cache, session or stored token, including through B's repository or host named in an argument, and under concurrent interleaved calls.

Writes. The server refuses writes to the default branch, to branches matching protected_branches, to branches outside branch_prefix (when set), and to existing branches the account can't push to. This is defence in depth: Forgejo's branch protection and protected file patterns are the real gate, and they apply to the contents API too (a commit touching a protected path is rejected with 403).

CI workflow folders. Files under .forgejo/, .gitea/ and .github/ (any case) are refused by default, because a forge with Actions enabled runs them, with the repository's secrets. A tenant can opt in with allow_ci_paths = true, or change the list with ci_paths. Protect these paths in Forgejo as well.

Request size. Bodies over server.max_request_bytes (default 8 MiB) are refused with 413 before authentication. Only HTTP is served; WebSocket connections are closed.

Outbound requests. Every value placed in a Forgejo URL is validated and percent-encoded per segment. The HTTP client never follows redirects, so a token can't be carried to another host.

Audit. One outcome event per tool call, success, failure or cancellation: time, tenant, subject, the caller's name and email when the token carries them, client address, tool, session ID, trace and span IDs, arguments, outcome, result summary, repository, branch, commit, author, committer and auth mode. File contents are recorded as size and SHA-256, not copied. Authorization links and OAuth state never reach audit events or span status.

Writes fail closed. Before any mutating Forgejo call (creating a branch, committing) an attempt event is written; if it can't be recorded, the write isn't made. The outcome event is written afterwards whatever happens. If only the outcome can't be recorded, the call still returns its result, because the write has landed and an error would invite a retry that duplicates it; the attempt event is the record. With several sinks, every sink is tried, and a failure in any of them counts as a failure.

Auth modes

Chosen per tenant with auth_mode.

Mode

Forgejo credential

Commit author

Committer

Status

service_account

one token per tenant

the service account, or a trusted override (below)

the service account

implemented

user_delegated

Forgejo OAuth2 per person (authorization code + PKCE), stored encrypted, refreshed

the person

the person

implemented, unit-tested; not yet run against a live Forgejo OAuth app

platform_scoped

short-lived per-repository token from a TokenMinter you provide

the end user from the verified token

the platform bot

interface only; no minter ships

Service-account author override. By default the service account authors commits. author_source lets the deployer choose a trusted source for the author; the committer stays the service account and the audit records both:

  • token_claims: name and email from the verified inbound token (JWT verifier). The token must carry email_verified: true.

  • trusted_header: a Name <email> header named by author_header. It is honoured only from callers that authenticated with an [[auth.static]] credential marked gateway = true; any other caller sending it is refused, and the config won't load without such a credential. Your gateway must set this header itself and strip any copy sent by clients.

  • tool_argument: author_name and author_email tool arguments. The model controls these; use only when that is acceptable. Without this opt-in the arguments are refused.

User-delegated mode. When a person has no token yet, tools return a Forgejo authorization link. Forgejo redirects to /oauth/forgejo/callback; the server exchanges the code, then checks that the Forgejo account's match_field (default email) equals the caller's match_claim. When matching on email, the caller's token must carry email_verified: true and the address must be a verified address of the Forgejo account (GET /user/emails). That check stops a link opened by someone else from binding their Forgejo account to your subject. Refreshes are serialised per person; a refresh that Forgejo rejects with invalid_grant drops the stored token, while any other failure (an outage, for example) keeps it. Register an OAuth2 application in Forgejo with the callback URL as its redirect URI. Requires server.master_key_*.

Platform-scoped mode. Implement alasin_mcp.credentials.TokenMinter:

class TokenMinter(Protocol):
    async def mint(
        self, tenant: TenantConfig, principal: Principal, repo: RepoRef
    ) -> MintedToken: ...

and name a factory package.module:factory (called with the TenantConfig) in the tenant's platform.minter. Without one, calls fail with a clear "no token minter is configured" error. Tokens are per repository, so list_repos isn't available in this mode. The author's email must be verified (email_verified: true).

Branch creation

Chosen per tenant with branch_creation:

  • api (default): the server's Forgejo account creates the branch.

  • webhook: for setups where another service must create branches. The server sends POST <branch_webhook.url> with the JSON body {"tenant", "repo", "branch", "from_ref", "session_id", "subject"} and these headers:

    • X-Alasin-Timestamp: Unix seconds.

    • X-Alasin-Signature: sha256= followed by the hex HMAC-SHA256 of "<timestamp>." + body, keyed with the tenant's webhook secret.

    The secret is re-read on every call. After a 2xx reply the server polls Forgejo until the branch exists, up to timeout_seconds. The receiver should verify the signature and reject stale timestamps.

To add another strategy, implement alasin_mcp.branching.BranchCreator and register it in Services.branch_creators under a name tenants can select.

Tenant resolver

class TenantResolver(Protocol):
    def resolve(self, principal: Principal) -> TenantConfig:
        """Return the tenant's settings, or raise UnknownTenant."""

The default, StaticTenantResolver, serves the [[tenants]] from the config file. To look tenants up elsewhere (a database, a control plane), implement the protocol and pass it to alasin_mcp.app.from_config(cfg, resolver=...). The principal's tenant comes from the verifier; the resolver decides what that tenant may reach.

Inbound verifiers implement:

class TokenVerifier(Protocol):
    async def verify(self, token: str) -> Principal | None: ...

Two ship: StaticTokenVerifier (fixed credentials, each bound to a tenant and subject, compared by SHA-256 in constant time) and JwtVerifier (JWKS, issuer, audience, expiry; the tenant from a configured claim).

Sessions and tracing

begin_change mints a work-session ID; every other tool requires it. Sessions expire after server.session_ttl_seconds (default 12 h).

begin_change always starts a new trace, which becomes the work session's trace. If the client sent _meta.traceparent (MCP SEP-414), its trace is recorded as a span link, not adopted, so a client can't choose the Trace-Id stamped into commits. Later tool spans are parented into the session's trace, so one work session is one trace. Incoming traces on later calls are linked too. The session's trace ID goes into the Trace-Id trailer and every audit event.

Spans are exported over OTLP/HTTP when telemetry.otlp_endpoint is set; export is off by default. Trace IDs are generated either way.

Configuration

ALASIN_CONFIG names a TOML file (default /etc/alasin/config.toml). Secrets are never written in it: every secret is referenced as <name>_file (a path) or <name>_env (an environment variable name). See examples/config.toml.

Key

Default

Meaning

server.host, server.port

0.0.0.0, 8000

Bind address.

server.allowed_hosts

[]

Host values accepted on /mcp (DNS-rebinding protection). Use the name your gateway uses to reach this server. Empty disables the check.

server.forwarded_allow_ips

127.0.0.1

Proxies whose X-Forwarded-* headers are trusted.

server.state_path

/var/lib/alasin/state.db

SQLite: sessions, encrypted tokens, OAuth state.

server.audit_dir

/var/lib/alasin/audit

<tenant>/audit-<date>.jsonl. Events also go to stderr.

server.master_key_file / _env

none

Base64 of 32 random bytes (openssl rand -base64 32). Required for user_delegated.

server.session_ttl_seconds

43200

Work-session lifetime.

telemetry.otlp_endpoint

"" (off)

OTLP/HTTP traces endpoint.

server.max_request_bytes

8 MiB

Largest request body, checked before authentication.

[[auth.static]]

token_file/token_env, tenant, subject, gateway (may send the trusted author header). Re-read every 30 s, so rotation needs no restart; list two entries to overlap old and new. The Forgejo service token is re-read on every call.

[auth.jwt]

jwks_url, issuer, audience, algorithms, tenant_claim, subject_claim, name_claim, email_claim, default_tenant.

[auth.oauth]

The OAuth front door; see OAuth sign-in. provider (origo), tenant (a service_account tenant), base_url (default server.public_url; its host must be in server.allowed_hosts), public_registration (false; see the warning under OAuth sign-in), auto_approve (false), token_ttl_seconds (3600), refresh_token_ttl_seconds (30 days), client_ttl_seconds, storage_path (/var/lib/alasin/oauth.db; "" = memory).

[[auth.oauth.clients]]

id, secret_file/secret_env, redirect_uris (exact match). Read at start-up: a new secret needs a restart.

[[tenants]] id

Lowercase tenant ID.

forgejo_url

Base URL of the tenant's Forgejo.

auth_mode

service_account

service_account, user_delegated or platform_scoped.

service_token_file / _env

The service account's Forgejo token (scopes write:repository, read:user).

repos

[]

owner/name glob allowlist. Empty means none.

protected_branches

main, master, release/*, releases/*

Never written by the server.

branch_prefix

""

If set, work branches must start with it.

author_source, author_header

service_account

See Auth modes.

agent_coauthor

Claude <noreply@anthropic.com>

The Co-Authored-By trailer; empty to omit.

rate_limit_per_minute

120

Per tenant.

max_file_bytes, max_commit_files, max_commit_bytes

1 MB, 50, 5 MB

Read and commit caps.

[tenants.oauth]

client_id, client_secret_file/_env, redirect_uri, match_claim, match_field.

[tenants.platform]

minter, bot_name, bot_email.

allow_ci_paths, ci_paths

false, .forgejo/, .gitea/, .github/

CI workflow folders; see Security model.

branch_creation

api

api, webhook, or a registered name; see Branch creation.

[tenants.branch_webhook]

url, secret_file/secret_env, timeout_seconds (60), poll_interval_seconds (2).

OAuth sign-in

With [auth.oauth], MCP clients that speak OAuth, such as claude.ai connectors, sign in to this server directly; no gateway is needed. provider = "origo" runs origo, an OAuth 2.1 + PKCE authorization server, in this process. Install it with the extra: pip install 'alasin-mcp[origo]' (the image includes it).

  • A request to /mcp without a valid token gets a 401 whose WWW-Authenticate names the protected-resource metadata, so the client can discover where to sign in.

  • The discovery and sign-in routes (/.well-known/..., /authorize, /token, /register, /userinfo) are public; each checks its own credentials.

  • Every token from the front door belongs to the one tenant configured. Its subject is oauth:<client id>: the OAuth client, not a person. origo has no user accounts, so everyone who signs in through one client shares its work sessions. For that reason the tenant must use auth_mode = "service_account"; a user_delegated or platform_scoped tenant is refused, since it would share per-person Forgejo tokens.

  • The oauth: subject prefix is reserved: an [[auth.static]] subject using it is refused, and a JWT whose subject starts with it is rejected.

  • The base_url host must be listed in server.allowed_hosts, so the DNS-rebinding check covers the host clients sign in at.

  • [[auth.static]] and [auth.jwt] keep working beside it on the same /mcp, so a machine caller (a tool registry listing tools, say) can keep a static bearer.

Warning: public_registration lets anyone on the internet connect. With it on, any client that can reach this server registers itself at /register and gets a token for the tenant: origo checks no user. It is refused unless the config also sets public_registration_i_understand_anyone_can_connect = true. Leave it off and list each client under [[auth.oauth.clients]], unless something in front of this server (a network policy, an access proxy) already limits who can reach it.

Another OAuth or OIDC issuer can be added as a further front door (oauth.py): its verifier is a JwtVerifier against the issuer's JWKS.

Running

docker build -t alasin-mcp .
docker run -p 8000:8000 \
  -v ./config.toml:/etc/alasin/config.toml:ro \
  -v ./secrets:/run/secrets:ro \
  -v alasin-state:/var/lib/alasin \
  alasin-mcp

Behind an OAuth gateway: the gateway authenticates the user, then forwards /mcp to this server with Authorization: Bearer <the static credential>. /healthz is public, as is /oauth/forgejo/callback (used only by user-delegated tenants). Point allowed_hosts at the host name the gateway uses for this upstream.

The Forgejo service account needs write access to the lane repositories and nothing more: not an admin, not an organisation owner. Put branch protection on the default branch, and protected file patterns on work branches for anything the agent must not change.

Development

uv sync
uv run ruff check . && uv run ruff format --check . && uv run pyright
uv run pytest tests/test_units.py tests/test_cross_tenant.py   # no Docker needed
uv run pytest tests/test_integration.py                          # starts Forgejo in Docker

The integration tests start a pinned Forgejo image (tests/forgejo.py) with a 512 MB memory cap and tear it down afterwards. Every test has a timeout. CI sets ALASIN_REQUIRE_FORGEJO=1, which turns a missing Docker into a failure instead of a skip.

Source files carry an SPDX-License-Identifier: Apache-2.0 header (scripts/check_spdx.py checks it).

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    C
    maintenance
    An MCP server that enables AI assistants to read, create, and manage issues, repositories, and releases on Gitea, Forgejo, or Codeberg instances via personal access tokens.
    21
    119 PyPI
    2
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    A secure and scalable Git MCP server giving AI agents powerful version control for local and (soon) serverless environments.
    202 npm
    Apache 2.0
  • A
    license
    C
    quality
    D
    maintenance
    A policy-aware MCP server for GitHub and GitHub Actions that enables safe AI-assisted infrastructure workflows—inspecting repositories, preparing branches and pull requests, and constrained remote mutations behind explicit preview-bound approval tokens.
    18
    MIT