{"slug": "kerberized-mcp-server-with-delegation", "title": "Kerberized MCP Server with Delegation", "summary": "A reference implementation of a Kerberized MCP server for FreeIPA remote-SSH development environments has been published, using SPNEGO authentication against a keytab and deny-by-default authorization by FreeIPA directory group so that developers authenticate with the Kerberos ticket they already hold at login and no passwords, API keys or per-developer secrets are stored. The project, described as under heavy development with names, installer flags and file layouts still changing between commits, ships a server on the official Python SDK behind nginx, an installer that creates the service account, venv, keytab, systemd unit, nginx vhost and certificate, and a client kit including a stdio bridge, the mcp-krb launcher, krb-fetch and krb-git. The server logs every tool call as an audit line and optionally supports constrained delegation so a tool can act downstream as the caller, with a known rough edge that the Firefox installed by setup.ps1 inside WSL for Windows workstations is not yet stable.", "body_md": "# Reference Implementation of a Kerberised MCP Server for Multi-OS FreeIPA Remote-SSH (VS Code) Development Environments\n\nKerberos single sign-on for MCP against FreeIPA: authenticate with the ticket a developer already holds from logging in, authorize by directory group, and keep no passwords, API keys or per-developer secrets anywhere.\n\n**Status: under heavy development.** Names, installer flags and file layouts\nstill change between commits, so read the log before upgrading a deployment.\nKnown rough edge: the Firefox that `setup.ps1` installs inside WSL for Windows\nworkstations is not stable yet. The bridge, `wslssh`, `wslgit`, `krb-fetch` and\n`krb-git` do not depend on it.\n\n*The server's optional policy editor. Each tool maps to the FreeIPA groups that may\ncall it and, if it may act downstream as the caller, to the one service it may reach.*\n\nThis is a system you deploy, in two halves, with one hook for your own code. It is not a library to import, and it is not an MCP client. You run the server on one host in your FreeIPA realm, hand the client kit to every workstation, and put your tools in one site file that this repository never carries.\n\n| Part | What you get | Where it runs | In the tree | \n|---|---|---|---|\n| **The server** | a Kerberised MCP server on the official Python SDK, behind nginx. Every request is authenticated offline by SPNEGO against a keytab; every tool call is authorized deny-by-default by FreeIPA group; every decision is an audit line. Optional: a tool may act downstream as the caller (constrained delegation), and a browser editor for the policy (pictured) | one Linux host in the realm | `server/` | \n| **Its installer** | one script that creates the service account, venv, keytab, systemd unit, nginx vhost and certificate, then publishes the client kit; a read-only verifier that checks the result | the same host | `server/install/` | \n| **The client kit** | a stdio bridge that mints a fresh Negotiate token per request, the `mcp-krb` launcher an MCP client is pointed at,`krb-fetch` for byte-exact files and`krb-git` for git over Kerberos, and the reverse bridge: a shared dev host that holds no ticket uses your workstation's through a socket forwarded by`ssh -R` | every workstation and shared dev host | `client/bridge/` | \n| **Workstation provisioning** | `setup.sh` (Linux: enrol, then install),`setup.ps1` (Windows without domain join: WSL2, Kerberos ssh, VS Code Remote-SSH, a browser that can SSO, the bridge),`setup-macos.sh` , uninstallers driven by install manifests, and a provisioning web page the installer serves | workstations | `client/` ,`client/web/` | \n\nYour own tools go in one Python file, loaded through `MCP_SITE_TOOLS`, each\ndeclaring the groups that may call it. The page's site-specific sections go in\none HTML fragment, through `CLIENT_SITE_SECTIONS`. Neither lives in this\nrepository, so a deployment never maintains a fork of a file it did not write.\nBehind all of it sits [SECURITY.md](https://github.com/overpassconnect/mcp-krb-server/blob/main/SECURITY.md), the review with the threat\nmodel, ranked findings and deployment checklist, and a hermetic test suite that\nruns on Windows and Linux with no KDC.\n\nWhat that gives a team, concretely:\n\n- A developer types one password, at login. From then on an MCP client such as Claude Code reaches the internal server with nothing to configure and no secret stored anywhere. Disabling the account in FreeIPA ends the access.\n- The server sees the real principal on every call, decides by directory group, and logs who did what under their own name.\n- A tool can call CI, a forge or any other Kerberised service as the person who asked, so the downstream system attributes the action to them and bounds it by their permissions there.\n- On a shared dev host with no ticket, the same things work: reaching the MCP server, fetching a file, and git clone, pull and push, all through a socket the workstation forwards. No credential is copied to the host.\n- A Windows workstation that is not domain-joined gets passwordless ssh, VS Code Remote-SSH and single sign-on in a browser, through WSL2.\n\nWhat it assumes: FreeIPA (or another MIT Kerberos realm with an LDAP directory you can adapt the group lookup to), Linux hosts for the server and the shared machines, and an MCP client that speaks stdio. The tools it ships are stubs; the scaffolding around them is the product.\n\nMCP has no enterprise single-sign-on story. Claude Code's MCP client can attach an OAuth flow or a fixed header to its requests, but it cannot speak HTTP Negotiate/SPNEGO, the standard way a browser or a command-line tool proves a Kerberos identity to a web service. So it cannot authenticate directly to a Kerberized internal MCP server. This repository is a worked, reviewed answer for FreeIPA shops: it closes that gap using the Kerberos ticket a developer already holds from logging in to a FreeIPA-enrolled machine. No passwords, no API keys, no per-dev secrets.\n\nIt is a reference implementation. The MCP tools it ships (`whoami`,\n`list_projects`, `restart_service`, `trigger_build`) are stubs; the value is the\nauthentication, authorization, delegation and deployment scaffolding around them,\nmeant to be read, reviewed and adapted to your own tools. Your own tools go in a\nseparate file that this repository never carries, loaded through `MCP_SITE_TOOLS`\n(see [Configuration](#configuration)), so a deployment does not end up\nmaintaining a fork of a file it did not write.\n\nA tool can also act on behalf of the caller. One of those stubs, `trigger_build`,\nshows the delegation path: a tool calls a downstream Kerberized service as the human\nwho invoked it, so the downstream logs the real person rather than a shared service\naccount. It is off by default, reaches any Kerberized service (CI is only the\nexample), and has the one genuinely subtle security story here. Its own section,\n[On-behalf-of delegation](#on-behalf-of-delegation), covers it.\n\nOne idea holds this together: **a user credential exists in exactly one place,\nthe workstation.** Every other machine either has its own machine identity, or\nhas nothing at all. Nothing is copied, forwarded or minted to make a second you.\n\n```\n                     ┌────────────────────────────────────────┐\n                     │        FreeIPA:  KDC · DNS · CA        │\n                     │                                        │\n                     │   issues  TGTs to people               │\n                     │           keytabs to machines          │\n                     │           the CA that signs every host │\n                     └────────────────────────────────────────┘\n                        ▲                ▲                ▲\n               ① kinit  │      ② keytab  │       ③ ca.crt │ plain HTTP,\n                  (TGT) │      at enrol  │        to a new │ hash checked\n                        │                │         machine │ out of band\n  ╔═════════════════════╪════════════════╪════════════════╪═════════════════╗\n  ║ WORKSTATION         ┘                │                │                 ║\n  ║ the ONLY place a user ticket lives   │                │                 ║\n  ║ (on Windows: inside WSL)                                                ║\n  ║                                                                         ║\n  ║    MCP client ───stdio──▶┌────────┐                                     ║\n  ║         │                │ bridge │───── ④ SPNEGO ──────────────────┐   ║\n  ║         │ runs           └───┬────┘                                 │   ║\n  ║         ▼                    │ ⑦a --listen                          │   ║\n  ║    krb-fetch ──┐             ▼                                      │   ║\n  ║    krb-git ────┤      ~/.mcp-krb.sock  0600                         │   ║\n  ║                ├──▶ ~/.mcp-krb-fetch.sock  0600  ⑦b                 │   ║\n  ║                └──▶ ~/.mcp-krb-git.sock  0600  ⑦c                   │   ║\n  ║                              ▲                                      │   ║\n  ║    ssh ────── ⑤ ─────┐       │                                      │   ║\n  ╚══════════╪═══════════╪═══════╪══════════════════════════════════════╪═══╝\n             │           │       ║                                      │\n             │           │       ║   ssh -R, one per socket             │\n             │           │       ║   the SOCKETS are forwarded,         │\n             │           │       ║   never the credential               │\n             │           │       ║                                      │\n  ╔══════════╪═══════════╪═══════╪══════════════════════════════════════╪═══╗\n  ║ SHARED   │  DEV HOST         ║                                      │   ║\n  ║ ◀────────┘  ⑤ ssh in         ▼                                      │   ║\n  ║                    /run/user/N/mcp-krb.sock        ◀── ⑦a           │   ║\n  ║                    /run/user/N/mcp-krb-fetch.sock  ◀── ⑦b           │   ║\n  ║                    /run/user/N/mcp-krb-git.sock    ◀── ⑦c           │   ║\n  ║                              ▲            ▲                         │   ║\n  ║                       stdio  │            │ asks the workstation    │   ║\n  ║    MCP client ──▶ remote bridge       krb-fetch · krb-git           │   ║\n  ║         │                                  ▲                        │   ║\n  ║         └────────── runs ──────────────────┘                        │   ║\n  ║                                                                     │   ║\n  ║    has a HOST keytab.  NO user ticket.  nothing here to steal.      │   ║\n  ╚═════════════════════════════════════════════════════════════════════╪═══╝\n             │                                                          │\n             │ ⑧ fetch a file, byte-exact · clone, pull, push           │ ④\n             ▼                                                          ▼\n  ┌────────────────────────────────┐        ┌──────────────────────────────┐\n  │ any Kerberised service         │        │ the MCP server               │\n  │ git · CI · wiki · artifacts    │◀─ ⑥ ───│ 401 on every path            │\n  │                                │ on-be- │ holds a SERVICE keytab       │\n  │ whatever your shop runs; this  │ half-of│ your tools live here         │\n  │ repo assumes none of them      │        │ refuses a forwarded TGT      │\n  └────────────────────────────────┘        └──────────────────────────────┘\n\n  ┌────────────────────────────────┐\n  │ the provisioning page          │   the one deliberately anonymous surface:\n  │ served by this installer       │   a machine that is not enrolled yet has\n  │ client scripts, anonymous      │   no ticket, so the bundle it needs in\n  │ on purpose                     │   order to enrol cannot sit behind SPNEGO\n  └────────────────────────────────┘\n```\n\n**① `kinit`** The only step involving a human secret, on the only machine holding\none.\n\n**② Host keytabs** Each enrolled machine gets its own identity. That proves a\nmachine is itself; it says nothing about who *you* are and cannot be used to\nbecome you. A Mac never does this, which is why macOS leaves the realm untouched\nwhere Linux does not.\n\n**③ CA bootstrap** A new machine trusts nothing, so it fetches the realm CA over\nplain HTTP and checks it against a SHA-256 obtained elsewhere. That comparison is\nthe whole check, which is why the hash must not come from the same infrastructure\nthat serves the certificate.\n\n**④ MCP** The client speaks stdio to the bridge; the bridge speaks SPNEGO to the\nserver. The ticket never moves, because the bridge is already where it is.\n\n**⑤ SSH** GSSAPI with `GSSAPIDelegateCredentials no`. Your ticket is **not**\nforwarded, which is why a shell on a shared host has no credentials. Design, not\ngap.\n\n**⑥ On-behalf-of** A tool can act as you against a downstream Kerberised service\nusing constrained delegation with a narrow evidence credential, refusing a\nforwarded TGT. The example tool targets CI; the mechanism cares about none of\nthat. See [On-behalf-of delegation](#on-behalf-of-delegation).\n\n**⑦ Forwarded sockets** `--listen` and `--fetch-listen` serve MCP and fetching\nover `0600` Unix sockets. `ssh -R` forwards them to a shared host, where\n`mcp-krb-remote-bridge.py` joins a client's stdio to them. The far end holds no\ncredential, imports no crypto, and loses the channel when the session ends.\n\n**⑧ Fetching a file** `--fetch` writes a URL to disk over SPNEGO, for content\nthat must arrive byte-exact and therefore must not pass through a model. On a\nshared host it goes via ⑦b, so the GET and the allowlist both happen where the\nticket is.\n\nFair question, since the assistant already has a shell and your ticket, and could call an internal API itself.\n\n**Authorisation cannot be enforced on the client.** A tool that runs on your\nmachine is a tool you can edit. \"Only this group may trigger a build\" living in a\nscript on a workstation is a suggestion; behind an authenticated server it is a\nrule, because the check happens somewhere the caller does not control.\n\n**Audit needs a chokepoint.** Every call is recorded against the Kerberos\nprincipal that made it, allowed or denied. Fifty workstations calling an API\ndirectly produce no such record.\n\n**Delegation needs a service principal.** Acting as you against another service,\nwithout holding your TGT, requires an identity the realm knows and rules it\nenforces. A shell script cannot be granted that, and should not be.\n\n**And the corollary:** things you could already do belong in the shell, not\nbehind a tool. `krb-fetch` and `krb-git` are deliberately **not** MCP tools. The\nassistant already has your ticket, so wrapping an HTTP GET or a `git pull` in a\nserver call would add a hop, a schema and an audit line while changing nothing\nabout what is possible.\nThe test each tool should pass is: *could the caller do this themselves, unlogged,\nif the tool did not exist?* When the answer is yes, it does not belong here.\n\nWorth stating plainly, because shared hosts are the case they exist for and several people at different privilege levels may be logged into one at once.\n\nThe sockets are `0600`, so an unprivileged peer cannot use them. **Root on that\nhost can, while your session is open**, which on a box where colleagues hold sudo\nmeans those colleagues. After you disconnect the socket file remains but nothing\nanswers it.\n\nSo the exposure is bounded by your session rather than by a ticket lifetime. The\nalternative, running `kinit` on the shared host, leaves a ticket cache that root\nthere can read and use to become you everywhere in the realm, for its full\nlifetime, still valid after you log out.\n\nIt is structurally `ssh-agent` forwarding, with a narrower grant: agent forwarding\ngives SSH-to-anywhere, these give one service and one allowlist. The operational\nrule that follows is to avoid mixing privilege levels and sudo on one host.\n\n`krb-git` adds one hop to this picture, a loopback port, because git cannot open\na Unix socket. A port is reachable by every user on the host, so the forwarder\nbehind it checks the uid of each connection against the kernel's TCP table and\nrefuses anyone else. The port grants nothing the socket did not.\n\nA developer logs in to their workstation. That is the only time anyone types a password. From then on Claude Code reaches the internal MCP server with no further prompt and nothing to configure:\n\n```\nklist              # a ticket is already there, put in place at login\nclaude mcp list    # internal-tools: connected\n```\n\nThe server learns who they are on every request, decides what they may call from their directory group membership, and records the decision under their real name. Because nothing was set up with a shared secret, there is nothing to rotate, leak, or hunt down later: removing the account in FreeIPA removes the access.\n\n`server/install/run.sh` is the installer, and it covers every step so nothing has\nto be done by hand: the service account, the `--system-site-packages` venv, the\ncode deploy, the keytab retrieval and permission contract, `mcp-server.service`,\nthe nginx vhost, the certificate, and the certbot deploy hook. It is idempotent, so\nre-running it converges, and it refuses to proceed on a value it cannot resolve\nrather than defaulting to a placeholder.\n\nTwo prerequisites are human on purpose, because they consume admin credentials the installer deliberately refuses to hold (it preflights for both and stops with the exact command if either is missing):\n\n1. The host is enrolled in FreeIPA (`ipa host-add` with a one-time OTP, then`ipa-client-install` on the box).\n2. The IPA service principal `HTTP/<mcp-fqdn>` exists and this host is allowed to\nretrieve its keytab.`run.sh --create-ipa-service` will do this for you, but\nonly if you already hold an admin ticket.\n\nThen, with `server/install/site.env.example` copied to `/etc/mcp-server/site.env`\nand filled in:\n\n```\nsudo sh server/install/run.sh --site-env /etc/mcp-server/site.env\nsh server/install/verify.sh <fqdn>\n```\n\nThe server's Python environment is the one part worth understanding before you run\nit. The venv must be created with `--system-site-packages` so it keeps the OS\n`python3-gssapi`, with `mcp` and `uvicorn` installed from\n`server/requirements.lock.txt`. An isolated venv produces a server that starts\ncleanly and then fails every SPNEGO handshake. The installer gets this right; the\nnote is here for anyone building the environment by hand.\n\nTwo warnings that bite in practice:\n\n- The installer runs as root and deploys whatever it finds in the source tree, so\nit walks every parent directory up to `/` and refuses a checkout that is not\nroot-owned or is group/world-writable. A`git pull` into a home directory fails\nthis by design; copy the tree to a root-owned path first.\n- `run.sh` restarts`mcp-server` unconditionally at the end, because`systemctl enable --now` does nothing to an already-running unit and would otherwise leave\nyesterday's process serving new code with every check still green.\n\n`verify.sh` is read-only and worth re-running after any change: 14 checks that turn\na silently broken install (a 401ing ACME challenge, a policy that denies everyone, a\nstale process, a delegation flag half-configured) into a loud one. Full installer\ndetail, what `verify.sh` asserts, and what the host serves live in\n[`server/`](https://github.com/overpassconnect/mcp-krb-server/blob/main/server) and [SECURITY.md](https://github.com/overpassconnect/mcp-krb-server/blob/main/SECURITY.md).\n\nOne command per platform, documented end to end in\n[`client/README.md`](https://github.com/overpassconnect/mcp-krb-server/blob/main/client/README.md): `client/setup.sh` for IPA-enrolled Linux,\n`client/setup.ps1` for non-domain-joined Windows (it stands up Kerberos SSH and the\nbridge inside WSL2, where the ticket lives). Both fetch the kit over HTTPS pinned to\nthe realm CA and never pipe a download to a shell.\n\nmacOS has no script. It is a short, documented manual Kerberos-client setup\n(see [`client/README.md`](https://github.com/overpassconnect/mcp-krb-server/blob/main/client/README.md) and the provisioning page the MCP host\nserves), separate because macOS ships Heimdal rather than MIT krb5 and needs a\nspecific `kdc = tcp/...` line that a naive config gets wrong. Both halves of the\nkit apply there: SSH via GSSAPI is proven, and the MCP bridge has a documented\npath too (`pip install gssapi`; the wheel links the system Heimdal, so the bridge\nreads the same ticket `kinit` writes), verified by linkage though not yet\nexercised end to end from a Mac.\n\nShared team workspace: once users reach a host with their own Kerberos identity, a\ndirectory the whole team can edit needs no extra machinery. FreeIPA already puts\neveryone in the `ipausers` group, so one owned `root:ipausers` at mode `2770` (setgid)\nwith a default ACL (`setfacl -d -m g:ipausers:rwx`) is group-writable, and new files\ninherit both the group and the writability. Keep it under `/srv` rather than\n`/home/<name>` (IPA auto-creates home directories there), and keep secrets out of a\ngroup-writable path.\n\n- One password, typed at login, and nothing stored anywhere afterwards. No API key to leak, rotate, or discover in a config file two years later. Disable the account in FreeIPA and the last service ticket dies within hours by its own validity window.\n- Every request is authenticated on its own, offline, against the keytab. A\nhalf-finished handshake is never mistaken for a completed one, which is the bug\nclass behind NTLM-style pre-auth bypasses (`[C1]` : the acceptor gates on`ctx.complete` , true GSS completeness per RFC 2743).\n- No session to steal. The server runs `stateless_http=True` , so no`Mcp-Session-Id` is ever issued or honoured as a bearer credential (`[S1]` ).\n- Authorization is deny by default, per tool, by IPA group, resolved through SSSD's\nlocal cache with no network call on the request path (`[S2]` ).\n- Who may call what is code rather than configuration: `authz.TOOL_GROUPS` and`delegation.TOOL_TARGETS` live in security-owned files, so changing them is a\nreviewed change. A bounded operator overlay (`MCP_DELEGATION_TARGETS` ) exists for\nsite-specific targets, syntax-checked, capped, and unable to override a reviewed\none.\n- The replay cache stays on, the service runs under a full systemd sandbox including\n`MemoryDenyWriteExecute` , and the only long-lived secret is a keytab readable by\nthe service account and root (`[R1]` ,`[K1]` , an accepted residual documented in\nSECURITY.md).\n- A tool can act as the caller against any Kerberized downstream service, off by\ndefault (`[D1]` ). An earlier finding, that a client could forward a full TGT to\nescape the target allowlist, is closed by a runtime check; see[On-behalf-of delegation](#on-behalf-of-delegation) .\n\nThe client is a small local stdio bridge ([`client/bridge/`](https://github.com/overpassconnect/mcp-krb-server/blob/main/client/bridge)):\nClaude Code runs it as a subprocess and it forwards every JSON-RPC message to the\nserver, normally over HTTPS with a freshly minted `Negotiate` token. Two exceptions\nare deliberate: the bridge accepts `http://` only for `localhost`, `127.0.0.1` and\n`::1` and refuses any other scheme, and `MCP_KRB_NOAUTH=1` drops the `Authorization`\nheader for local testing. Both let a developer exercise the transport without a KDC,\nand neither belongs in a deployed config.\n\nServer and bridge are both Python. The bridge is stdlib only: `python3-gssapi`\nships with `ipa-client`, so there is nothing to `pip install` on an IPA-enrolled\nLinux workstation. (Inside WSL the distro is not enrolled and has no `ipa-client`,\nso `python3-gssapi` and `krb5-user` are an explicit `apt install`, which `setup.ps1`\nperforms.)\n\nThe server is the official MCP Python SDK (FastMCP, streamable HTTP) with the\nKerberos auth as a self-contained ASGI middleware in front, behind nginx (TLS).\nNon-domain-joined Windows workstations need one extra step, covered under\n[Provision a workstation](#provision-a-workstation) above.\n\n```\n One tool call, end to end.  Every arrow carries [T] the transport and [A] what authenticates it.\n Three arrows say [A] none. Those are deliberate, and each one is explained below the diagram.\n\n WORKSTATION (IPA-enrolled)          | NETWORK |   MCP HOST mcp.example.internal    | REALM\n ------------------------------------+---------+-----------------------------------+-------------\n                                     |         |                                   |\n  [ Claude Code ]                    |         |                                   |\n        |                            |         |                                   |\n        | (1) [T] stdio pipes, one JSON-RPC message per line                        |\n        |     [A] none: the OS process boundary. The bridge runs as you, with       |\n        |         your ticket. This hop adds no authentication of its own.          |\n        v                            |         |                                   |\n  [ mcp-krb-bridge.py ]              |         |                                   |\n        |     \\                      |         |                                   |\n        |      \\ (2) [T] Kerberos TGS-REQ, SPNEGO mech 1.3.6.1.5.5.2 ------------> [ FreeIPA KDC ]\n        |       \\    [A] your TGT, put in your ccache by SSSD when you logged in.  |  ipa.example\n        |        \\       The bridge never prompts for a password.                  |  .internal\n        |         \\  Flags asked for: mutual_auth, out_of_sequence.                |       |\n        |          \\ never delegate_to_peer. See [CL1].                            |       |\n        |           \\                 |         |                                  |       |\n        |            <---- service ticket for HTTP/mcp.example.internal -----------+-------+\n        |                             |         |                                   |\n        | (3) [T] HTTPS 443, TLS 1.2+, realm CA. New token every request.           |\n        |     [A] Authorization: Negotiate <base64 AP-REQ>   (RFC 4559)             |\n        +---------------------------->|-------->[ nginx ]                           |\n                                      |         |   TLS terminates here. Rate + conn\n                                      |         |   limits, 1 MB body cap, X-Forwarded-For\n                                      |         |   overwritten, security headers.\n                                      |         |        |\n                                      |         | (4) [T] UNIX socket /run/mcp-server/mcp.sock,\n                                      |         |         root:<nginx-group> 0660, parent dir 0755\n                                      |         |     [A] none: file permissions are the control.\n                                      |         |         0666 here would let any local user bypass\n                                      |         |         nginx entirely. Set by ExecStartPost as\n                                      |         |         root, because the sandboxed service holds\n                                      |         |         no CAP_CHOWN and cannot set it itself.\n                                      |         |        v\n                                      |         |   [ uvicorn ]\n                                      |         |        |\n                                      |         | ===== Gate 1: who are you =====================\n                                      |         |   SpnegoAuthMiddleware + spnego_auth\n                                      |         |     reject NTLM, cap token at 64 KB\n                                      |         |     accept using KRB5_KTNAME's keytab\n                                      |         |       (that file is root:<grp> 0640)\n                                      |         |     require ctx.complete <- the real gate [C1]\n                                      |         |     pin mech to krb5/SPNEGO, regex the principal\n                                      |         |     require the realm to match MCP_REALM\n                                      |         |   fail -> 401 Negotiate / 403. Reason to the audit\n                                      |         |   log only, never to the caller. [C4]\n                                      |         |        |\n                                      |         |   pass -> scope['krb_principal'] = alice@...\n                                      |         |        v\n                                      |         | ===== Gate 2: may you do this =================\n                                      |         |   require(ctx, '<this tool's own literal name>')\n                                      |         |     authz.TOOL_GROUPS[tool] -> IPA group set\n                                      |         |     os.getgrouplist via SSSD's local cache\n                                      |         |     no network. Any error -> deny. [S2]\n                                      |         |        |\n                                      |         |   every decision, allow or deny, -> JSON audit\n                                      |         |   line on stderr -> journald\n                                      |         |        v\n                                      |         |   [ the tool runs ]\n                                      |         |        :\n                                      |         :        : (5) optional, off by default. [D1]\n                                      |         :        : forward_header(ctx, '<own name>')\n                                      |         :        :   is_narrow_evidence()? a forwarded TGT\n                                      |         :        :     is refused here\n                                      |         :        :   TOOL_TARGETS[tool] -> exactly one SPN\n                                      |         :        :   [T] S4U2Proxy TGS-REQ ------> [ KDC ]\n                                      |         :        :   [A] the evidence credential naming you\n                                      |         :        v\n                                      |         :   [ downstream ] sees alice, not the MCP\n                                      |         :   service account. Site supplies the HTTP call.\n```\n\nThe bridge turns each MCP message into an HTTPS request carrying a fresh SPNEGO\ntoken, which nginx terminates and hands to the Python server over a UNIX socket.\nGate 1 (`spnego_asgi.py` with `spnego_auth.py`) validates the ticket offline against\nthe keytab and answers who the caller is [C1]. Gate 2 (`authz.py`, the first line of\neach tool) answers whether that caller may call the tool, deny-by-default by IPA\ngroup [S2]. The three `[A] none` arrows are hops a stronger control already covers,\nthe OS process boundary and the UNIX socket permissions. Delegation (arrow 5) is off\nby default. [SECURITY.md](https://github.com/overpassconnect/mcp-krb-server/blob/main/SECURITY.md) is the reference for all of it.\n\nOff by default. A tool can call a downstream Kerberized service as the caller, so\nthe downstream sees the real human rather than this server's shared service account.\nThe shipped example `trigger_build` forwards to a CI system, but the mechanism knows\nnothing about CI: it reaches any Kerberized service the caller could reach, such as\nan internal REST API, a directory, a database proxy, or a second MCP server.\n\nThe mechanism is evidence-based S4U2Proxy constrained delegation. It does not use\nprotocol transition, the variant that would let a service mint a ticket for a user\nwho never authenticated. When the caller authenticates, MIT composes a credential\nnaming them from the ticket they already presented; the server shows that to the KDC\nand asks for a ticket to one named downstream service. Two limits follow: it cannot\nact for a user who never called, since that user's ticket is the evidence, and it\ncannot reach a service `delegation.TOOL_TARGETS` has not named (deny by default, one\ntarget per tool, security-owned in code plus a bounded operator overlay).\n\nThe subtle part, and the reason this is more than a config switch, is that a\nhostile client can set `GSS_C_DELEG_FLAG` and hand the server its full forwarded TGT\nin place of a narrow evidence credential, which the realm's target allowlist does\nnot constrain. The server rejects that credential. `is_narrow_evidence()` reads\nMIT's `GSS_KRB5_GET_CRED_IMPERSONATOR`, a marker the Kerberos library writes only on\nthe non-delegating accept path and from the server's own name, so a client cannot\nforge it, and it accepts only an S4U2Proxy evidence credential composed by this\nacceptor. It fails closed on every unresolvable case, including a GSSAPI too old to\nanswer the question. So an earlier finding, that a client could forward a TGT to\nescape the allowlist, is closed by that runtime check, and the realm's allowlist\nholds for everything the server uses.\n\nEnabling it has a cost the docs spell out: the acceptor credential becomes usable\nfor outbound authentication, which raises what a stolen keytab is worth under [K1].\nTurning it on is a deliberate deployment decision. The shipped client does not\ndelegate ([CL1]), and the full analysis is [D1] in [SECURITY.md](https://github.com/overpassconnect/mcp-krb-server/blob/main/SECURITY.md).\n\nSkip this if constrained delegation is familiar. It is the least well known corner of Kerberos and the rest of this section is hard to follow without it.\n\nOrdinary Kerberos proves one thing: *I am stergios, and I am talking to this\nservice*. It says nothing about that service then talking to a third one on your\nbehalf. But that is exactly what is wanted here: the MCP server has to reach Gitea\n**as you**, so Gitea's own permissions and audit log apply to the real person,\nwithout the MCP server ever holding your password or your TGT.\n\nThe naive answer is credential forwarding: you hand the service your TGT and it becomes you, everywhere, for the life of the ticket. That is what this design refuses, because a single compromised service then owns every user who called it, against every service in the realm.\n\nS4U2Proxy, *Service for User to Proxy*, is the KDC feature that does it narrowly:\n\n1. You authenticate to the MCP server normally. That handshake leaves it holding\n**your service ticket to itself** . That ticket is*evidence* you turned up.\n2. The MCP server returns to the KDC: *here is evidence stergios authenticated to\nme; give me a ticket to Gitea, on his behalf.*\n3. The KDC checks whether **this service is allowed to delegate to that target** ,\nand issues only if so.\n\nTwo properties follow, and they are the whole reason for the design.\n\n**No evidence, no ticket.** The server cannot invent a session for somebody who\nnever called it. Its reach is bounded by who actually used it, not by who exists\nin the realm. (The sibling feature S4U2**Self**, \"protocol transition\", removes\nthat bound and lets a keytab mint sessions for arbitrary users. This\nimplementation deliberately does not use it; see [D1].)\n\n**The KDC decides, not the service.** Step 3 is enforced by the KDC, so it keeps\nholding even if the service host is fully compromised. It is the only control in\nthis design that survives that, which is why it is worth configuring properly\nrather than relying on the server's own `MCP_DELEGATION_TARGETS` list.\n\nIn the KDC log a successful one looks like this, and it is worth knowing the shape because it is the fastest way to confirm the whole chain works:\n\n```\nHTTP/mcp.example.internal for HTTP/git.example.internal\n    ... CONSTRAINED-DELEGATION s4u-client=stergios@EXAMPLE.INTERNAL\n```\n\nThree things have to line up, and the KDC is the only one this repository cannot arrange for you.\n\nFirst, authorise the delegation in FreeIPA, as a realm admin. It is two objects,\nnot one, and the split trips people up: a *target* is a reusable named list of\nservices that may be delegated **to**, and a *rule* says which service may use\nthat list. Read a configured pair as one sentence: *this service may act as a\ncaller towards these destinations, and nothing else.*\n\nNeither object is exposed in the FreeIPA web UI. There is no Service Delegation\npage; `ipa` or LDAP are the only ways to see or change them.\n\n```\nipa servicedelegationtarget-add mcp-targets\nipa servicedelegationtarget-add-member mcp-targets \\\n    --principals=HTTP/ci.example.internal@EXAMPLE.INTERNAL\nipa servicedelegationrule-add mcp-delegation\nipa servicedelegationrule-add-member mcp-delegation \\\n    --principals=HTTP/mcp.example.internal@EXAMPLE.INTERNAL\nipa servicedelegationrule-add-target mcp-delegation \\\n    --servicedelegationtargets=mcp-targets\n```\n\nRepeat the flag for each value (`--principals=a --principals=b`). A\ncomma-separated list is accepted and then silently adds nothing: `ipa` reports\nsuccess with the rejects in a `failed` field most callers never read, and the\nfirst symptom is a runtime `KDC_ERR_BADOPTION` that `_explain()` cannot\ndistinguish from a missing rule.\n\nVerify with `ipa servicedelegationrule-show mcp-delegation`, **as a realm admin**.\n\nThat emphasis is the single most expensive thing on this page to get wrong.\nReading these objects needs the `System: Read Service Delegations` permission,\ncarried by the **Service Administrators** privilege, which an ordinary user does\nnot hold. LDAP ACIs *hide* entries rather than refusing them, so an unprivileged\n`ipa servicedelegationrule-find` returns:\n\n```\n------------------------------\n0 service delegation rules matched\n------------------------------\n```\n\non a realm where delegation is fully configured and working. No error, no warning, nothing to suggest the answer was filtered. It is indistinguishable from a realm where nothing has ever been set up.\n\nThis has bitten in practice, on this codebase, and it cost real time: an audit run as an ordinary user concluded the KDC-side allowlist did not exist, and only a direct LDAP query as directory manager showed two rules quietly doing their job. It applies to a host principal too, so checking from the MCP host is equally useless.\n\nTwo things follow. **Audit delegation as `admin` or over LDAP, never as\nyourself.** And if you are the person who will later have to verify this, grant\nyour own account the Service Administrators privilege now, while you still\nremember these objects exist. They are invisible in the web UI, so there is\nnothing to stumble across that would remind you.\n\nTo see them regardless of IPA permissions, from the IPA server itself:\n\n```\nldapsearch -LLL -Y EXTERNAL -H ldapi://%2frun%2fslapd-EXAMPLE-INTERNAL.socket \\\n    -b cn=s4u2proxy,cn=etc,dc=example,dc=internal \\\n    \"(objectClass=*)\" cn memberPrincipal ipaAllowedTarget\n```\n\nSecond, callers need forwardable tickets. Without protocol transition the KDC\nhard-requires it. On Windows that is `setup.ps1 -Forwardable`; elsewhere it is\n`forwardable = true` in `krb5.conf`. A non-forwardable caller is refused with the\nsame opaque error as a missing rule.\n\nThird, turn it on in `site.env`: `MCP_DELEGATION=1` plus a\n`MCP_DELEGATION_TARGETS` row per forwarding tool. `run.sh` validates the grammar,\nrefuses a target naming a tool that does not call `forward_header()`, and warns\nthat the keytab is now usable outbound.\n\n`server/install/site.env.example` is the single source of site values (domain,\nrealm, KDC, MCP URL, CA hash, delegation toggles). Copy it to\n`/etc/mcp-server/site.env`, fill it in, and keep it out of git; the installer reads\nsite values from there and nowhere else.\n\n`MCP_SITE_TOOLS` points at a Python file loaded at startup, after the shipped\nstubs and before the ASGI app is built. It defines one function:\n\n``` python\ndef register(mcp, require, forward_header, register_tool_policy):\n    @mcp.tool()\n    def list_tickets(ctx: Context) -> str:\n        \"\"\"List the caller's tickets.\"\"\"\n        p = require(ctx, 'list_tickets')          # authorize first, always\n        h = forward_header(ctx, 'list_tickets')   # optional: act as the caller\n        ...\n    register_tool_policy('list_tickets', {'support-staff'})\n```\n\nKeep that file outside the deployed code directory. `run.sh` converges that\ndirectory on this repository's file set, so anything left beside the shipped\nmodules is removed on the next deploy, and your tools would go with it.\n`/etc/mcp-server/site_tools.py` is the natural home: the installer owns that\ndirectory and never prunes it. Own it `root:root 0644`, the same as the code, and\nkeep it in whatever repository holds your site configuration.\n\nLoading is fail-loud. A path that is set but unreadable, unloadable, or missing\n`register()` stops the server at startup rather than quietly serving a tool set\nthat lost half its entries. Two limits worth knowing: the invariant test in\n`tests/python/` parses `mcp_server.py` only, so it does not check a site tool's\n`require()` wiring, and delegation targets for site tools still come from\n`MCP_DELEGATION_TARGETS` like any other.\n\n```\nserver/          # the MCP server (official SDK)\n  spnego_auth.py     - hardened Kerberos acceptor (fixes [C1] by construction)\n  spnego_asgi.py     - self-contained SPNEGO ASGI auth middleware (wraps the SDK app)\n  mcp_server.py      - FastMCP server (stateless) + tools; wires in authz + audit\n  authz.py           - security-owned per-tool IPA-group policy + SSSD group lookup\n  authz_editor.py    - optional, disabled-by-default browser editor for that policy\n  delegation.py      - security-owned on-behalf-of forwarding policy; off by default\n  requirements.txt   - server-only deps (mcp, uvicorn); the bridge stays stdlib\n  requirements.lock.txt - the ==-pinned tree actually validated; install from this\n  install/           # everything about getting it running, kept apart from what runs\n    run.sh  - the installer: account, venv, code, keytab, unit, vhost, cert, and\n              the client bundle (served at /client/ by default, or --client-export DIR)\n    verify.sh  - read-only post-install verifier (last step of the install), 14 checks\n    site.env.example   - the single source of site values; copy, fill, keep out of git\n    mcp-server.service, nginx-mcp.nginx\nclient/          # everything that runs on a workstation\n  setup.sh           - Linux: enroll in FreeIPA, then install the MCP client\n  setup.ps1          - Windows: WSL2 Kerberos SSH, VS Code Remote-SSH, Firefox-in-WSL, MCP bridge\n  install-bridge.sh         - install the client (downloaded over HTTPS, then run; never piped to a shell)\n  JsoncEdit.ps1      - helper used by setup.ps1 to edit JSONC config in place\n  README.md          - provisioning a Linux or Windows workstation, end to end\n  bridge/            # what install-bridge.sh installs on a workstation\n    mcp-krb-bridge.py  - the bridge itself (stdlib + python3-gssapi)\n    mcp-krb-remote-bridge.py - the inert half, for a host that holds no ticket\n    krb-fetch          - fetch one URL byte-exact, over whichever of the two applies\n    mcp-fetch          - krb-fetch's former name: a shim that runs it, kept so the name still works\n    krb-git            - git through the same choice: Negotiate itself on a workstation, relayed on a shared host\n    examples/          - mcp.json, mcp.json.windows, managed-mcp.json\ntests/           # hermetic unit tests (fake gssapi, no KDC needed)\n  run-tests.sh, python/\ndocs/            # the images this README shows; nothing here is installed anywhere\n```\n\nThe installer directory is `server/install/`. If you find a doc or a script\nreferring to `server/deploy/`, it is stale.\n\nThe full threat model, RFC compliance mapping, ranked findings, CVE inventory and\ndeployment checklist live in [SECURITY.md](https://github.com/overpassconnect/mcp-krb-server/blob/main/SECURITY.md). Read it before deploying.\n\nHeadline posture: authentication is offline SPNEGO/Kerberos on every request, the replay cache stays on, authorization is per-tool deny-by-default by IPA group, and the only long-lived secret is a keytab readable by the service account and by root. The finding to read before deploying is [SC1], which is not fixed: client distribution rests on HTTPS plus a CA pin with no signature, so a compromised publisher can serve anything, and whoever controls those bytes runs code as root on every workstation that installs them. It is an accepted risk with a named upgrade path, and the docs treat it as accepted rather than solved.\n\n- [SECURITY.md](https://github.com/overpassconnect/mcp-krb-server/blob/main/SECURITY.md) : the security review. RFC compliance mapping,\nranked findings, CVE inventory, and the deployment checklist. Read before\nproduction.\n- [client/README.md](https://github.com/overpassconnect/mcp-krb-server/blob/main/client/README.md) : provisioning a Linux, Windows, or macOS\nworkstation end to end, including non-domain-joined Windows via WSL2 (Kerberos SSH\n  - VS Code) and the trust model for the client kit.\n\nRun the hermetic unit tests with `sh tests/run-tests.sh`: they pass on Windows and\nLinux with no native packages, no KDC and no MCP SDK, using a fake `gssapi`, and\ninclude a source-level check that every `@mcp.tool` is wired to the authorization\npolicy and that a tool which forwards names itself. Security-owned paths (the\nacceptor, the middleware, `authz.py`, `delegation.py`) require review under\n[.github/CODEOWNERS](https://github.com/overpassconnect/mcp-krb-server/blob/main/.github/CODEOWNERS); a fork should point that file at its own\nreviewers. Report vulnerabilities per\n[SECURITY.md](https://github.com/overpassconnect/mcp-krb-server/blob/main/SECURITY.md#reporting-a-vulnerability), not in a public issue.\n\n[Apache-2.0](https://github.com/overpassconnect/mcp-krb-server/blob/main/LICENSE), copyright Overpass Connect. See [NOTICE](https://github.com/overpassconnect/mcp-krb-server/blob/main/NOTICE).", "url": "https://wpnews.pro/news/kerberized-mcp-server-with-delegation", "canonical_source": "https://github.com/overpassconnect/mcp-krb-server", "published_at": "2026-09-24 08:35:45+00:00", "updated_at": "2026-09-24 09:01:51.594896+00:00", "lang": "en", "topics": ["agent-protocols", "ai-agents", "developer-tools", "ai-infrastructure"], "entities": ["FreeIPA", "Kerberos", "MCP", "Python SDK", "nginx", "Claude Code", "VS Code", "WSL2"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/kerberized-mcp-server-with-delegation", "markdown": "https://wpnews.pro/news/kerberized-mcp-server-with-delegation.md", "text": "https://wpnews.pro/news/kerberized-mcp-server-with-delegation.txt", "jsonld": "https://wpnews.pro/news/kerberized-mcp-server-with-delegation.jsonld"}}