Skip to content

fix: complete and correct every tool's MCP annotations for plugin review - #184

Open
karaposu wants to merge 3 commits into
brightdata:mainfrom
karaposu:fix/tool-annotations
Open

karaposu wants to merge 3 commits into
brightdata:mainfrom
karaposu:fix/tool-annotations

Conversation

@karaposu

@karaposu karaposu commented Sep 15, 2026

Copy link
Copy Markdown

Why

OpenAI's ChatGPT/Codex plugin directory imports each tool's MCP annotations (readOnlyHint, destructiveHint, openWorldHint) when it scans a server, and rejects submissions whose annotations do not match what the tool does. On main every one of the 74 tools is missing at least one of the three, and several of the values that are present are wrong: scraping_browser_go_back claims to be destructive, while scraping_browser_type_ref, which types into a form on a third-party site, claims nothing at all. The MCP spec defaults a missing destructiveHint and openWorldHint to true, so a scanner reading main today sees 74 tools that all claim to be destructive.

What changes

Three commits, each usable on its own.

  1. A shared test helper and a narrowed test glob. test/helpers/spawn_server.js spawns the server with a hermetic environment (no inherited TOOLS/GROUPS; optional network: 'none' that points the child at a refusing proxy so startup finishes in well under a second without touching the API). npm test now runs test/*.test.js rather than every .js under test/, which bare node --test would otherwise pick up. This commit is shared verbatim with fix: keep credentials and debug payloads out of tool results (complement to 2.11.2) #185 and feat: send the model server instructions on how to escalate between tools #186; whichever merges first, the others drop it on rebase.
  2. One scoped logger, and a bounded startup zone check. Diagnostics go through logger.js ([scope] message on stderr, LOG_LEVEL threshold) instead of 26 hand-formatted console.error calls. The zone check at startup gets the same timeout as every other request (BASE_TIMEOUT, default 10s), so a hung API cannot freeze the server before it answers initialize. That timeout change is byte-identical to fix: bound the startup zone check so a hung API cannot freeze the server #176, which this PR supersedes.
  3. Annotations from eight behaviour classes. tool_annotations.js defines the classes (sync_fetch, job_start, closed_read, browser_read, browser_navigate, browser_scroll, browser_act, browser_fill), each with its three hints and a written reason. A tool declares its class and the class supplies the values; the 50 generated web_data_* tools get theirs from one place. An unknown class never throws: it falls back to the conservative triple, logs a [config] warning, and suffixes the title with [unclassified] so the mistake is visible in any client.

One decision for maintainers

The 51 tools that start a billed collection job (web_data_*, search_dataset, the marketplace queries) are marked readOnlyHint: false. That follows the MCP definition, since they create a job and spend credits, but some clients ask the user to confirm before calling a tool that is not read-only. Whether ChatGPT does so has not been observed; the class design makes flipping it a one-line change in job_start.

Two tools are marked destructive on purpose, scraping_browser_click_ref and scraping_browser_type_ref, because a click or a keystroke on a third-party page can submit, purchase or send. The reasons are written into the class table.

Verification

npm test: 45 tests, 44 pass, 1 skipped. The skipped one checks a deployed server when MCP_URL and MCP_TOKEN are set; the token travels in the header, never the URL. test/annotations-coverage.test.js asserts that every registered tool carries all three hints, that every tool is in a golden class map, that no source file writes annotations: { by hand, and that startup emits no [config] warning.

Not verified

  • Whether ChatGPT shows a confirmation prompt for readOnlyHint: false tools. Needs a session in ChatGPT developer mode.
  • Whether the hosted server at mcp.brightdata.com, a separate build reporting version 1.0.0, will carry these annotations once released. The directory scans the deployed server, not this repository.

Groundwork for validating what this server advertises to MCP clients.
Checking the whole tool catalog from a test means the spawned server has
to register exactly the tools it would in production, and today every
test that starts one re-implements the spawn and spreads the whole of
process.env into the child. A stray TOOLS or GROUPS in a developer's
shell therefore silently shrinks the catalog under the test.

test/helpers/spawn_server.js owns that contract instead:

  - an explicit inherit list. The MCP SDK already supplies HOME, PATH,
    SHELL, TERM and USER, so this carries the network plumbing a
    developer behind a proxy depends on, and nothing else.
  - network: 'none', which points the child at a refused proxy for tests
    that must never reach the real API.
  - captured, drained stderr, so a test can assert on what the server
    reported. Draining is not optional: a piped stderr nobody reads
    blocks the child once the pipe buffer fills, and this server logs
    every tool call.
  - a deployed-server variant that sends the token in an Authorization
    header rather than in a URL, and scrubs the exact value from
    anything it throws, so a transport error cannot put a credential in
    a terminal or a CI log.

The npm script now names test/*.test.js. Bare `node --test` treats every
.js file under test/ as a test, which would run this helper as an empty
passing test. The pattern stays single-level deliberately: under sh
without globstar, test/**/*.test.js matches only files below test/ and
would silently drop the entire suite.
…rtup zone check

