Skip to content

fix(scripts): stop extract-tools dropping tools with long descriptions#436

Open
latekvo wants to merge 17 commits into
mainfrom
fix/extract-tools-long-description
Open

fix(scripts): stop extract-tools dropping tools with long descriptions#436
latekvo wants to merge 17 commits into
mainfrom
fix/extract-tools-long-description

Conversation

@latekvo

@latekvo latekvo commented Jun 30, 2026

Copy link
Copy Markdown
Member

Defect

scripts/extract-tools.mjs extracts MCP tool definitions by regex to feed the downstream spidershield scan --tools-json security scan. For each id: match it scanned only the next 2000 chars (const descWindow = afterId.slice(0, 2000)) for the description: value, and the description regexes require the string's closing delimiter to fall inside that window.

Any tool whose description's closing delimiter sits past ~2000 chars is therefore matched as null and silently dropped - never emitted, never security-scanned. Two shipping tools hit this:

  • run-sequence (packages/tool-server/src/tools/run-sequence/index.ts) - a ~4285-char multi-line template-literal description whose closing backtick sits ~4324 chars past its id:.
  • describe (packages/tool-server/src/tools/describe/index.ts) - a ~2195-char description whose closing backtick sits ~2252 chars past its id:.

Security impact: describe is the accessibility / DOM tree tool, exactly the kind of tool a spidershield scan should cover, and it was being silently excluded alongside run-sequence. A dropped tool is not a scan finding, so the gap was invisible.

Repro (before)

$ node scripts/extract-tools.mjs | grep -c '"run-sequence"'
0
$ node scripts/extract-tools.mjs | grep -c '"describe"'
0
$ node scripts/extract-tools.mjs | grep -c '"gesture-tap"'
1
# total tools emitted: 70  (run-sequence AND describe both missing)

After

$ node scripts/extract-tools.mjs | grep -c '"run-sequence"'
1
$ node scripts/extract-tools.mjs | grep -c '"describe"'
1
# total tools emitted: 72  (both captured, run-sequence's full 4285-char description intact)

Fix

  • Drop the fixed 2000-char lookahead. Each tool's description: is resolved at the same object-literal brace level as its id: by a forward scan that tracks brace depth over real code only: comments, string literals, template literals (with ${...} interpolations skipped opaquely, recursively), and regex literals (prev-token heuristic incl. regex-permitting keywords like return) are skipped whole, so nothing inside them can open a fake string, shift the depth, or match as a description:/id: token. The value is then parsed to its actual closing delimiter, so a description of any length is captured in full.
  • Because matching is by brace scope rather than raw position, a stray id:/description: token can't corrupt extraction: a description-less tool can't borrow the next tool's description, an id: appearing inside description text or a comment can't truncate or overwrite anything, and an id: key nested inside a value object (e.g. defaultPayload: { id: "example" }) is neither emitted as a spurious tool nor allowed to drop the real one.
  • Captured text follows full JS cooked-string semantics: named escapes, \xNN, \uNNNN, \u{...}, identity escapes, line continuations, and template \r\n/\r -> \n normalization - the emitted (and security-scanned) text is the string the runtime renders.
  • A description value that is not a single plain literal - a concatenation, a ${...} interpolation, a method/operator suffix ("...".slice(0, 3)), as const - is never emitted as a truncated/wrong literal: it warn-skips on stderr (stdout stays valid JSON for the scanner). Non-interpolated template-literal ids (id: `x`) are extracted; interpolated ids stay out of scope like const-reference ids.
  • Duplicate ids keep the first occurrence and warn (exported as a pure, unit-tested function).
  • The extractor stays dependency-free (node: builtins only): the tool-description-quality workflow runs it on a bare checkout without npm ci.
  • Output JSON shape is unchanged.

CI guard

