mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
156 lines
5.7 KiB
Text
156 lines
5.7 KiB
Text
---
|
|
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";
|
|
|
|
<VersionBadge version="3.0.0" />
|
|
|
|
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.
|