Diagnostics were 26 bare console.error calls across two files. Eight had
hand-written [scope] prefixes, the rest had none, so "did the server warn
about this?" could only be asked by matching free text, and an operator
running the server behind a wrapper had no way to filter or quiet them.

logger.js gives every line a scope tag and a LOG_LEVEL threshold
(silent|error|warn|info|debug, default info). Levels gate emission only:
each message is printed exactly as the call site wrote it, printf
placeholders included, so the lines this replaces are unchanged apart
from the tag. Everything stays on stderr, because on the stdio transport
stdout carries the MCP protocol itself and a stray write there corrupts
the session.

Scopes: [zone] for the startup bootstrap, [server] for lifecycle,
[browser] for session handling, [config] for configuration problems, and
the tool's own name for per-call lines.

The zone bootstrap also gets the bound it never had. It runs before the
MCP handshake with no timeout, so a proxy that drops packets rather than
refusing them freezes startup with nothing diagnosable on the client
side: the client waits for a handshake that never comes. It now honors
BASE_TIMEOUT, else ten seconds. Unlike a tool call, "no timeout" is
never a sensible setting for startup, so the bound applies even when
BASE_TIMEOUT is 0. This change is byte-identical to the one in brightdata#176, so
the two merge cleanly in either order and whichever lands second is a
no-op there.
This is the tool-annotation half of preparing the server for OpenAI's
ChatGPT and Codex plugin directory. Their submission portal scans the
live MCP endpoint, reads readOnlyHint, openWorldHint and
destructiveHint from every tool, and checks them against OpenAI's
published definitions; hints that are missing or contradicted by what
the tool does are a documented rejection reason. All 74 tools this
server advertises currently fail that check.

The hints matter beyond that review. Clients use them to decide when to
ask a user for confirmation, and absent hints are not neutral: the MCP
specification defaults destructiveHint and openWorldHint to true, so a
partially annotated tool reads as more dangerous than it is. A browser
"go back" was advertised as destructive and open-world, while filling in
a form on an arbitrary website looked like taking a screenshot.

What was wrong, by those definitions:

  - 51 tools that start a billed collection job (the 50 web_data_* tools
    and discover) claimed readOnlyHint: true. The definition sets it
    false for anything that can "run jobs, start workflows ... or
    otherwise change state". They create a snapshot in the user's own
    account that cannot be un-created, but they destroy, overwrite and
    send nothing, so destructiveHint stays false.
  - navigate, go_back, go_forward, scroll and scroll_to_ref claimed
    destructiveHint: true although every one of them is reversible.
  - click_ref and type_ref can submit a form on a third-party site, the
    textbook case for openWorldHint, and carried none.
  - list_dataset_fields and search_dataset talk only to Bright Data's own
    API and claimed openWorldHint: true.
  - fill_form carried no annotations at all, not even a title.

tool_annotations.js replaces 25 hand-written objects with eight named
classes, so a tool declares what kind of thing it is and the class
supplies all three hints. That is what stops the drift this commit is
fixing: the values cannot be partially written, and a reviewer changing
a policy edits one line rather than fifty-one.

annotate() never throws. An unknown class falls back to the most
conservative hints, warns on [config], and marks the title
[unclassified]. The mark is load-bearing: that fallback triple is
identical to the browser_act class, so without it a mistyped class on
click_ref or type_ref would satisfy every value check and ship as a
warning nobody reads.

test/annotations-coverage.test.js imports none of that, on purpose. It
lists the tools a client actually sees and asserts completeness, that
the advertised set equals the classified set in both directions, and
that each tool carries its class's literal values. Two source invariants
keep annotations written only through the helper and only with known
class names. Given MCP_URL and MCP_TOKEN it runs the same checks against
a deployed server, which is what the submission portal scans and what a
merged-but-not-yet-released change would otherwise leave unverified.

Tool names, descriptions and input schemas are byte-identical before and
after; only annotations move.
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.

1 participant