Adds scripts/extract-tools.test.mjs (node's built-in test runner, wired into npm run test:scripts; 36 tests). Ground truth: the real-tree tests parse every tool source file with the actual TypeScript parser (a root devDependency, test-only) and require the extractor's output to equal the AST exactly - names and descriptions byte-for-byte. Any drop, corruption, or fabrication on a shape that enters the tree fails CI loudly, with no reliance on stderr warning formats and no false red from id:-like text in comments or strings.

Two policy gates keep un-extractable shapes out of the tree instead of letting them silently leave the scan: a description that cannot be read statically (non-literal value, shorthand/getter/method/computed member, or a spread sibling that may carry/override it) and a plain-literal description written before its id: (the scan is forward-only) each fail with an actionable, file-naming message.

Fixture tests pin the parsing rules independent of any real tool's wording - long descriptions (>4000-char value, >2000 chars of siblings between id: and description:), embedded id:/description: tokens, comments, keyword-position and sibling regex literals, division, nested templates and interpolation content, template/as const ids, the full escape suite, CRLF cooking, single-quoted forms, trim, warn-skip classes, and the duplicate-id warning. Each new fixture was verified to fail under a targeted mutation of the extractor.

Note: await-ui-element is registered but uses a const-reference id (id: AWAIT_UI_ELEMENT_TOOL_ID) rather than a static literal, so it stays out of scope for this extractor - a separate, pre-existing limitation unrelated to the description window.

Verification

  • node scripts/extract-tools.mjs -> 72 tools, run-sequence and describe both present (total 70 -> 72), byte-identical to the TypeScript-AST parse of the tree, no stderr warnings
  • npm run test:scripts -> all pass (extract-tools.test.mjs: 36 tests)
  • mutation checks: window-bounding, depth-anchor removal, trim removal, dedup neutralization, single-quote-branch neutralization each fail the suite
  • npm run typecheck:scripts -> clean
  • prettier + eslint on changed files -> 0 problems
  • lockfile: npm install --package-lock-only --ignore-scripts -> no diff (idempotent)

latekvo added 8 commits June 30, 2026 18:15
…ription

The next-id: boundary could be truncated by an `id:` token appearing inside
a tool's own description template literal (e.g. a structured example like
`{ id: "menu-item" }`) or a nested field, dropping that tool from the
security scan — the same silent-drop class the PR fixes, via a different
trigger. Anchor the description search on its closing delimiter instead, so an
inner id: can't cut it off, and guard against borrowing a later tool's
description with an intervening-id check. Extract the parser into a pure,
exported extractToolsFromSource so the boundary rules are unit-tested directly.

Output on the real tree is byte-identical (72 tools, no warnings).
…r form

The prior forward search tried the three description delimiter forms in fixed
priority (template, then double, then single), each an unbounded scan. In a file
with two tools using different forms — an earlier double-quoted description and a
later template literal — the template regex grabbed the LATER tool's description
across the whole file; the intervening-id guard then nulled it and the earlier
tool was dropped from the security scan. Locate the nearest `description:` by
position instead and parse whatever delimiter it actually uses, so each tool
keeps its own adjacent description regardless of the forms other tools use.
Real-tree output is unchanged (72 tools, no warnings).
…slash

The unescape step only handled \`, \$, and \\, so a real \n/\t in a
template-literal description (e.g. flow-add-step.ts's shipped
description) survived as a literal two-character backslash-n instead
of an actual newline in the extracted (and downstream
security-scanned) text. Extend it to the standard escapes.
Resolve test:scripts conflict by running both new script test files (extract-tools.test.mjs and next-canary-version.test.mjs).
Match each tool's description: at the same object-literal brace level as
its id:, ignoring braces/tokens inside string and template literals. A
nested id: key (e.g. inside a defaultPayload or example object) between a
tool's id: and its description: no longer drops the real tool or emits a
spurious one - which would have silently removed the tool from the
downstream spidershield security scan. Add matrix/nested-id guard tests.
@latekvo latekvo marked this pull request as ready for review July 1, 2026 22:58

@hubgan hubgan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few inline notes below.

Comment thread scripts/extract-tools.test.mjs Outdated
Comment thread scripts/extract-tools.mjs Outdated
Comment thread scripts/extract-tools.mjs
Comment thread scripts/extract-tools.mjs Outdated
Comment thread scripts/extract-tools.test.mjs Outdated
latekvo added 2 commits July 2, 2026 14:26
… descriptions loud

Address review on the extractor that feeds the spidershield security scan:

- Detect `id:` only in code context, skipping strings, template literals, and
  comments. An `id: "..."` inside a comment - e.g. a `// see id: "screenshot"`
  cross-reference to another tool - was matched as a tool candidate and, via
  first-wins dedup, could silently overwrite the real tool's description in the
  scan. Lexing to code context removes that class (and the `$id:` spurious key).
