255 lines
9.1 KiB
TypeScript
255 lines
9.1 KiB
TypeScript
/**
|
|
* MCP Tool Cardinality Reduction — TV5 / F4.3
|
|
*
|
|
* Pure, stateless utility for reducing a tool manifest to the subset
|
|
* permitted by a caller profile. Announcing fewer tools in the MCP
|
|
* manifest saves tokens in the model's context window ("layer 5"
|
|
* compression).
|
|
*
|
|
* ACTIVATION NOTE: The live MCP server registration loop in server.ts is
|
|
* UNCHANGED by this file. This is a pure utility; wiring it into the server
|
|
* startup path is a separate follow-up task. The default server behaviour
|
|
* remains identical to before.
|
|
*
|
|
* Key rules for reduceToolManifest:
|
|
* 1. A tool is kept if any of its scopes intersects `allowScopes` (when set).
|
|
* 2. A tool listed in `allowTools` is always kept (regardless of scopes).
|
|
* 3. A tool listed in `denyTools` is always removed (takes priority over
|
|
* both allowScopes and allowTools).
|
|
* 4. When neither `allowScopes` nor `allowTools` is present the profile
|
|
* allows everything — the full manifest is returned unchanged.
|
|
* 5. If `maxTools` is set the result is capped: allowTools-listed entries
|
|
* come first, then the remaining entries sorted by name. Deterministic.
|
|
* 6. Input is never mutated.
|
|
* 7. Return type mirrors the input type: array → array, object → object.
|
|
*/
|
|
|
|
import { estimateCompressionTokens } from "../services/compression/stats.ts";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Minimal shape of an MCP tool manifest entry.
|
|
* Structurally compatible with McpToolDefinition from schemas/tools.ts —
|
|
* captures the fields needed for filtering; additional properties pass through.
|
|
*/
|
|
export interface ToolManifestEntry {
|
|
/** MCP tool identifier */
|
|
name: string;
|
|
/** Human-readable description (used for token estimation) */
|
|
description?: string;
|
|
/** Required scopes for this tool. May be absent for tools that need no scope. */
|
|
scopes?: readonly string[] | string[];
|
|
/** Allow additional fields to pass through unchanged */
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
/**
|
|
* A named profile that controls which tools are announced to a model.
|
|
*
|
|
* Filtering semantics:
|
|
* - If neither `allowScopes` nor `allowTools` is set → keep everything.
|
|
* - A tool is kept when:
|
|
* scope_intersection(tool.scopes, allowScopes) is non-empty
|
|
* OR tool.name is in allowTools
|
|
* - Then denyTools removes named tools (deny beats allow).
|
|
* - Finally, maxTools caps the count (allowTools entries are prioritised).
|
|
*/
|
|
export interface ToolProfile {
|
|
/** Arbitrary label for logging/debugging */
|
|
name: string;
|
|
/**
|
|
* Whitelist of scopes. A tool qualifies when any of its scopes appears here.
|
|
* Supports wildcard suffix matching: "read:*" matches "read:x", "read:y", etc.
|
|
*/
|
|
allowScopes?: readonly string[] | string[];
|
|
/**
|
|
* Explicit tool names that are always kept (regardless of scope).
|
|
* Evaluated before denyTools.
|
|
*/
|
|
allowTools?: readonly string[] | string[];
|
|
/**
|
|
* Tool names that are always removed. Takes priority over allowScopes and
|
|
* allowTools.
|
|
*/
|
|
denyTools?: readonly string[] | string[];
|
|
/**
|
|
* Maximum number of tools in the output. When set and the filtered set
|
|
* exceeds this limit:
|
|
* 1. allowTools-listed entries fill the cap first (in name order).
|
|
* 2. Remaining slots are filled from the rest, sorted by name.
|
|
* Deterministic: same input → same output.
|
|
*/
|
|
maxTools?: number;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Internal helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Returns true when `grantedScope` covers `requiredScope`.
|
|
* Mirrors the matching logic in scopeEnforcement.ts.
|
|
*/
|
|
function scopeMatches(grantedScope: string, requiredScope: string): boolean {
|
|
if (grantedScope === "*" || grantedScope === requiredScope) {
|
|
return true;
|
|
}
|
|
if (grantedScope.endsWith("*")) {
|
|
const prefix = grantedScope.slice(0, -1);
|
|
return requiredScope.startsWith(prefix);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Returns true if any scope in `toolScopes` is covered by any scope in
|
|
* `allowScopes`.
|
|
*/
|
|
function scopeIntersects(toolScopes: readonly string[], allowScopes: readonly string[]): boolean {
|
|
for (const ts of toolScopes) {
|
|
for (const as of allowScopes) {
|
|
if (scopeMatches(as, ts)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Applies profile filtering to a flat array of entries.
|
|
* Returns a new array (does not mutate input).
|
|
*/
|
|
function filterEntries(entries: ToolManifestEntry[], profile: ToolProfile): ToolManifestEntry[] {
|
|
const allowScopes = profile.allowScopes ?? [];
|
|
const allowTools = profile.allowTools ?? [];
|
|
const denyTools = profile.denyTools ?? [];
|
|
|
|
const denySet = new Set<string>(denyTools as string[]);
|
|
const allowToolSet = new Set<string>(allowTools as string[]);
|
|
|
|
// No filtering rules → return all entries unchanged.
|
|
const hasFilter = allowScopes.length > 0 || allowTools.length > 0;
|
|
|
|
// Step 1: filter by scope / explicit allow, then apply deny.
|
|
const filtered = entries.filter((entry) => {
|
|
// Deny always wins.
|
|
if (denySet.has(entry.name)) return false;
|
|
|
|
// No filter → keep everything.
|
|
if (!hasFilter) return true;
|
|
|
|
// Explicit allow-list overrides scope checks.
|
|
if (allowToolSet.has(entry.name)) return true;
|
|
|
|
// Scope intersection.
|
|
if (allowScopes.length > 0 && (entry.scopes?.length ?? 0) > 0) {
|
|
return scopeIntersects(entry.scopes as readonly string[], allowScopes as readonly string[]);
|
|
}
|
|
|
|
return false;
|
|
});
|
|
|
|
// Step 2: cap by maxTools. A negative max would silently drop tail entries via
|
|
// slice(0, -n) — treat max < 0 as "no cap" (invalid-state guard).
|
|
const max = profile.maxTools;
|
|
if (max === undefined || max < 0 || filtered.length <= max) {
|
|
return filtered;
|
|
}
|
|
|
|
// Priority ordering: allowTools-listed entries first (by name asc),
|
|
// then the rest by name asc. Both groups are sorted for determinism.
|
|
const prioritised = filtered
|
|
.filter((e) => allowToolSet.has(e.name))
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
const rest = filtered
|
|
.filter((e) => !allowToolSet.has(e.name))
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
return [...prioritised, ...rest].slice(0, max);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Reduce a tool manifest according to the given profile.
|
|
*
|
|
* Overloads preserve the return shape: array → array, Record → Record.
|
|
*/
|
|
export function reduceToolManifest(
|
|
manifest: ToolManifestEntry[],
|
|
profile: ToolProfile
|
|
): ToolManifestEntry[];
|
|
export function reduceToolManifest(
|
|
manifest: Record<string, ToolManifestEntry>,
|
|
profile: ToolProfile
|
|
): Record<string, ToolManifestEntry>;
|
|
export function reduceToolManifest(
|
|
manifest: ToolManifestEntry[] | Record<string, ToolManifestEntry>,
|
|
profile: ToolProfile
|
|
): ToolManifestEntry[] | Record<string, ToolManifestEntry> {
|
|
if (Array.isArray(manifest)) {
|
|
return filterEntries(manifest, profile);
|
|
}
|
|
|
|
// Object/Record variant: convert → filter → convert back.
|
|
const entries = Object.values(manifest);
|
|
const filtered = filterEntries(entries, profile);
|
|
return Object.fromEntries(filtered.map((e) => [e.name, e]));
|
|
}
|
|
|
|
/**
|
|
* Rough token estimate for a tool manifest.
|
|
*
|
|
* Delegates to `estimateCompressionTokens` from the existing compression
|
|
* stats module (chars / 4 ceiling). Accepts both array and Record forms.
|
|
*/
|
|
export function estimateManifestTokens(
|
|
manifest: ToolManifestEntry[] | Record<string, ToolManifestEntry>
|
|
): number {
|
|
const entries = Array.isArray(manifest) ? manifest : Object.values(manifest);
|
|
if (entries.length === 0) return 0;
|
|
|
|
return entries.reduce((sum, entry) => {
|
|
const nameTokens = estimateCompressionTokens(entry.name);
|
|
const descTokens = estimateCompressionTokens(entry.description ?? "");
|
|
return sum + nameTokens + descTokens;
|
|
}, 0);
|
|
}
|
|
|
|
/**
|
|
* Build an opt-in {@link ToolProfile} from environment variables, or `null` when unset (no
|
|
* filtering — the default). Used by the MCP server to disable tools it should not announce.
|
|
*
|
|
* MCP_TOOL_DENY — comma-separated tool names to always drop
|
|
* MCP_TOOL_ALLOW — comma-separated tool names to keep exclusively (allow-list mode)
|
|
*
|
|
* Scope-based (`allowScopes`) and `maxTools` filtering need the full manifest at registration
|
|
* and are intentionally not env-exposed here (a tools/list-level hook is a tracked follow-up).
|
|
*/
|
|
export function readMcpToolProfileFromEnv(
|
|
env: Record<string, string | undefined>
|
|
): ToolProfile | null {
|
|
const parse = (value: string | undefined): string[] =>
|
|
value
|
|
? value
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean)
|
|
: [];
|
|
const denyTools = parse(env["MCP_TOOL_DENY"]);
|
|
const allowTools = parse(env["MCP_TOOL_ALLOW"]);
|
|
if (denyTools.length === 0 && allowTools.length === 0) return null;
|
|
return {
|
|
name: "env",
|
|
...(denyTools.length > 0 ? { denyTools } : {}),
|
|
...(allowTools.length > 0 ? { allowTools } : {}),
|
|
};
|
|
}
|