mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-21 13:04:18 +02:00
docs: add tool fingerprinting recipe for stable schema hashing (#4233)
Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
This commit is contained in:
parent
140d96aadd
commit
989f6f8bde
2 changed files with 161 additions and 13 deletions
|
|
@ -20,10 +20,7 @@
|
|||
"primary": "#2d00f7"
|
||||
},
|
||||
"contextual": {
|
||||
"options": [
|
||||
"copy",
|
||||
"view"
|
||||
]
|
||||
"options": ["copy", "view"]
|
||||
},
|
||||
"description": "The fast, Pythonic way to build MCP servers and clients.",
|
||||
"errors": {
|
||||
|
|
@ -118,7 +115,8 @@
|
|||
"servers/transforms/namespace",
|
||||
"servers/visibility",
|
||||
"servers/transforms/resources-as-tools",
|
||||
"servers/transforms/prompts-as-tools"
|
||||
"servers/transforms/prompts-as-tools",
|
||||
"servers/tool-fingerprinting"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -299,10 +297,7 @@
|
|||
"collapsed": true,
|
||||
"group": "Web Frameworks",
|
||||
"icon": "code",
|
||||
"pages": [
|
||||
"integrations/fastapi",
|
||||
"integrations/openapi"
|
||||
]
|
||||
"pages": ["integrations/fastapi", "integrations/openapi"]
|
||||
},
|
||||
{
|
||||
"collapsed": true,
|
||||
|
|
@ -374,10 +369,7 @@
|
|||
"collapsed": true,
|
||||
"group": "What's New",
|
||||
"icon": "sparkles",
|
||||
"pages": [
|
||||
"updates",
|
||||
"changelog"
|
||||
]
|
||||
"pages": ["updates", "changelog"]
|
||||
},
|
||||
"more/faq"
|
||||
]
|
||||
|
|
|
|||
156
docs/servers/tool-fingerprinting.mdx
Normal file
156
docs/servers/tool-fingerprinting.mdx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
---
|
||||
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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue