--- title: Tool Fingerprinting sidebarTitle: Tool Fingerprinting description: Build stable fingerprints for tool identity and schema change detection icon: fingerprint --- import { VersionBadge } from "/snippets/version-badge.mdx"; Downstream systems like routers, gateways, and audit loggers often need to detect whether a tool's schema changed between deployments. Rather than each system inventing its own JSON normalization and hashing logic, you can build stable fingerprints from FastMCP's existing API surface. FastMCP does not define a single "contract hash" because the inclusion policy is necessarily application-specific: some systems care only about the input schema, others include the description, metadata, tags, or version. Instead, this recipe shows how to assemble a fingerprint payload from the parts you care about, then hash it deterministically. ## The Recipe The two key building blocks are: - **`tool.key`** — FastMCP's canonical component identity, encoding type, name, and version (e.g. `tool:greet@1.0` or `tool:greet@`) - **`tool.to_mcp_tool()`** — the protocol-facing tool object that MCP clients see, including the input schema Combine them into a payload, serialize deterministically, and hash: ```python import hashlib import json from fastmcp import FastMCP mcp = FastMCP("demo") @mcp.tool() def greet(name: str) -> str: """Say hello.""" return f"Hello {name}" async def fingerprint_tool(server: FastMCP, tool_name: str) -> str: tool = await server.get_tool(tool_name) if tool is None: raise ValueError(f"Tool {tool_name!r} not found") mcp_tool = tool.to_mcp_tool() dumped = mcp_tool.model_dump(mode="json", by_alias=True, exclude_none=True) payload = { "key": tool.key, "inputSchema": dumped["inputSchema"], } canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")) return hashlib.sha256(canonical.encode("utf-8")).hexdigest() ``` The fingerprint is stable across process restarts as long as the tool's name, version, and input schema remain the same. ## Why `tool.key`? `tool.key` is FastMCP's canonical component identity. It encodes the component type, identifier, and version into a single string: ``` tool:greet@1.0 # versioned tool tool:greet@ # unversioned tool ``` Using `key` rather than just the tool name ensures that two versions of the same tool produce distinct fingerprints, and that a tool and a resource with the same name cannot collide. ## Why `to_mcp_tool()`? `to_mcp_tool()` returns the protocol-facing representation — the shape that MCP clients actually receive. This matters because routers and gateways typically operate on the protocol layer, not FastMCP internals. The `model_dump(mode="json", by_alias=True, exclude_none=True)` call produces a clean, serializable dictionary using the MCP protocol field names. ## Customizing the Payload You own the inclusion policy. Add or remove fields depending on what constitutes a "contract" in your system: ```python async def custom_fingerprint(server: FastMCP, tool_name: str) -> str: tool = await server.get_tool(tool_name) if tool is None: raise ValueError(f"Tool {tool_name!r} not found") mcp_tool = tool.to_mcp_tool() dumped = mcp_tool.model_dump(mode="json", by_alias=True, exclude_none=True) # Include description to detect documentation drift payload = { "key": tool.key, "inputSchema": dumped["inputSchema"], "description": dumped.get("description"), } canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")) return hashlib.sha256(canonical.encode("utf-8")).hexdigest() ``` Common variations: | Field | When to include | | -------------- | -------------------------------------------------------------------------- | | `inputSchema` | Always — this is the core contract | | `description` | When documentation drift matters (e.g. LLM routing decisions depend on it) | | `outputSchema` | When downstream consumers validate response shapes | | `annotations` | When behavioral hints (read-only, destructive) affect routing | | `_meta` | When custom metadata drives policy decisions | ## Detecting Schema Drift in CI Store fingerprints as artifacts and compare between deployments: ```python import json import hashlib from pathlib import Path from fastmcp import FastMCP async def generate_manifest(server: FastMCP) -> dict[str, str]: """Generate a fingerprint manifest for all tools.""" manifest = {} for tool in await server.list_tools(): mcp_tool = tool.to_mcp_tool() dumped = mcp_tool.model_dump(mode="json", by_alias=True, exclude_none=True) payload = { "key": tool.key, "inputSchema": dumped["inputSchema"], } canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")) manifest[tool.key] = hashlib.sha256(canonical.encode("utf-8")).hexdigest() return manifest async def check_drift(server: FastMCP, baseline_path: Path) -> list[str]: """Compare current fingerprints against a stored baseline.""" current = await generate_manifest(server) baseline = json.loads(baseline_path.read_text()) changed = [] for key, fingerprint in current.items(): if baseline.get(key) != fingerprint: changed.append(key) for key in baseline: if key not in current: changed.append(key) return changed ``` Run `generate_manifest` in CI after each build and compare against the previous run. Any differences indicate a schema change that downstream consumers should be aware of.