Skip to content

Add per-tool toggles and show what each service exposes - #190

Open
Arsey wants to merge 6 commits into
mattt:mainfrom
Arsey:feature/per-tool-toggles
Open

Add per-tool toggles and show what each service exposes#190
Arsey wants to merge 6 commits into
mattt:mainfrom
Arsey:feature/per-tool-toggles

Conversation

@Arsey

@Arsey Arsey commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Enabling a service is all-or-nothing, and nothing in the UI says what it grants. Toggling Calendar on exposes event creation as well as reading — the user can't see that, and can't allow one without the other. For an app whose job is handing personal data to AI clients, that's too coarse.

This adds per-tool control, enforced by the server:

  • Server enforcement. A persisted set of disabled tool names filters tools/list and rejects tools/call — a client holding a stale list still can't invoke a disabled tool. Changes push to connected clients via tools/listChanged. Updates to the network actor carry a generation counter so out-of-order delivery can't apply a stale set.
  • Settings → Services. New pane listing every service's tools with title, description, a Read-only badge (from tool annotations), and a switch per tool. Service master toggles here go through activation, so enabling from Settings triggers the macOS permission prompt and reverts if denied — same as the menu.
  • Menu tooltips. Hovering a service row lists its tools by name.

Default state (nothing disabled) serves the identical tool list as before.

Conflicts with #183 on one tooltip line in ServiceToggleView — trivial to resolve whichever lands second.

Test plan

  • End-to-end over MCP stdio: baseline 19 tools → disable calendars_list → absent from tools/list, tools/call returns the disabled error → persists across relaunch → restore returns the baseline byte-identical
  • Settings pane renders all services/tools with badges and switches; rows dim when the service is off; per-tool state survives service off/on
  • xcodebuild build succeeds
  • Not verified: a tools/list refresh in a live Claude Desktop session at the moment a switch is clicked

Known issue

Freshly rendered switch rows in the pane can transiently paint the wrong position (state is never wrong, repaint corrects it). Mitigated with value-keyed identity; the full fix is moving the disabled set to a published property, which I kept out of scope here.

Arsey and others added 6 commits August 19, 2026 09:29
A JSON-encoded set of disabled tool names is persisted the same way as
trusted clients and pushed into ServerNetworkManager the same way as
service bindings. ListTools omits disabled tools and CallTool rejects
them, so a client holding a stale list still cannot invoke one.
Connected clients get tools/listChanged on every change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLDiNzrDds62dnD9NoCEJV
Each write to ServerController.disabledTools fired an unstructured Task
carrying a snapshot to the actor. Two rapid writes could reach the
actor out of order, and the actor's equality guard would accept the
stale snapshot, leaving live ListTools/CallTool enforcement diverged
from the persisted value until the next toggle. A monotonic generation
counter is now attached to each push; the actor discards any delivery
whose generation is not newer than the last one it applied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLDiNzrDds62dnD9NoCEJV
Each service lists its tools with human-readable titles, descriptions,
and a Read-only badge from the tool annotations, plus a switch per tool
backed by the disabled-tools set. The service master toggle is the same
binding the menu uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLDiNzrDds62dnD9NoCEJV
Two Important findings from the Task 2 review, both in
App/Views/ServicesSettingsView.swift:

1. `.accessibilityElement(children: .combine)` on each tool row merged
   the label and the interactive Toggle into a single non-actionable AX
   element, so VoiceOver users could not flip a tool's switch. Removed
   the combine modifier and gave both the per-tool and per-service
   Toggle real accessibility labels (tool title / service name) with
   `.labelsHidden()` to keep the visual layout unchanged while exposing
   a named, actionable control to assistive technology.

2. Freshly rendered/scrolled switch rows could transiently paint "off"
   while the persisted value was "on" (view identity was reused across
   rows with different underlying state). Per the controller's ruling,
   mitigated by keying each switch's view identity to its current value
   via `.id("\(name)-\(currentValue)")`, so a newly materialized row
   constructs its switch with the right state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLDiNzrDds62dnD9NoCEJV
Hovering a service row now shows what enabling it exposes, using the
tool annotation titles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLDiNzrDds62dnD9NoCEJV
…tion

The Settings service toggle (ServicesSettingsView.serviceBinding) wrote
config.binding.wrappedValue directly, bypassing the activation flow that
the menu path (ServiceToggleView) already performs. Enabling a service
like Calendar from Settings never triggered the macOS permission prompt,
and there was no revert-to-off if activation failed or was denied.

It also never pushed the updated bindings to ServerNetworkManager, so
connected MCP clients were not notified via tools/listChanged when a
service was toggled from Settings (only the menu scene's ContentView
.onChange did this).

Add ServerController.setService(_:enabled:), which updates the binding,
calls config.service.activate() when enabling an unactivated service
(reverting on failure), and pushes currentServiceBindings to the network
manager so notifyToolListChanged fires for connected clients. Route
ServicesSettingsView.serviceBinding's setter through it, removing the
now-redundant manual objectWillChange.send() from the view.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLDiNzrDds62dnD9NoCEJV
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