Skip to content

feat(mcp): annotate tools with MCP capability hints - #251

Open
BeamNawapat wants to merge 2 commits into
KnockOutEZ:mainfrom
BeamNawapat:feat/mcp-tool-annotations
Open

feat(mcp): annotate tools with MCP capability hints#251
BeamNawapat wants to merge 2 commits into
KnockOutEZ:mainfrom
BeamNawapat:feat/mcp-tool-annotations

Conversation

@BeamNawapat

@BeamNawapat BeamNawapat commented Jul 29, 2026

Copy link
Copy Markdown

What & why

The ten tools in tools/list ship with no annotations, so every host falls back to its own default. Per the MCP spec that default is readOnlyHint: false — wigolo is currently advertising all ten tools as potentially state-changing.

The sharpest consequence is Claude Code's plan mode: it refuses any MCP tool that is not annotated read-only, and it resolves that before consulting permission rules. So an mcp__wigolo__* allow rule cannot lift it, and every wigolo call prompts in plan mode no matter what the user puts in settings.json. Observed on Claude Code 2.1.220.

Cursor, VS Code, Zed, Windsurf and opencode read the same hints for auto-approval, and wigolo ships installers for all of them, so this is not Claude-Code-specific.

Changes

  • src/server.tsannotations on all ten tools/list entries. Eight are read-only. cache and watch are not, and say so: cache accepts clear, watch creates and deletes jobs. Prompting on those two is the correct outcome.
  • Two values are derived from the handlers rather than the tool names, because the names mislead:
    • cache is open-worldcheck_changes re-fetches every matching cached URL over the network (src/tools/cache.ts:55).
    • diff is closed-world — it resolves its url sides from the local cache and returns cache_miss rather than fetching (src/tools/diff.ts:49-56).
  • idempotentHint is inert wherever readOnlyHint is true (spec: "meaningful only when readOnlyHint == false"), so those tools carry true for consistency rather than as a claim about output stability.
  • Read-only tools still populate the local content cache. That store is an implementation detail rather than caller-visible state, which is why they stay readOnlyHint: true while cache — the tool that exposes the store directly — does not. Noted in a comment so the next reader does not have to re-derive it.
  • tests/unit/server/tool-annotations.test.ts — pins the full matrix, asserts every hint is an explicit boolean (an absent hint falls back to a host default, which is the failure this fixes), and asserts exactly cache + watch are non-read-only.
  • Docs: a "Your agent keeps asking permission" section in docs/troubleshooting.md covering the plan-mode behaviour, the ~/.claude/settings.json allow rule, and the restart requirement — permission rules are read once at session start, which is the step people miss. Pointers from docs/installation.md and the README troubleshooting list.

No schema, handler, or API-surface change. Purely additive.

Testing

  • npm test passes
  • npm run lint passes
  • Added/updated tests for the new behavior

Full suite locally: 4 pre-existing failures in llm-fallback-e2e, extraction/llm-fallback, and integrations/llm-runner. All three assert "no LLM provider configured" and fail on any machine with a provider key in the OS keychain. Verified identical before and after this change by stashing it and re-running the same files, so they are unrelated to this PR.

tests/unit/server/schema-registration.test.ts asserts tool names and count only, never whole tool objects, so adding an annotations key does not break it — confirmed by running it.

Checklist

  • Follows Conventional Commits
  • Focused change (no unrelated refactoring)
  • I have read CONTRIBUTING.md and agree to its contribution terms

Summary by CodeRabbit

  • New Features

    • Added capability-hint annotations to tools returned by the tools listing (read-only, destructive, idempotent, and open-world indicators) along with per-tool descriptive titles.
  • Documentation

    • Expanded troubleshooting for agents repeatedly requesting permission, including guidance on allowing tools and restarting sessions so rules are reloaded.
    • Added installation pointers and clarified how permission behavior is influenced by tool capability hints.
  • Tests

    • Added unit coverage to verify each advertised tool includes complete, correctly-typed capability annotations and matches the expected non-read-only tool set.

The ten tools in `tools/list` shipped with no `annotations`, so every host
had to fall back to its own default. Per the MCP spec that default is
`readOnlyHint: false`, which means wigolo was advertising all ten tools as
potentially state-changing.

Claude Code's plan mode refuses any MCP tool that is not annotated
read-only, and it resolves that before consulting permission rules — so an
`mcp__wigolo__*` allow rule could not lift it and every call prompted.
Cursor, VS Code, Zed, Windsurf and opencode read the same hints for
auto-approval, and wigolo ships installers for all of them.

Eight tools are read-only. `cache` and `watch` are not, and are annotated
accordingly: `cache` accepts `clear`, `watch` creates and deletes jobs.
Prompting on those two is the correct outcome, not a regression.

