JJoeven

Curriculum/Tools & Function Calling

MCP Host Policy

The host decides which servers are on, which tools reach the model, and which calls are audited. “It’s MCP” is not a threat model.

intermediate19 min19 / 24

The host must still:

  • Decide which servers are installed
  • Filter tools before they reach the model
  • Audit tools/call
  • Treat resource bodies as untrusted text (injection)
  • Validate arguments with your schema, even if the server sent one
  • Enforce timeouts, size caps, and write keys on the way through

A GitHub MCP server may expose 80 tools. Your support bot needs get_issue, not delete_repo. Filter by name. Introspection is the feature: show the user a list that came from somewhere else, then run your allowlist on top. “It’s MCP” is not a threat model. It is a transport.

The agent loop is a client of the filtered catalog. If delete_repo never appears in the assembler’s tool list and still runs, your call path bypassed the host. That is a bug in the host, not in the model.

Host chooses a subset
1get_issue1list_prs0delete_repo

The server may expose 80 tools. The support bot sees three.

Host chooses a subset

Default-deny new names

Servers version. New tools appear. Default-deny names you have not reviewed. Pin server versions so a surprise delete_repo does not land on Friday because the latest tag grew a catalog. When you allow a new name, you add schema fixtures, a risk label, and an audit line. Same as a local tool.

If a server is down, fail closed. Do not “skip the allowlist because list failed.” An empty list is not “allow all.”

Call-time checks, not only advertise-time

Never showing delete_repo in the prompt is necessary and not sufficient. Something may still emit that name (injection, a stale prompt cache, a second assembler). The host deny on tools/call is the real gate. Advertise-time filter reduces mistakes. Call-time filter stops incidents.

Audit every call: user, server, name, args redacted, policy version, result size. If you cannot answer “who allowed this server,” you cannot operate it.

Resources and prompts through the same policy

resources/read can exfiltrate. Allowlist resource URIs the way you allowlist paths. prompts/get can inject instructions. Treat returned templates as untrusted until you wrap them. The host decides what is inserted into the transcript, not the server.

Classroom host allow

The server offers delete_repo and write_file. The host allow set does not. The model sees get_issue, list_prs, read_file. Calls to delete and write fail even if something bypasses the prompt. That double gate is the lesson.

Live PythonOpen full playgroundpython
Output
Run to execute this in your browser. Nothing is sent to a server.

What printed: the model sees three dotted names, none of them delete or write. get_issue runs. delete_repo is host_denied. write_file is host_denied. slack is denied because the server is not in HOST_ALLOW. The catalog on the wire was larger. The host chose a subset.

What goes wrong

Trusting server-provided schema as policy. Allowing all tools from a “trusted publisher.” Caching list for a week while the server grows. Filtering in the prompt only. Logging without policy version. Installing a server as root. These treat MCP as a permission system. It is not.

How to test host policy

Golden list of names the model may see. For each server tool not in that list, assert call returns host_denied. Assert a name in the list runs. Assert a new name added only to SERVER_TOOLS stays denied until HOST_ALLOW changes. Put that in CI when you bump a server version.

Advertise a subset, deny the rest at call time

A support bot that “uses GitHub MCP” still needs a table: get_issue and list_prs on, delete_repo off. Generate the model’s tool list from that table, not from raw tools/list. When the server ships a new name in v1.4, default-deny until someone adds fixtures, a risk label, and a review. Pin the server so v1.4 cannot land because a lockfile floated.

Call-time deny is mandatory. Stale prompts, injection, and a second assembler will emit names you hid. If delete_repo still runs, the host’s call path bypassed policy. That is your bug. Audit user, server, name, redacted args, policy version. If you cannot say which policy ran, you cannot replay the incident.

Resources and server-offered prompts go through the same host. URI allowlists for reads. Templates are untrusted until wrapped. Down servers fail closed: empty list, not allow-all. Caching tools/list for a week while the catalog grows is how surprise writes appear.

Show humans the filtered names when they toggle a server. Consent on an 80-tool novel is not consent. The loop should only ever be a client of the filtered catalog. Version the host allow table the same way you version local registries. A Friday bump of the GitHub server that adds delete_repo should fail CI because the golden model-visible list changed without a policy diff. If CI cannot see the list, you are reviewing marketing, not a host. Treat host policy as code: reviewed, versioned, default-deny. “We trust that publisher” is not a row in the table. Publishers ship new tools. Your support bot did not ask for them. The model must never see them, and the call path must still refuse them if something emits the name anyway.

How agents use this

Default-deny new tools from a new server version. Pin server versions. Log which policy list was active. MCP does not replace that table. The loop should never be handed delete_repo “because the spec allows servers to advertise it.” Advertising is not allowing.

When a user toggles a server in the UI, show the filtered names they are about to enable, not the raw 80. Consent is meaningless if the list is a novel.

Watch out:Servers expose catalogs. Hosts choose a subset. Policy lives in the host.

Check your understanding

If an MCP server exposes delete_repo, who must block it for a support bot?