Skip to content

Franz Franz

goFranz // personal journal all writing
Tools · forseti

claude.ai on Forgejo, signed in through Forseti

Connect claude.ai to a self-hosted Forgejo with forgejo-mcp and Forseti SSO, without a personal access token.

I wanted claude.ai to read my repositories on a self-hosted Forgejo, without pasting a personal access token into a connector. Turns out Forgejo 16 added the missing piece; Forseti needed one more.

Versions

  • Forseti 0.2.24 or newer: access-token claims on resource registry rows
  • Forgejo 17.0.1 (tested); Authorized Integrations exist since 16.0
  • forgejo-mcp v3.3.0, resource-server mode
  • Ory Hydra v26.2.0

How it fits

  1. claude.ai logs in at Forseti and gets a JWT access token
  2. The token carries forgejo_aud: the audience of your Forgejo Authorized Integration
  3. forgejo-mcp checks the token, signs a 5-minute JWT for you, calls Forgejo with it
  4. Forgejo matches it to your integration and applies its permissions

No forge token anywhere. forgejo-mcp’s one secret is its signing key.

Server (once)

1. forgejo-mcp on its own host, e.g. mcp.git.example.com:

forgejo-mcp:
  image: git.b4mad.industries/agentic-forges/forgejo-mcp:v3.3.0
  command: ["--transport", "http", "--http-port", "8080"]
  environment:
    - FORGEJO_URL=https://git.example.com
    - FORGEJO_MCP_HOST=0.0.0.0
    - FORGEJO_MCP_ALLOWED_HOSTS=mcp.git.example.com
    - FORGEJO_MCP_AUTH_MODE=resource-server
    - FORGEJO_MCP_AUTHORIZATION_SERVER=https://accounts.example.com/hydra
    - FORGEJO_MCP_RESOURCE=https://mcp.git.example.com/mcp
    - FORGEJO_MCP_SCOPES_SUPPORTED=openid offline_access
    - FORGEJO_MCP_FORGEJO_JWT_ISSUER=https://mcp.git.example.com/issuer
    - FORGEJO_MCP_FORGEJO_JWT_SIGNING_KEY_FILE=/run/secrets/signing-key.pem
  volumes:
    - ./signing-key.pem:/run/secrets/signing-key.pem:ro   # EC P-256, owned by 65532
  • offline_access, or the 5-minute access token means a new login every 5 minutes
  • Proxy only /mcp, /.well-known/oauth-protected-resource[/mcp], /issuer/.well-known/openid-configuration and /issuer/jwks.json; 404 the rest, never redirect
  • The name has to resolve to a public IP, from Forgejo too (it refuses issuers on private addresses)

2. Hydra - lift the claim to the top level of the JWT (otherwise it only sits under ext):

oauth2:
  allowed_top_level_claims:
    - forgejo_aud

3. Forseti - /admin/resources/new:

  • Resource: https://mcp.git.example.com/mcp
  • Access-token claims: forgejo_aud
  • Only tokens for this resource get the claim; Stackpit, Sortello etc. don’t

Per user

4. Forgejo - Settings → Authorized Integrations → Generic JWT:

  • Issuer: https://mcp.git.example.com/issuer
  • Claim rules: {"rules": [{"claim": "sub", "compare": "eq", "value": "<Kratos identity id>"}]}
  • Permissions: as narrow as you can live with (read:repository, read:user, …)
  • Copy the audience it shows: u:1:<uuid>

5. Forseti admin - store it on the identity (metadata_admin is admin-only):

curl -X PATCH http://kratos:4434/admin/identities/<id> \
  -H 'Content-Type: application/json' \
  -d '[{"op":"add","path":"/metadata_admin","value":{"forgejo_aud":"u:1:<uuid>"}}]'

This replaces metadata_admin, so carry over existing keys.

6. claude.ai - Settings → Connectors → Add custom connector:

  • URL: https://mcp.git.example.com/mcp
  • Leave the OAuth fields empty (claude.ai registers itself via CIMD)
  • Connect, log in, approve; ask “Who am I on git.example.com?”

Gotchas

  • The sub rule isn’t optional. Forgejo finds the integration by issuer and audience alone; without the rule, anyone carrying your audience acts as you.
  • The claim is fixed at consent. Changed the audience? Disconnect and reconnect - a refresh keeps the old one.
  • The integration’s permissions are the only limit. forgejo-mcp exposes every tool to whoever logs in.
  • The signing key acts for every user whose integration trusts the issuer. Treat it like an admin token.
  • Org-scoped Forseti admins can’t set claims (by design); only instance admins.
  • No admin UI for metadata_admin yet, hence the curl. If this gets used, a field on the identity page comes next.