fix(mcp): limit bare delete-verb precedence to leading or conjoined verbs

Co-Authored-By: bot_apk <apk@cognition.ai>
This commit is contained in:
Devin AI 2026-09-25 03:03:56 +00:00
parent cb1ac02814
commit e7456e8a0f
3 changed files with 25 additions and 8 deletions

View file

@ -116,9 +116,19 @@ def _token_variants(token: str) -> frozenset[str]:
return frozenset(variant for variant in variants if variant)
_CONJUNCTION_TOKENS: Final = frozenset({"and", "then", "or", "n"})
def _has_bare_delete_verb(tokens: tuple[str, ...]) -> bool:
return any(
token in _DELETE_TOKENS and (index == 0 or tokens[index - 1] in _CONJUNCTION_TOKENS)
for index, token in enumerate(tokens)
)
def _classify_tokens(tokens: Iterable[str]) -> ToolOperation:
token_tuple: Final = tuple(tokens)
if frozenset(token_tuple) & _DELETE_TOKENS:
if _has_bare_delete_verb(token_tuple):
return "delete"
token_set: Final = frozenset(chain.from_iterable(map(_token_variants, token_tuple)))
if token_set & _READ_TOKENS:
@ -136,10 +146,11 @@ def classify_tool_op(name: str, description: str | None = None) -> ToolOperation
"""Classify a tool by exact token match on its name, falling back to the
description's words only when the name yields no recognized token.
A bare destructive verb outranks read tokens, so ``get_and_delete_item``
is delete while inflected forms only match through variants: precedence
is then read > delete > update > create, keeping ``get_removed_entries``
read. A misleading description cannot override a recognized name."""
A bare destructive verb outranks read tokens only when it leads the name
or follows a conjunction, so ``get_and_delete_item`` is delete while
``describe_purge_job`` and ``getDeleteStatus`` are read. Inflected forms
only match through variants under read > delete > update > create, and a
misleading description cannot override a recognized name."""
by_name: Final = _classify_tokens(_name_tokens(name))
if by_name != "unknown":
return by_name

View file

@ -9,7 +9,7 @@
{"name": "get_removed_entries", "description": null, "expected": "read"},
{"name": "list_deleted_items", "description": null, "expected": "read"},
{"name": "find_deleted", "description": null, "expected": "read"},
{"name": "describe_purge_job", "description": null, "expected": "delete"},
{"name": "describe_purge_job", "description": null, "expected": "read"},
{"name": "updateItem", "description": null, "expected": "update"},
{"name": "create-record", "description": null, "expected": "create"},
{"name": "foo", "description": "Deletes the file", "expected": "delete"},
@ -24,5 +24,6 @@
{"name": "settings", "description": null, "expected": "unknown"},
{"name": "get_and_delete_item", "description": null, "expected": "delete"},
{"name": "fetchAndDelete", "description": null, "expected": "delete"},
{"name": "read_then_remove", "description": null, "expected": "delete"}
{"name": "read_then_remove", "description": null, "expected": "delete"},
{"name": "getDeleteStatus", "description": null, "expected": "read"}
]

View file

@ -98,9 +98,14 @@ const tokenVariants = (token: string): string[] =>
(variant) => variant.length > 0,
);
const CONJUNCTION_TOKENS = new Set(["and", "then", "or", "n"]);
// Matches litellm/proxy/_experimental/mcp_server/tool_classification.py, fixture-pinned.
const classifyTokens = (tokens: string[]): CrudOp => {
if (tokens.some((token) => DELETE_TOKENS.has(token))) return "delete";
const hasBareDeleteVerb = tokens.some(
(token, index) => DELETE_TOKENS.has(token) && (index === 0 || CONJUNCTION_TOKENS.has(tokens[index - 1])),
);
if (hasBareDeleteVerb) return "delete";
const variants = new Set(tokens.flatMap((token) => tokenVariants(token)));
if ([...variants].some((variant) => READ_TOKENS.has(variant))) return "read";
if ([...variants].some((variant) => DELETE_TOKENS.has(variant))) return "delete";