An MCP server for a private library, gated per user
Blog · mcp

An MCP server for a private library, gated per user

40 tools for AI assistants, role-filtered tool lists, a reading tool for whole books, and the URL-encoding bypass that taught me to parse, not match

In August 2026 the MCP server for my research library had 2 tools. By October it had 40 tools, and AI assistants could search the library, read books in order, check a file’s provenance and follow links to offline Wikipedia. Along the way, one detail mattered more than any tool: a plain reader account and an administrator must see different servers, and a restricted collection must stay invisible even when the request is crafted to slip past the gate.

This post covers both halves: building an MCP connector that AI assistants can actually use for long documents, and gating sensitive collections per user. The gating lessons come from a bypass I found and closed, described here as a design lesson.

Contents · 8 sections

TL;DR #

  • With the Python MCP SDK’s FastMCP, set the host explicitly. Otherwise it binds to localhost and rejects every request through a Kubernetes Service with 421 Invalid Host header.
  • Pin the SDK major version; a 2.0 release renamed the API.
  • Authenticate with personal tokens and filter the tool list by role. Hidden tools must also be refused when called by name, and any “actor” argument must be forced to the token’s user.
  • Give assistants a reading tool with offsets and an outline. Search alone cannot walk a book, and whole-book reads exhaust the client’s context.
  • Gate sensitive collections by parsing requests, never by matching raw strings. A URL-encoded parameter walked straight past my regex.
  • Treat paths and filenames as sensitive metadata. Provenance and download-by-hash endpoints leaked them until they got the same rules as the documents.

Building the server #

The library is a set of small services behind an API gateway: a catalogue, a search service, an NLP service and so on. The MCP server is one more of them. It exposes tools over the streamable HTTP transport and calls the internal services directly.

Two gotchas cost an afternoon:

  1. FastMCP(name) without an explicit host defaults to 127.0.0.1. With transport security left unset, it also restricts the allowed Host headers to localhost names as protection against DNS rebinding. Every request through a Service ClusterIP or DNS name got 421 Invalid Host header. Passing the real bind host fixed it: 421 before, 200 after, same request.
  2. Version 2.0.0 of the mcp package renamed mcp.server.fastmcp.FastMCP and removed the module. An unpinned mcp>=1.2 resolved to 2.0 and failed on import.
# requirements.txt:  mcp<2.0
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("library", host="0.0.0.0")   # match what uvicorn binds

@mcp.tool()
def library_search(q: str, mode: str = "hybrid", osint: str = "off") -> dict:
    ...

A bare GET /mcp returns 406, so HTTP probes fail; I use TCP socket probes for liveness and readiness.

Tools an assistant can actually use #

Early on, an assistant asked to summarise a Romanian novel could only search it. Semantic search returns passages, not a book. So I added library_read: a work or document id, an offset, a length of up to 50,000 characters, and an optional outline of headings with their offsets. On a 466,000-character novel it returned 28 headings in 1.1 seconds.

The server logs then showed the assistant reading the whole novel in ten 50,000-character windows, every call successful. Later in that chat the client switched the connector off, most likely because its context was full. The guidance in the tool’s description now says: use the outline, read chapter by chapter, at most 20,000 characters at a time.

Other lessons from watching real assistant sessions:

  • Steer with docstrings. My question-answering tool runs a local LLM on a shared GPU and is slow. Its description now tells agents to prefer the search tools, and they do.
  • Sub-agents may not inherit connectors, and an assistant’s code sandbox may not reach your download links. Design for reading through tools.
  • Log tool names and arguments in your own access log. My MCP pod logs only said “CallToolRequest”; the gateway’s access log is where I can see which tools an agent called.
  • Paired “400 then new session” lines at connect time are the normal handshake, not errors.
  • Test citation formats end to end: grouped citations like [3, 9] were not parsed until I tested them.

Per-user access: tokens, roles, filtered tools #

Since September the MCP server requires Authorization: Bearer <personal token> with an mcp scope. A token belongs to a user; the user has a role. Tools carry a minimum role:

Role Can use
viewer search, browse, read
reader plus ask and export
curator plus editing tools
admin everything, including any tool not yet classified

Four rules make it hold:

  1. list_tools returns only the tools the caller’s role allows. A reader saw 15 tools, a curator 23.
  2. Calling a hidden tool by name is refused. Hiding is not enough; clients can call anything they can guess.
  3. Any actor argument is replaced by the token’s user, so an agent cannot act “as” someone else.
  4. Every call is logged with the user, tool and a summary of the arguments.