Two values are less obvious than they look, both derived from the handlers
rather than the tool names:

- `cache` is open-world. `check_changes` re-fetches every matching cached
  URL over the network (src/tools/cache.ts).
- `diff` is closed-world. It resolves its `url` sides from the local cache
  and returns `cache_miss` rather than fetching (src/tools/diff.ts).

`idempotentHint` is inert wherever `readOnlyHint` is true, per the spec, so
those tools carry `true` for consistency rather than as a claim about
output stability.

No schema, handler, or API-surface change. The new test pins the full
matrix so an eleventh tool added without annotations fails the suite
instead of silently regressing permissions in every host.
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The MCP tools/list response now includes per-tool capability annotations. New tests verify the annotation contract, and documentation explains recurring permission prompts and required client configuration.

Changes

MCP tool annotations

Layer / File(s) Summary
MCP annotation contract
src/server.ts
Tool listings now include titles and explicit read-only, destructive, idempotent, and open-world hints with tool-specific values.
Annotation integration tests
tests/unit/server/tool-annotations.test.ts
In-memory server tests validate annotation presence, boolean types, expected hint values, and state-changing tool classification.
Permission troubleshooting guidance
README.md, docs/installation.md, docs/troubleshooting.md
Documentation covers recurring permission prompts, MCP capability hints, client configuration, literal server prefixes, and session restarts.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested reviewers: knockoutez

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding MCP capability hints to tool annotations.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/server.ts`:
- Around line 334-344: Update the fetch tool annotations in src/server.ts (lines
334-344) to set readOnlyHint false, destructiveHint true, and idempotentHint
false. Update both corresponding fetch entries in
tests/unit/server/tool-annotations.test.ts (lines 99-109 and 204-212) so the
pinned annotation matrix and notReadOnly assertion expect the
interaction-capable semantics.

In `@tests/unit/server/tool-annotations.test.ts`:
- Around line 112-115: Update the test setup around connectClient to isolate
plugin loading by creating an empty temporary directory and assigning its path
to WIGOLO_PLUGINS_DIR before initSubsystems runs. Ensure this setup applies
before loadPlugins is reached, preventing user-installed plugins from affecting
tools/list or executing during the test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d7878363-d6b3-43c6-b445-7f7d107e1a16

📥 Commits

Reviewing files that changed from the base of the PR and between 56da0c8 and ebcd587.

📒 Files selected for processing (5)
  • README.md
  • docs/installation.md
  • docs/troubleshooting.md
  • src/server.ts
  • tests/unit/server/tool-annotations.test.ts

Comment thread src/server.ts
Comment thread tests/unit/server/tool-annotations.test.ts
Caught in review: `fetch` accepts an `actions` array whose type enum
includes `click` and `type` (src/server/tool-schemas.ts), and passing it
forces browser rendering. Those run as live Playwright interactions, so a
caller can submit a login field or trigger navigation on the target page.
That is not read-only.

Annotations are static per tool and cannot vary on whether `actions` was
passed, so `fetch` has to declare the widest behaviour it can reach — the
same rule already applied to `cache` and `watch`. A click on an arbitrary
page can destroy remote state, so `destructiveHint` is true.

The practical cost is real and worth stating: `fetch` now prompts in
Claude Code's plan mode, and it is the most-used tool. The alternatives
were to claim read-only semantics a host would act on, or to split `fetch`
into read and interactive tools, which is an API break. Documented in
troubleshooting.md, with the allow rule for anyone who never passes
`actions`.

Also pins WIGOLO_PLUGINS_DIR in the test. It already resolved under the
redirected WIGOLO_DATA_DIR, but an exported WIGOLO_PLUGINS_DIR in a
developer's shell would have let initSubsystems() import real plugin code
during the run.
@BeamNawapat
BeamNawapat force-pushed the feat/mcp-tool-annotations branch from 6d50117 to b622b7d Compare July 29, 2026 09:19
@BeamNawapat

Copy link
Copy Markdown
Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/unit/server/tool-annotations.test.ts`:
- Around line 140-156: Update the beforeEach/afterEach setup around
WIGOLO_DATA_DIR and WIGOLO_PLUGINS_DIR to capture their inherited values before
overriding them, then restore those values in afterEach instead of always
deleting the variables. Preserve deletion only when a variable was originally
unset, while keeping the existing resetConfig, cleanup, and mock-reset behavior
unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cfada988-b734-4a80-97cc-733c3c9d9e4a

📥 Commits

Reviewing files that changed from the base of the PR and between 56da0c8 and b622b7d.

📒 Files selected for processing (5)
  • README.md
  • docs/installation.md
  • docs/troubleshooting.md
  • src/server.ts
  • tests/unit/server/tool-annotations.test.ts

