MCP Toolset Policy¶
Shared policy for building AI toolsets from the Imbi OpenAPI spec.
Overview¶
The Imbi AI services (imbi-mcp, imbi-assistant, and future bots) turn
the Imbi API's /openapi.json into a toolset via
fastmcp.FastMCP.from_openapi. This module centralises which
operations are kept out of those toolsets so the decision lives in one
place instead of being copied into each consumer:
excluded_route_maps— a path/method denylist (auth, MFA, status, thumbnails) passed asroute_maps, anchored at the spec's mount prefix.exclude_non_ai_tools— aroute_map_fnthat honours thex-imbi-ai-tool: falseextension imbi-api stamps on sensitive operations (e.g. project Configuration / SSM Parameter Store).copy_permissions_to_meta— anmcp_component_fnthat records thex-imbi-permissionextension on each generated component's publicmeta.PermissionFilterMiddleware— narrowstools/listper caller to the operations that caller has permission for. Requirescopy_permissions_to_meta; advisory only, and fails open.mount_prefix— the path prefix imbi-api mounts its routers under (the path ofIMBI_API_URL, e.g./api), read back off the spec. Spec paths carry it but a client'sbase_urldoes not, so anything naming a path by hand has to add it back. RaisesValueErrorwhen the prefix cannot be resolved unambiguously, so a spec glitch fails closed rather than silently unanchoring the exclusions above.
Keeping the which in imbi-api (it stamps the extension on tagged operations) and the how to honour it here means hiding a future endpoint from every AI service is just a matter of tagging it in imbi-api.
Requires the mcp extra:
Usage¶
The two pieces compose — pass the maps as route_maps (alongside any
consumer-specific maps) and the hook as route_map_fn:
import fastmcp
import httpx
from imbi.common import mcp
client = httpx.AsyncClient(base_url="http://localhost:8000")
server = fastmcp.FastMCP.from_openapi(
openapi_spec=spec,
client=client,
name="Imbi",
route_maps=mcp.excluded_route_maps(spec),
route_map_fn=mcp.exclude_non_ai_tools,
)
A consumer with its own classification rules prepends the shared maps to its own:
server = fastmcp.FastMCP.from_openapi(
openapi_spec=spec,
client=client,
route_maps=[*mcp.excluded_route_maps(spec), *MY_ROUTE_MAPS],
route_map_fn=mcp.exclude_non_ai_tools,
)
Note that every operation becomes a tool by default, reads included.
Classifying GETs as MCP resources or resource templates hides them from
clients that only consume tools/*, so consumers should not do it.
To filter each caller's toolset down to what they can actually invoke,
add the mcp_component_fn and the middleware:
server = fastmcp.FastMCP.from_openapi(
openapi_spec=spec,
client=client,
route_maps=mcp.excluded_route_maps(spec),
route_map_fn=mcp.exclude_non_ai_tools,
mcp_component_fn=mcp.copy_permissions_to_meta,
)
server.add_middleware(mcp.PermissionFilterMiddleware(client, spec))
Both parts are required: without copy_permissions_to_meta no tool
carries permission metadata, so the middleware has nothing to filter on
and returns every tool. The client must forward the calling
principal's credentials, or every caller is filtered against one
identity. Both take the spec so they can resolve the API's mount
prefix — the profile lookup and the denylist patterns are absolute
paths, and a deployment served under /api needs them prefixed.
exclude_non_ai_tools is backward compatible: when the extension is
absent it returns None and changes nothing, so a consumer can adopt it
before or after imbi-api ships the flag. copy_permissions_to_meta is
likewise a no-op on operations with no x-imbi-permission.
API Reference¶
mount_prefix ¶
Return the path prefix the API's routers are mounted under.
imbi-api mounts every domain router under the path component of
IMBI_API_URL (e.g. /api), so the spec describes
/api/users/me while an internal client's base_url is just
http://imbi-api:8000. Anything that names a path by hand has to
add the prefix back, and the spec is the only place it appears.
Returns:
| Type | Description |
|---|---|
str
|
The prefix ( |
str
|
derived from the spec path ending in :data: |
Raises:
| Type | Description |
|---|---|
ValueError
|
When the spec does not contain exactly one path
ending in :data: |
Source code in libraries/common/src/imbi/common/mcp.py
excluded_route_maps ¶
Endpoints that must never become AI tools regardless of tagging.
Covers authentication, MFA, the status probe, and image thumbnails.
The patterns are anchored at the spec's mount prefix so they still
match on a deployment served under one (e.g. /api/auth/login).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
spec
|
Mapping[str, Any]
|
The OpenAPI spec the toolset is built from. |
required |
Source code in libraries/common/src/imbi/common/mcp.py
exclude_non_ai_tools ¶
Exclude operations imbi-api flagged as off-limits for AI.
Intended to be passed as route_map_fn to
:meth:fastmcp.FastMCP.from_openapi.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
route
|
HTTPRoute
|
The OpenAPI route fastmcp is classifying. |
required |
_mcp_type
|
MCPType
|
The component type fastmcp would otherwise assign; unused, since the flag overrides any classification. |
required |
Returns:
| Type | Description |
|---|---|
MCPType | None
|
attr: |
MCPType | None
|
|
MCPType | None
|
route-map decision unchanged. The check is identity-against |
MCPType | None
|
|
MCPType | None
|
of the extension) keeps the operation. |
Source code in libraries/common/src/imbi/common/mcp.py
copy_permissions_to_meta ¶
Copy an operation's required permissions onto the component.
Intended to be passed as mcp_component_fn to
:meth:fastmcp.FastMCP.from_openapi. The route carries
:data:PERMISSION_EXTENSION from the spec, but the generated
component only keeps its route privately; recording the value in
the public meta dict at build time lets
:class:PermissionFilterMiddleware read it later without reaching
into fastmcp internals.
Source code in libraries/common/src/imbi/common/mcp.py
required_permissions ¶
Return the permissions an operation-backed tool enforces.
Reads what :func:copy_permissions_to_meta recorded. Tools built
without that mcp_component_fn, or whose operation has no
permission dependency, return an empty list.
Source code in libraries/common/src/imbi/common/mcp.py
PermissionFilterMiddleware ¶
Bases: Middleware
Hide tools the calling principal cannot invoke.
tools/list is filtered down to operations whose required
permissions (see :data:PERMISSION_EXTENSION) the caller actually
holds, so an agent is not offered hundreds of tools that can only
ever return 403. The caller's effective permissions come from
the API's :data:PROFILE_PATH, which reports is_admin and the
permissions list; admins are never filtered, matching the
API's own admin bypass. That path is resolved against the spec's
:func:mount_prefix, since the client's base_url does not
carry the prefix the API is mounted under.
Requires the server to have been built with
:func:copy_permissions_to_meta as its mcp_component_fn.
Without it no tool carries permission metadata, so there is nothing
to filter on and every tool is returned.
This is advisory only -- the API remains the sole enforcement
point. Filtering therefore fails open: if the profile lookup
fails, or the request carries no credentials, the unfiltered list
is returned rather than an empty toolset. A caller who invokes a
hidden tool anyway still gets a 403 from the API.
Resolved profiles are cached per credential for
:data:CACHE_TTL_SECONDS, since clients re-list tools on reconnect
and on capability changes rather than only once per session. The
cache is bounded and keyed on a hash of the credential so the raw
token is never held in memory -- the same tradeoff the API makes
for API-key auth. Permission changes therefore take effect within
the TTL.
Store the API client used to resolve the caller's profile.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
client
|
AsyncClient
|
Client bound to the Imbi API that forwards the calling principal's credentials -- the same client used to build the toolset. Its auth must be per-caller, or every caller would be filtered against one identity. |
required |
spec
|
Mapping[str, Any]
|
The OpenAPI spec the toolset was built from, used to resolve the profile path against the API's mount prefix. |
required |
Source code in libraries/common/src/imbi/common/mcp.py
on_list_tools
async
¶
Filter tools/list to what the caller may invoke.