New tools default to admin-only until someone assigns them a role. Fail closed.

Gating sensitive collections #

The library also holds an OSINT shelf, and some of its collections are restricted. Access works in layers:

  • OSINT at all: only accounts with an explicit permission (or curators and above) may search it. Tools take osint=off|only|all.
  • Per collection: a narrowed account carries an allow-list; restricted collections need an explicit grant. A document from a collection you may not see returns 404, not 403, so its existence does not leak.
  • In MCP: for callers without OSINT access, the osint argument is removed from the tool schema, and a call that tries it anyway is refused.

The bypass, and the fix #

An audit found that the gateway decided “does this request want OSINT?” by matching the raw query string with a regex. Two holes followed:

  • a URL-encoded key or value, such as %6Fsint=only or osint=%6Fnly, did not match the regex, yet the web framework decoded it to osint=only. A reader without OSINT access could search the whole shelf;
  • the question-answering and filter endpoints carried osint in a JSON body, which no gate inspected.

The fix parses exactly what the framework will parse and fails closed:

from urllib.parse import parse_qsl

def wants_osint(raw_query: str) -> bool:
    for key, value in parse_qsl(raw_query, keep_blank_values=True):
        if key == "osint" and value not in ("", "off"):
            return True        # any value except off/empty counts as a request
    return False

def clamp_body(body: dict, caller) -> dict:
    if not caller.osint_allowed:
        body["osint"] = "off"  # never trust a body flag from an unprivileged caller
    return body

After deploying it, osint=only, %6Fsint=only and osint=%6Fnly all return 403 for a viewer, and osint=off returns 200. Ten tests cover the encodings and body paths.

The general rule: a security check must see the request the same way the code that acts on it does. If the router decodes, the gate decodes. If the handler reads a body, the gate reads the body.

Metadata leaks through side doors #

When provenance (content fingerprints) was extended to the OSINT shelf, two endpoints that predated it became side doors:

  • a “look up a fingerprint” endpoint returned matching OSINT files with their real paths to any reader;
  • a “download by content hash” endpoint served anything stored on the private IPFS node, which by then included OSINT files, to any reader who had a hash.

Both needed a signed-in reader account, but both ignored the collection rules. Now both apply the same OSINT permission and per-collection scope as the documents, and paths are dropped for readers. A fingerprint says nothing about a file. A path like collection/filename can say a lot.

Testing gates without creating accounts #

One more lesson, from verifying that spoofed identity headers were rejected: my test request sent a fake user name with an admin group header, and the identity bridge, which auto-provisions users on first sight, created a real admin row. I disabled it immediately. A spoof probe against a system that provisions on first sight is a write. Use an existing low-privilege user or read-only endpoints, and assume anything you send may be remembered.

Checklist: an MCP connector for private data #

  • Set FastMCP’s host explicitly; pin the SDK major version.
  • Use TCP probes if your endpoint rejects bare GETs.
  • Require personal tokens; filter list_tools by role and refuse hidden tools by name.
  • Force actor fields to the authenticated user; log every call.
  • Provide a reading tool with offsets, length limits and an outline.
  • Put usage guidance in tool descriptions.
  • Gate on parsed parameters and bodies; fail closed on unknown values.
  • Return 404 for documents a caller may not see.
  • Apply collection rules to every endpoint that can reveal a path or bytes.
  • Never probe auth with requests that can provision identities.

Frequently asked questions #

Why does my FastMCP server return 421 Invalid Host header in Kubernetes? #

Because without an explicit host it binds to localhost and only allows localhost Host headers, as DNS-rebinding protection. Requests through a Service carry the Service name. Pass the real bind host to FastMCP, or configure the allowed hosts.

How should an MCP server handle different user permissions? #

Authenticate each user with a personal token, filter the tool list by role, refuse calls to tools the user cannot see, and override any identity arguments with the authenticated user. Log every call.

How can an AI assistant read a whole book over MCP? #

Give it a reading tool with an offset, a maximum length and a heading outline, and tell it in the tool description to read chapter by chapter. Reading everything in large windows can exhaust the client’s context.

How do I stop URL encoding from bypassing an access check? #

Never match raw query strings. Parse them with the same decoder your framework uses, check the decoded values, also check JSON bodies, and treat any unexpected value as a request for the restricted resource.

The lesson #

An MCP server is just another API, with one twist: its client is tireless, literal and will try whatever the schema allows. That makes the old rules sharper. Show each caller only what they may use, check the request exactly as your code will read it, and remember that a path, a hash lookup or a test probe can reveal as much as the document itself.