Comment on lines +140 to +156
beforeEach(() => {
tmpDataDir = mkdtempSync(join(tmpdir(), 'wigolo-tool-annotations-'));
process.env.WIGOLO_DATA_DIR = tmpDataDir;
// `pluginsDir` defaults to `<dataDir>/plugins`, so the line above already
// isolates it — but pin it anyway so an exported WIGOLO_PLUGINS_DIR in the
// developer's shell can't make `initSubsystems()` import real plugin code.
process.env.WIGOLO_PLUGINS_DIR = join(tmpDataDir, 'plugins');
resetConfig();
_resetMigrationGuard();
vi.clearAllMocks();
});
afterEach(() => {
delete process.env.WIGOLO_DATA_DIR;
delete process.env.WIGOLO_PLUGINS_DIR;
resetConfig();
try { rmSync(tmpDataDir, { recursive: true, force: true }); } catch { /* ignore */ }
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Restore inherited environment values in teardown.

Lines 152-153 permanently discard pre-existing WIGOLO_DATA_DIR / WIGOLO_PLUGINS_DIR values. Save and restore them so later tests retain their runner configuration.

Proposed fix
 describe('tools/list capability annotations', () => {
   let tmpDataDir: string;
+  let previousDataDir: string | undefined;
+  let previousPluginsDir: string | undefined;
 
   beforeEach(() => {
+    previousDataDir = process.env.WIGOLO_DATA_DIR;
+    previousPluginsDir = process.env.WIGOLO_PLUGINS_DIR;
     tmpDataDir = mkdtempSync(join(tmpdir(), 'wigolo-tool-annotations-'));
@@
   afterEach(() => {
-    delete process.env.WIGOLO_DATA_DIR;
-    delete process.env.WIGOLO_PLUGINS_DIR;
+    if (previousDataDir === undefined) delete process.env.WIGOLO_DATA_DIR;
+    else process.env.WIGOLO_DATA_DIR = previousDataDir;
+    if (previousPluginsDir === undefined) delete process.env.WIGOLO_PLUGINS_DIR;
+    else process.env.WIGOLO_PLUGINS_DIR = previousPluginsDir;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
beforeEach(() => {
tmpDataDir = mkdtempSync(join(tmpdir(), 'wigolo-tool-annotations-'));
process.env.WIGOLO_DATA_DIR = tmpDataDir;
// `pluginsDir` defaults to `<dataDir>/plugins`, so the line above already
// isolates it — but pin it anyway so an exported WIGOLO_PLUGINS_DIR in the
// developer's shell can't make `initSubsystems()` import real plugin code.
process.env.WIGOLO_PLUGINS_DIR = join(tmpDataDir, 'plugins');
resetConfig();
_resetMigrationGuard();
vi.clearAllMocks();
});
afterEach(() => {
delete process.env.WIGOLO_DATA_DIR;
delete process.env.WIGOLO_PLUGINS_DIR;
resetConfig();
try { rmSync(tmpDataDir, { recursive: true, force: true }); } catch { /* ignore */ }
});
describe('tools/list capability annotations', () => {
let tmpDataDir: string;
let previousDataDir: string | undefined;
let previousPluginsDir: string | undefined;
beforeEach(() => {
previousDataDir = process.env.WIGOLO_DATA_DIR;
previousPluginsDir = process.env.WIGOLO_PLUGINS_DIR;
tmpDataDir = mkdtempSync(join(tmpdir(), 'wigolo-tool-annotations-'));
process.env.WIGOLO_DATA_DIR = tmpDataDir;
// `pluginsDir` defaults to `<dataDir>/plugins`, so the line above already
// isolates it — but pin it anyway so an exported WIGOLO_PLUGINS_DIR in the
// developer's shell can't make `initSubsystems()` import real plugin code.
process.env.WIGOLO_PLUGINS_DIR = join(tmpDataDir, 'plugins');
resetConfig();
_resetMigrationGuard();
vi.clearAllMocks();
});
afterEach(() => {
if (previousDataDir === undefined) delete process.env.WIGOLO_DATA_DIR;
else process.env.WIGOLO_DATA_DIR = previousDataDir;
if (previousPluginsDir === undefined) delete process.env.WIGOLO_PLUGINS_DIR;
else process.env.WIGOLO_PLUGINS_DIR = previousPluginsDir;
resetConfig();
try { rmSync(tmpDataDir, { recursive: true, force: true }); } catch { /* ignore */ }
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/server/tool-annotations.test.ts` around lines 140 - 156, Update
the beforeEach/afterEach setup around WIGOLO_DATA_DIR and WIGOLO_PLUGINS_DIR to
capture their inherited values before overriding them, then restore those values
in afterEach instead of always deleting the variables. Preserve deletion only
when a variable was originally unset, while keeping the existing resetConfig,
cleanup, and mock-reset behavior unchanged.

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