Skip to content

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 as route_maps, anchored at the spec's mount prefix.
  • exclude_non_ai_tools — a route_map_fn that honours the x-imbi-ai-tool: false extension imbi-api stamps on sensitive operations (e.g. project Configuration / SSM Parameter Store).
  • copy_permissions_to_meta — an mcp_component_fn that records the x-imbi-permission extension on each generated component's public meta.
  • PermissionFilterMiddleware — narrows tools/list per caller to the operations that caller has permission for. Requires copy_permissions_to_meta; advisory only, and fails open.
  • mount_prefix — the path prefix imbi-api mounts its routers under (the path of IMBI_API_URL, e.g. /api), read back off the spec. Spec paths carry it but a client's base_url does not, so anything naming a path by hand has to add it back. Raises ValueError when 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:

imbi-common[mcp]

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

AI_TOOL_EXTENSION module-attribute

AI_TOOL_EXTENSION = 'x-imbi-ai-tool'

PROFILE_PATH module-attribute

PROFILE_PATH = '/users/me'

mount_prefix

mount_prefix(spec: Mapping[str, Any]) -> str

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 ('' when the API is mounted at the root),

str

derived from the spec path ending in :data:PROFILE_PATH.

Raises:

Type Description
ValueError

When the spec does not contain exactly one path ending in :data:PROFILE_PATH. This fails closed: assuming the root would unanchor :func:excluded_route_maps, re-exposing auth and MFA operations as tools, and would point :class:PermissionFilterMiddleware at a profile URL that does not exist.

Source code in libraries/common/src/imbi/common/mcp.py
def mount_prefix(spec: collections.abc.Mapping[str, typing.Any]) -> str:
    """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:
        The prefix (``''`` when the API is mounted at the root),
        derived from the spec path ending in :data:`PROFILE_PATH`.

    Raises:
        ValueError: When the spec does not contain exactly one path
            ending in :data:`PROFILE_PATH`. This fails *closed*:
            assuming the root would unanchor
            :func:`excluded_route_maps`, re-exposing auth and MFA
            operations as tools, and would point
            :class:`PermissionFilterMiddleware` at a profile URL that
            does not exist.

    """
    paths: collections.abc.Iterable[str] = spec.get('paths') or {}
    matches = [path for path in paths if path.endswith(PROFILE_PATH)]
    if len(matches) != 1:
        raise ValueError(
            f'Cannot determine the API mount prefix: expected exactly '
            f'one spec path ending in {PROFILE_PATH}, found '
            f'{len(matches)}'
        )
    return matches[0].removesuffix(PROFILE_PATH)

excluded_route_maps

excluded_route_maps(
    spec: Mapping[str, Any],
) -> list[RouteMap]

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
def excluded_route_maps(
    spec: collections.abc.Mapping[str, typing.Any],
) -> list[RouteMap]:
    """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``).

    Args:
        spec: The OpenAPI spec the toolset is built from.

    """
    prefix = re.escape(mount_prefix(spec))
    return [
        RouteMap(pattern=rf'^{prefix}/auth/', mcp_type=MCPType.EXCLUDE),
        RouteMap(pattern=rf'^{prefix}/mfa/', mcp_type=MCPType.EXCLUDE),
        RouteMap(pattern=rf'^{prefix}/status/?$', mcp_type=MCPType.EXCLUDE),
        RouteMap(pattern=r'.*/thumbnail/?$', mcp_type=MCPType.EXCLUDE),
    ]

exclude_non_ai_tools

exclude_non_ai_tools(
    route: HTTPRoute, _mcp_type: MCPType
) -> MCPType | None

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.EXCLUDE when the operation carries

MCPType | None

x-imbi-ai-tool: false, else None to leave the existing

MCPType | None

route-map decision unchanged. The check is identity-against

MCPType | None

False so an explicit x-imbi-ai-tool: true (or the absence

MCPType | None

of the extension) keeps the operation.

Source code in libraries/common/src/imbi/common/mcp.py
def exclude_non_ai_tools(
    route: HTTPRoute, _mcp_type: MCPType
) -> MCPType | None:
    """Exclude operations imbi-api flagged as off-limits for AI.

    Intended to be passed as ``route_map_fn`` to
    :meth:`fastmcp.FastMCP.from_openapi`.

    Args:
        route: The OpenAPI route fastmcp is classifying.
        _mcp_type: The component type fastmcp would otherwise assign;
            unused, since the flag overrides any classification.

    Returns:
        :attr:`MCPType.EXCLUDE` when the operation carries
        ``x-imbi-ai-tool: false``, else ``None`` to leave the existing
        route-map decision unchanged. The check is identity-against
        ``False`` so an explicit ``x-imbi-ai-tool: true`` (or the absence
        of the extension) keeps the operation.

    """
    if route.extensions.get(AI_TOOL_EXTENSION) is False:
        return MCPType.EXCLUDE
    return None

PERMISSION_EXTENSION module-attribute

PERMISSION_EXTENSION = 'x-imbi-permission'

PERMISSION_META_KEY module-attribute

PERMISSION_META_KEY = 'imbi_permission'

copy_permissions_to_meta

copy_permissions_to_meta(
    route: HTTPRoute, component: FastMCPComponent
) -> None

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
def copy_permissions_to_meta(
    route: HTTPRoute, component: FastMCPComponent
) -> None:
    """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.
    """
    permissions = route.extensions.get(PERMISSION_EXTENSION)
    if not permissions:
        return
    component.meta = (component.meta or {}) | {
        PERMISSION_META_KEY: permissions
    }

required_permissions

required_permissions(tool: Tool) -> list[str]

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
def required_permissions(tool: Tool) -> list[str]:
    """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.
    """
    meta = getattr(tool, 'meta', None) or {}
    value = meta.get(PERMISSION_META_KEY)
    return value if isinstance(value, list) else []

PermissionFilterMiddleware

PermissionFilterMiddleware(
    client: AsyncClient, spec: Mapping[str, Any]
)

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
def __init__(
    self,
    client: httpx.AsyncClient,
    spec: collections.abc.Mapping[str, typing.Any],
) -> None:
    """Store the API client used to resolve the caller's profile.

    Args:
        client: 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.
        spec: The OpenAPI spec the toolset was built from, used to
            resolve the profile path against the API's mount prefix.
    """
    self._client = client
    self._profile_path = f'{mount_prefix(spec)}{PROFILE_PATH}'
    self._cache: collections.OrderedDict[
        str, tuple[float, tuple[bool, set[str]]]
    ] = collections.OrderedDict()

on_list_tools async

on_list_tools(
    context: MiddlewareContext[Any], call_next: Any
) -> collections.abc.Sequence[Tool]

Filter tools/list to what the caller may invoke.

Source code in libraries/common/src/imbi/common/mcp.py
async def on_list_tools(
    self,
    context: fastmcp.server.middleware.MiddlewareContext[typing.Any],
    call_next: typing.Any,
) -> collections.abc.Sequence[Tool]:
    """Filter ``tools/list`` to what the caller may invoke."""
    tools: collections.abc.Sequence[Tool] = await call_next(context)
    gated = [(tool, required_permissions(tool)) for tool in tools]
    if not any(required for _, required in gated):
        return tools
    resolved = await self._caller_permissions()
    if resolved is None:
        return tools
    is_admin, granted = resolved
    if is_admin:
        return tools
    kept = [
        tool
        for tool, required in gated
        if self._is_invocable(required, granted)
    ]
    LOGGER.debug(
        'Filtered tool list from %d to %d for caller permissions',
        len(tools),
        len(kept),
    )
    return kept