MCP Drift Detection¶
Overview¶
Model Context Protocol (MCP) servers expose tools dynamically. When a server updates a tool's schema — changing parameter types, adding required fields, or removing arguments — an AI agent that cached the old schema may call the tool with malformed arguments, or worse, receive unexpected data it was not designed to handle.
MCP drift detection solves this by pinning a cryptographic fingerprint of each tool schema at a known-good point in time and checking live schemas against those pins at runtime or in CI. Any change to the schema produces a different fingerprint and surfaces a structured McpDriftChange record with a human-readable remediation message.
Basic Usage¶
Pin your tool schemas once at setup time, then check for drift before each agent run:
import {
pinFingerprint,
detectDrift,
FingerprintStore,
} from 'ai-tool-guard/mcp';
// 1. Pin schemas when you first approve them.
const store = new FingerprintStore();
const fp = await pinFingerprint(
'readFile',
'filesystem-server',
myFileReadToolSchema,
'production',
);
store.set(fp);
// 2. At runtime, fetch live schemas from the MCP server and check for drift.
const liveSchemas = await fetchSchemasFromMcpServer();
const result = await detectDrift(store.getAll(), liveSchemas);
if (result.drifted) {
for (const change of result.changes) {
console.error(change.remediation);
}
process.exit(1);
}
Configuration Options¶
MCP drift detection is a standalone module. It does not require a ToolGuard instance. All functions are pure async utilities.
The mcpFingerprint field on ToolGuardConfig lets you attach an expected schema hash to a guarded tool so the guard can verify it at execution time:
const tools = guard.guardTools({
readFile: {
tool: readFileTool,
riskLevel: 'medium',
mcpFingerprint: 'abc123...', // Expected SHA-256 hash
},
});
Core Functions¶
computeFingerprint(toolName, schema): Promise<string>¶
Computes a SHA-256 fingerprint for a tool schema. The schema is canonicalized (object keys sorted recursively) before hashing, so fingerprints are stable regardless of key insertion order.
import { computeFingerprint } from 'ai-tool-guard/mcp';
const hash = await computeFingerprint('readFile', {
type: 'object',
properties: {
path: { type: 'string' },
},
required: ['path'],
});
// => "4f3e2a1b..." (64-character hex string)
The input to the hash function is JSON.stringify({ toolName, schema }) with canonicalized key order. Including toolName in the hash means the same schema used under two different tool names produces two different fingerprints.
pinFingerprint(toolName, serverId, schema, environment?): Promise<McpToolFingerprint>¶
Creates a McpToolFingerprint record capturing the current schema hash, the time of pinning, and an optional environment tag.
import { pinFingerprint } from 'ai-tool-guard/mcp';
const fp: McpToolFingerprint = await pinFingerprint(
'queryDatabase',
'db-server-v2',
queryDatabaseSchema,
'staging',
);
// {
// toolName: 'queryDatabase',
// serverId: 'db-server-v2',
// schemaHash: 'c4f9...',
// pinnedAt: '2024-01-15T10:30:00.000Z',
// environment: 'staging',
// }
detectDrift(pinnedFingerprints, currentSchemas): Promise<McpDriftResult>¶
Compares a set of pinned fingerprints against live schemas. Returns a McpDriftResult indicating whether any drift was found and providing details for each changed tool.
Tools present in currentSchemas but absent from pinnedFingerprints are also flagged — they are treated as unknown tools that have not been reviewed.
const result = await detectDrift(
store.getAll(),
[
{ toolName: 'readFile', serverId: 'fs-server', schema: liveReadFileSchema },
{ toolName: 'writeFile', serverId: 'fs-server', schema: liveWriteFileSchema },
],
);
console.log(result.drifted); // true | false
console.log(result.changes.length); // number of drifted tools
Data Types¶
McpToolFingerprint¶
interface McpToolFingerprint {
/** Tool name. */
toolName: string;
/** MCP server identifier. */
serverId: string;
/** SHA-256 of the canonical schema JSON. */
schemaHash: string;
/** ISO-8601 timestamp when this fingerprint was pinned. */
pinnedAt: string;
/** Optional environment tag (e.g. "production", "staging"). */
environment?: string;
}
McpDriftResult¶
interface McpDriftResult {
/** True if any tool schemas changed or new unpinned tools appeared. */
drifted: boolean;
/** Details for each drifted tool. */
changes: McpDriftChange[];
}
McpDriftChange¶
interface McpDriftChange {
toolName: string;
serverId: string;
/** The hash stored in the pin. "(not pinned)" for new unknown tools. */
expectedHash: string;
/** The hash computed from the live schema. */
actualHash: string;
/** Human-readable description of what changed and how to fix it. */
remediation: string;
}
The remediation string is ready to log or display to a developer. It identifies the server and tool by name, shows the first 12 characters of both hashes for visual comparison, and instructs the developer to call pinFingerprint() after reviewing the change.
FingerprintStore¶
FingerprintStore is an in-memory reference implementation for managing pinned fingerprints. For production deployments, use export() and import() to persist to a file, database, or secret store.
Methods¶
| Method | Signature | Description |
|---|---|---|
set |
(fp: McpToolFingerprint) => void |
Adds or replaces a pinned fingerprint. |
get |
(serverId: string, toolName: string) => McpToolFingerprint \| undefined |
Retrieves a single pin by server and tool name. |
getAll |
() => McpToolFingerprint[] |
Returns all pinned fingerprints as an array. |
delete |
(serverId: string, toolName: string) => boolean |
Removes a pin. Returns true if it existed. |
export |
() => string |
Serializes all fingerprints to a pretty-printed JSON string. |
import |
(json: string) => void |
Loads fingerprints from a JSON string, validating each entry. |
Persistence with export() and import()¶
import fs from 'node:fs';
// Save to disk.
fs.writeFileSync('fingerprints.json', store.export());
// Load on next startup.
const stored = new FingerprintStore();
stored.import(fs.readFileSync('fingerprints.json', 'utf-8'));
import() validates that every entry in the JSON array has non-empty toolName, serverId, schemaHash, and pinnedAt string fields. Malformed entries throw an Error identifying the index of the invalid entry.
Warning
FingerprintStore is an in-memory store. Data is lost when the process exits unless you call export() and persist the result. Plan your persistence strategy before deploying.
Advanced Examples¶
CI/CD Schema Validation¶
Run drift detection as a pre-deployment check. Fail the pipeline if any tool schema changed since the last pin.
// scripts/check-mcp-drift.ts
import fs from 'node:fs';
import { FingerprintStore, detectDrift } from 'ai-tool-guard/mcp';
import { fetchToolSchemas } from './mcp-client.js';
const store = new FingerprintStore();
store.import(fs.readFileSync('fingerprints.json', 'utf-8'));
const liveSchemas = await fetchToolSchemas();
const result = await detectDrift(store.getAll(), liveSchemas);
if (result.drifted) {
console.error('MCP schema drift detected:');
for (const change of result.changes) {
console.error(` [${change.serverId}] ${change.toolName}`);
console.error(` Expected: ${change.expectedHash.slice(0, 12)}...`);
console.error(` Actual: ${change.actualHash.slice(0, 12)}...`);
console.error(` ${change.remediation}`);
}
process.exit(1);
}
console.log('All MCP tool schemas match pinned fingerprints.');
Runtime Drift Checking¶
Check for drift at agent startup, before any tool calls are made, and block execution if drift is found:
import { createToolGuard } from 'ai-tool-guard';
import { FingerprintStore, detectDrift } from 'ai-tool-guard/mcp';
import { fetchToolSchemas } from './mcp-client.js';
async function createGuardedAgent() {
const store = new FingerprintStore();
store.import(loadPersistedFingerprints());
const liveSchemas = await fetchToolSchemas();
const driftResult = await detectDrift(store.getAll(), liveSchemas);
if (driftResult.drifted) {
throw new Error(
`MCP schema drift detected on ${driftResult.changes.length} tool(s). ` +
`Re-pin schemas after review.`
);
}
return createToolGuard({ rules: [...] });
}
Multi-Environment Pinning¶
Pin the same tool separately for production and staging environments, which may expose different schema versions:
import { pinFingerprint, FingerprintStore } from 'ai-tool-guard/mcp';
const store = new FingerprintStore();
// Pin production schema.
store.set(await pinFingerprint('sendEmail', 'email-server', prodSchema, 'production'));
// Pin staging schema (may differ during a rollout).
store.set(await pinFingerprint('sendEmail', 'email-server', stagingSchema, 'staging'));
// The store keys on serverId + toolName, so both coexist.
// Filter by environment when running drift checks:
const prodPins = store.getAll().filter(fp => fp.environment === 'production');
How It Works¶
computeFingerprinttakes the tool name and schema, wraps them in a deterministic object{ toolName, schema }, then passes it throughcanonicalize()— a recursive key-sorting serializer — before computing SHA-256 via the Nodecryptomodule.pinFingerprintcallscomputeFingerprintand wraps the result in aMcpToolFingerprintrecord stamped with the current ISO-8601 time.detectDriftbuilds an internal lookup map keyed on"${serverId}:${toolName}". For each live schema, it looks up the corresponding pin. If the pin is missing, the tool is flagged as unknown. If the pin exists but the hashes differ, the tool is flagged as changed.- For each mismatch, a
McpDriftChangeis constructed with the expected and actual hashes and a remediation string that includes the pin timestamp for easy auditing. - The final
McpDriftResultsetsdrifted: trueif thechangesarray is non-empty.