Skip to content

fix(mcp): annotate tools, confirm deletes, fence app output, expire sessions - #83

Merged
jbouder merged 1 commit into
mainfrom
fix/mcp-safety
Oct 19, 2026
Merged

jbouder merged 1 commit into
mainfrom
fix/mcp-safety

Conversation

@jbouder

@jbouder jbouder commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

Closes #48

What changes

  • Tool annotations on all 13 tools, using the MCP ToolAnnotations hints. openWorldHint: false is set everywhere.

    Hint Tools
    readOnlyHint describe_cluster, list_apps, get_app, get_app_status, get_app_logs, get_app_metrics
    destructiveHint remove_app, update_app, stop_app, plus launch_app
    neither authenticate, start_app, restart_app

    launch_app isn't in the issue's list, but its idempotent upsert overwrites an existing app's spec, so it counts as destructive. idempotentHint is set where retries are safe.

    The issue counts 7 read tools, but only 6 tools are actually read-only. authenticate changes session state, so it isn't marked read-only.

  • remove_app needs confirm: true. Without it, the tool raises a ToolError telling the agent to ask the user and call again.

  • Fenced untrusted output (untrusted.py). App-authored content (pod logs, inline source files) is wrapped in <untrusted-{16 hex nonce}>…</untrusted-…> next to a notice to treat it as data. The content can't close the fence early because it can't know the nonce.

    • get_app_logs returns {notice, logs, truncated} with the last 64 KiB.
    • get_app and the tools that return an app (update_app, stop_app, start_app, restart_app) fence each inline file, capped at the first 16 KiB, with truncatedFiles.
    • list_apps drops inline.files and lists paths only.
  • Sessions.

    • TokenState.last_used is tracked, and logins idle longer than MCP_SESSION_TTL_SECONDS (default 8h) are evicted on each access.
    • A missing session id now raises MissingSessionError instead of falling back to a shared "anonymous" bucket. authenticate reports unavailable, and gated tools get a ToolError. Passthrough bearer callers aren't affected.
  • Docs: MCP page sections on annotations, untrusted output and sessions; MCP_SESSION_TTL_SECONDS in configuration.

Upgrade notes

  • get_app_logs output shape changed: logs is now the fenced string, alongside notice and truncated.
  • list_apps no longer returns inline file contents.
  • Agents must pass confirm: true to remove_app.

Testing

  • Local (tests/test_safety.py):
    • annotations on every tool
    • unconfirmed remove_app raises ToolError and the app survives
    • one injection test per content-returning tool (get_app_logs, get_app, list_apps, stop_app): the injected text, which includes a fake closing marker, sits only inside the nonce fence, and list_apps doesn't contain it at all
    • log cap keeps the tail; file cap plus truncatedFiles
    • idle-session eviction, empty session id refused, authenticate without a session
    • Totals: 35 MCP tests pass, plus ruff and mypy strict.
  • nebari-local, with the MCP image built from this branch:
    • tools/list over HTTP returned the expected readOnlyHint / destructiveHint on all 13 tools.
    • The gated tools couldn't be driven over HTTP: passthrough tokens are dropped (MCP bearer passthrough is broken: fastmcp strips the Authorization header #72), and the pod can't verify the local cluster's self-signed Keycloak certificate for the device flow. So I ran the deployed server's tools in-pod with FastMCP's in-memory client, MCP-layer auth off, and Alice's token sent to the real apps-api:
      • launch_app with an injection-laden index.html, then list_apps → {"paths": ["index.html"]} with no injected text.
      • get_app → the file inside <untrusted-4a20cf76c133ceb1>…</untrusted-4a20cf76c133ceb1>, with the fake </untrusted-0000…> left inert inside it.
      • get_app_logs → fenced, truncated: false.
      • remove_app without confirm → ToolError; with confirm: true → deleted.

🤖 Generated with Claude Code

https://claude.ai/code/session_016pvMUQJoLkBDk6B94TTm45

…essions

- ToolAnnotations on all 13 tools: readOnlyHint on the six read tools,
  destructiveHint on remove_app, update_app, stop_app and launch_app
  (its upsert overwrites an existing app); openWorldHint false.
- remove_app raises ToolError unless confirm=true.
- Pod logs (last 64 KiB) and inline files (16 KiB each) are returned
  inside untrusted-<nonce> markers the content cannot close, with a
  notice to treat them as data; list_apps lists inline file paths only.
- Device-flow sessions are evicted after MCP_SESSION_TTL_SECONDS idle
  (default 8h); a missing session id is refused instead of sharing an
  "anonymous" bucket.

Closes #48

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pvMUQJoLkBDk6B94TTm45
@github-actions

Copy link
Copy Markdown

📄 Docs preview for fix/mcp-safety:
https://fix-mcp-safety.apps-pack.pages.dev

@jbouder
jbouder merged commit cff8b2a into main Oct 19, 2026
13 checks passed
@jbouder
jbouder deleted the fix/mcp-safety branch October 19, 2026 13:32
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.

MCP safety: tool annotations, confirmation on destructive tools, session TTL, fenced untrusted output

1 participant