- Treat a non-static description value as loud, not silent: a `+` concatenation
  or an unescaped `${...}` template interpolation is no longer emitted as a
  truncated leading literal; it warns on stderr and is skipped, so a partial
  description never reaches the scanner unnoticed.
- Warn on a duplicate tool id instead of silently dropping the later one; a
  same-name collision was an invisible scan-bypass.
- Resolve `description:` with a sticky regex anchored at the candidate position
  instead of allocating a fresh substring per candidate.

Real-tree output is unchanged (72 tools, no warnings).
…overage

- Rewrite the whole-tree guard so a tool that legitimately uses a shape the
  extractor skips (a nested `id:` key, a non-static description, a
  description-less tool) no longer turns CI red with a misleading "dropped by
  the extractor" message: every discovered id must be emitted OR named in a
  stderr warning (skipped loudly), and no emitted name may be fabricated. It
  still fails on a genuine SILENT drop.
- Assert run-sequence's long description is captured in full (length + final
  sentence), not just that its name is present; the original bug truncated it.
- Add coverage for the new rules: a commented `id:` cross-reference must not
  corrupt a real tool, concatenation/interpolation descriptions warn and skip,
  and an escaped \${ stays a literal.
- Correct the capability-shape tool count in a comment (7, matching the source).

@hubgan hubgan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the extractor rewrite end-to-end against the real tool tree, not only the suite: it does what it claims — recovers run-sequence and describe (70 to 72) with byte-exact descriptions, drops nothing, and correctly renders flow-add-step's embedded newline. Tests, typecheck, and lint all pass locally. None of the inline comments are blocking; most are latent or pre-existing.

Two items are out of scope for this PR and better handled as follow-ups: (1) tool ids defined via a const reference — e.g. await-ui-element's id: AWAIT_UI_ELEMENT_TOOL_ID — stay invisible to the scan (already acknowledged in the description); a follow-up could cover const-ref ids and add a real-tree emit-count and empty-stderr check so a future silent regression trips CI. (2) The extractor also emits two defs that are not registered as live MCP tools, paste and simulator-server — pre-existing and harmless to the scan, not introduced here.

Comment thread scripts/extract-tools.mjs Outdated
Comment thread scripts/extract-tools.mjs Outdated
Comment thread scripts/extract-tools.mjs
Comment thread scripts/extract-tools.test.mjs Outdated
Comment thread scripts/extract-tools.test.mjs Outdated
Comment thread scripts/extract-tools.test.mjs Outdated
latekvo added 6 commits July 9, 2026 23:59
…uffixes

A regex literal between (or before) a tool's id: and description: corrupted
the forward scan: a quote inside one (z.regex(/["']/)) opened a fake string
state and a brace (match: /[{]/) shifted the tracked depth, so the tool was
warn-skipped out of the spidershield scan; a quote-regex before the id: hid
the tool with no warning at all. Both scanners now skip regex literals whole,
using the standard prev-token heuristic to keep division as division.

A description literal extended by a method/operator suffix (e.g.
"...".slice(0, 3)) was emitted as the raw leading literal with no warning -
silently wrong text in the security scan. The whole-value check now requires
the property to simply end after the literal (',' or '}'), covering
concatenation, method suffixes, 'as const', and any other extension.

Also exports the duplicate-id dedup as a pure function so its warning path
is unit-testable.
… ground truth

The old oracle regex-matched id: literals anywhere (comments included), so a
benign // see id: "x" cross-reference turned CI red, while a real tool
regressing to a warn-skip (regex field, concatenation) left the scan with CI
green. The suite now parses every tool file with the actual TypeScript parser
and requires the extractor's output to equal the AST exactly - names and
descriptions byte-for-byte - plus asserts every real description stays a
plain literal. typescript is test-only: the extractor itself remains
dependency-free for the bare-checkout tool-description-quality workflow.

Drops the run-sequence wording/length coupling (a legitimate rewrite no
longer fails; a synthetic >4000-char fixture pins the fixed-window
regression instead) and adds fixtures for regex-literal siblings, division,
method suffixes, single-quoted forms, and the duplicate-id dedup warning.
…ns, and JS escape semantics

Three lexer classes could still corrupt the scan input, each proven by repro:
- a regex after a keyword (function helpers like 'return /["']/' share files
  with tool definitions today) was read as division, so its quotes opened a
  fake string and every later tool in the file silently vanished;
- template literals were lexed as simple quoted strings, so a nested
  template's opening backtick 'closed' the outer one - a following tool
  silently vanished, and a 'description: ...' string inside an interpolation
  could even be emitted as a tool's real description;
- a no-substitution template-literal id (id: `x`) was invisible.
The scanners now share one set of lexical skip helpers: comments, string
literals, template literals with opaque interpolation skipping (recursive),
and regex literals gated by the standard prev-token heuristic extended with
regex-permitting keywords. Template ids count when non-interpolated;
interpolated ids stay out of scope like const-reference ids.

Captured text now follows full JS cooked-string semantics: \xNN, \uNNNN,
\u{...}, \v/\b/\f, identity escapes (backslash drops), line continuations,
and template CRLF/CR -> LF normalization. Previously anything outside a
9-escape whitelist shipped as raw source text with no warning. The
interpolation probe replaces escape pairs with a placeholder instead of
deleting them, so '$' + escape + '{' can no longer fabricate a fake ${
and warn-skip a valid literal.
…and search-scope fixtures

The oracle previously looked only at plain PropertyAssignments, so a
shorthand, getter, method, computed, or spread-carried 'description' left
the object out of both buckets - a real tool could leave the scan with CI
fully green. Those member kinds now land in the nonStatic bucket (with file
names in the failure message). A plain-literal description written BEFORE
its id lands in a new 'misordered' bucket with an actionable message,
replacing the misleading 'missing from the extractor' failure. An
'id: "x" as const' is unwrapped (as/satisfies/parens) so a correct
extraction is no longer reported as a fabrication, and template-literal ids
are recognized on both sides.

New fixtures pin the lexer soundness cases (keyword-position regex, nested
templates, interpolation containing a fake description:, template and
as-const ids), cooked-text fidelity (escape suite, CRLF cooking, $-escape
no-glue, trim), and search scope (>2000 chars of siblings between id and
description, nested description: key) - each verified to fail under a
targeted mutation of the extractor.

Declares typescript as a root devDependency: the suite imports it, and it
previously resolved only through workspace hoisting.
The escape-pair placeholder was a raw NUL byte, which made grep/file treat
extract-tools.mjs as binary. A space has the same no-glue property.
…s, LS/PS continuations; scope oracle policy to tool sites

Extractor:
- 'counts.in / 2' and 'i++ / 2' end in values; the keyword walk-back and the
  bare +/- in REGEX_PREV_CHARS read the following '/' as a regex opener, and
  with a second slash on the same line the fake regex desynchronized string
  lexing - a following tool could be silently dropped or have description
  text stolen from a string. Property-access and postfix checks close both.
- ids are cooked like any literal so an escape in an id emits the runtime
  tool name to the scan, matching the AST oracle.
- backslash + U+2028/U+2029 are line continuations per spec; they now cook
  to nothing like backslash-newline.

Oracle:
- policy buckets (nonStatic/misordered) now apply only to objects that could
  be tool definitions; an object reached as another object literal's property
  value is data, so an example payload can no longer produce a false red
  demanding reordering or a static description. Equality mirroring is
  unchanged.
- a spread BEFORE an explicit description cannot override it at runtime and
  is no longer flagged; a spread after it, or one with no explicit
  description, still is.
- a getter/method id beside a description was silent on every channel (a
  runtime-real tool bypassing the scan with green CI); it now lands in
  nonStatic.

Fixtures cover each: member/postfix division, defaults-spread, nested data
objects, getter/method ids, escaped ids, LS/PS continuations.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants