python-slack-agents 0.7.0__tar.gz → 0.8.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/.gitignore +2 -0
  2. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/CHANGELOG.md +33 -1
  3. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/PKG-INFO +16 -2
  4. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/README.md +14 -1
  5. python_slack_agents-0.8.1/docs/oauth.md +292 -0
  6. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/docs/tools.md +91 -2
  7. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/llms-full.txt +91 -2
  8. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/pyproject.toml +2 -1
  9. python_slack_agents-0.8.1/src/slack_agents/__init__.py +78 -0
  10. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/agent_loop.py +17 -2
  11. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/config.py +85 -2
  12. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/main.py +5 -1
  13. python_slack_agents-0.8.1/src/slack_agents/oauth/__init__.py +6 -0
  14. python_slack_agents-0.8.1/src/slack_agents/oauth/crypto.py +47 -0
  15. python_slack_agents-0.8.1/src/slack_agents/oauth/prompts.py +67 -0
  16. python_slack_agents-0.8.1/src/slack_agents/oauth/server.py +124 -0
  17. python_slack_agents-0.8.1/src/slack_agents/oauth/state.py +106 -0
  18. python_slack_agents-0.8.1/src/slack_agents/oauth/storage.py +124 -0
  19. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/agent.py +191 -25
  20. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/storage/base.py +57 -0
  21. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/storage/postgres.py +113 -1
  22. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/storage/postgres.sql +25 -0
  23. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/storage/sqlite.py +98 -1
  24. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/storage/sqlite.sql +25 -0
  25. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/tools/base.py +69 -1
  26. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/tools/canvas.py +40 -21
  27. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/tools/file_exporter.py +23 -3
  28. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/tools/mcp_http.py +25 -3
  29. python_slack_agents-0.8.1/src/slack_agents/tools/mcp_http_oauth.py +1098 -0
  30. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/tools/user_context.py +45 -18
  31. python_slack_agents-0.8.1/tests/test_llm_error_classification.py +72 -0
  32. python_slack_agents-0.8.1/tests/test_load_plugin.py +47 -0
  33. python_slack_agents-0.8.1/tests/test_mcp_http_oauth.py +626 -0
  34. python_slack_agents-0.8.1/tests/test_oauth_crypto.py +72 -0
  35. python_slack_agents-0.8.1/tests/test_oauth_integration.py +165 -0
  36. python_slack_agents-0.8.1/tests/test_oauth_prompts.py +68 -0
  37. python_slack_agents-0.8.1/tests/test_oauth_server.py +105 -0
  38. python_slack_agents-0.8.1/tests/test_oauth_state.py +78 -0
  39. python_slack_agents-0.8.1/tests/test_oauth_storage.py +189 -0
  40. python_slack_agents-0.8.1/tests/test_oauth_validation.py +77 -0
  41. python_slack_agents-0.8.1/tests/test_storage_oauth.py +132 -0
  42. python_slack_agents-0.8.1/tests/test_tool_errors.py +92 -0
  43. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/uv.lock +30 -46
  44. python_slack_agents-0.7.0/src/slack_agents/__init__.py +0 -26
  45. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/.dockerignore +0 -0
  46. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/.env.example +0 -0
  47. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/.github/workflows/ci.yml +0 -0
  48. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/.github/workflows/publish.yml +0 -0
  49. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/.pre-commit-config.yaml +0 -0
  50. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/AGENTS.md +0 -0
  51. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/CODE_OF_CONDUCT.md +0 -0
  52. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/CONTRIBUTING.md +0 -0
  53. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/LICENSE +0 -0
  54. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/SECURITY.md +0 -0
  55. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/agents/README.md +0 -0
  56. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/agents/docs-assistant/config.yaml +0 -0
  57. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/agents/docs-assistant/system_prompt.txt +0 -0
  58. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/agents/hello-world/config.yaml +0 -0
  59. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/agents/hello-world/system_prompt.txt +0 -0
  60. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/agents/kitchen-sink/config.yaml +0 -0
  61. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/agents/kitchen-sink/system_prompt.txt +0 -0
  62. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/docs/access-control.md +0 -0
  63. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/docs/agents.md +0 -0
  64. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/docs/canvas.md +0 -0
  65. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/docs/cli.md +0 -0
  66. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/docs/deployment.md +0 -0
  67. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/docs/llm.md +0 -0
  68. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/docs/media/demo.gif +0 -0
  69. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/docs/observability.md +0 -0
  70. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/docs/private-repo.md +0 -0
  71. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/docs/setup.md +0 -0
  72. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/docs/slack-app-manifest.json +0 -0
  73. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/docs/storage.md +0 -0
  74. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/docs/user-context.md +0 -0
  75. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/llms.txt +0 -0
  76. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/Dockerfile +0 -0
  77. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/access/__init__.py +0 -0
  78. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/access/allow_all.py +0 -0
  79. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/access/allow_list.py +0 -0
  80. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/access/base.py +0 -0
  81. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/cli/__init__.py +0 -0
  82. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/cli/build_docker.py +0 -0
  83. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/cli/export_conversations.py +0 -0
  84. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/cli/export_conversations_html.py +0 -0
  85. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/cli/export_usage.py +0 -0
  86. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/cli/export_usage_csv.py +0 -0
  87. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/cli/healthcheck.py +0 -0
  88. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/cli/init.py +0 -0
  89. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/cli/run.py +0 -0
  90. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/conversations.py +0 -0
  91. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/files.py +0 -0
  92. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/llm/__init__.py +0 -0
  93. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/llm/anthropic.py +0 -0
  94. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/llm/base.py +0 -0
  95. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/llm/openai.py +0 -0
  96. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/observability.py +0 -0
  97. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/py.typed +0 -0
  98. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/scripts/__init__.py +0 -0
  99. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/scripts/download_fonts.py +0 -0
  100. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/scripts/generate_llms_full.py +0 -0
  101. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/__init__.py +0 -0
  102. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/actions.py +0 -0
  103. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/canvas_auth.py +0 -0
  104. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/canvases.py +0 -0
  105. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/files.py +0 -0
  106. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/format.py +0 -0
  107. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/streaming.py +0 -0
  108. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/streaming_formatter.py +0 -0
  109. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/tool_blocks.py +0 -0
  110. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/storage/__init__.py +0 -0
  111. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/tools/__init__.py +0 -0
  112. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/tools/canvas_importer.py +0 -0
  113. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/src/slack_agents/tools/file_importer.py +0 -0
  114. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/tests/__init__.py +0 -0
  115. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/tests/test_access.py +0 -0
  116. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/tests/test_agent_loop.py +0 -0
  117. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/tests/test_canvas_auth.py +0 -0
  118. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/tests/test_canvas_importer.py +0 -0
  119. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/tests/test_cli.py +0 -0
  120. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/tests/test_config.py +0 -0
  121. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/tests/test_conversations.py +0 -0
  122. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/tests/test_cost.py +0 -0
  123. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/tests/test_export_documents.py +0 -0
  124. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/tests/test_export_usage.py +0 -0
  125. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/tests/test_file_extractors.py +0 -0
  126. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/tests/test_format.py +0 -0
  127. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/tests/test_init.py +0 -0
  128. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/tests/test_llm_factory.py +0 -0
  129. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/tests/test_mcp_client.py +0 -0
  130. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/tests/test_openai_convert.py +0 -0
  131. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/tests/test_overlay_integration.py +0 -0
  132. {python_slack_agents-0.7.0 → python_slack_agents-0.8.1}/tests/test_tool_blocks.py +0 -0
@@ -37,4 +37,6 @@ agents-local/
37
37
 
38
38
  # Certificates
39
39
  *.pem
40
+ you c
40
41
  /ONGOING.md
42
+ /docs/superpowers/
@@ -1,4 +1,4 @@
1
- # Changelog
1
+ we don# Changelog
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
@@ -6,6 +6,38 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.8.1] - 2026-05-07
10
+
11
+ ### Fixed
12
+
13
+ - `oauth_clients` cache is now keyed by `(server_id, redirect_uri)` instead of `server_id` alone. Two `mcp_http_oauth` providers pointing at the same MCP server but with different `OAUTH_PUBLIC_URL` values (e.g. agents sharing a database, or a single agent whose tunnel hostname rotates) used to collide on the cached client registration; the second one would reuse a row whose `redirect_uri` the IdP no longer accepted, producing `Invalid parameter: redirect_uri` from the IdP.
14
+ - `mcp_http_oauth.Provider.call_tool` now detects an IdP `Invalid parameter: redirect_uri` (or `redirect_uri_mismatch`) rejection, deletes the stale cached client registration and the user's cached tokens, and surfaces a structured `system_error` with `code="redirect_uri_mismatch"` so the LLM can explain the situation. The next call re-registers a fresh client and prompts for re-auth.
15
+
16
+ ### Migration
17
+
18
+ - The `oauth_clients` table primary key changed from `(server_id)` to `(server_id, redirect_uri)`. There is no automatic migration: any rows from 0.8.0 will be re-created on demand the first time each `(server_id, redirect_uri)` pair is used, leaving harmless orphan rows behind. Operators who want a clean slate can `DELETE FROM oauth_clients;` before upgrading.
19
+
20
+ ## [0.8.0] - 2026-05-06
21
+
22
+ ### Added
23
+
24
+ - `slack_agents.tools.mcp_http_oauth` — OAuth-authenticated MCP tool provider with per-Slack-user tokens. Each user authenticates separately to the upstream service; refresh tokens are AES-GCM-encrypted at rest. The provider runs an in-process aiohttp callback listener alongside Slack Bolt's WebSocket connection — no public ingress beyond a single `/oauth/callback` path. Includes Dynamic Client Registration with PRM-driven scope catalog, scope-merging on every authorize request (OIDC baseline + cached-token scopes + server-hinted scopes), and post-step-up permission-denied detection. See `docs/oauth.md`.
25
+ - `slack_agents.oauth/` package — signed-state codec, HKDF/AES-GCM crypto, callback listener, ephemeral auth-prompt builder, `DBTokenStorage` bridging the MCP SDK's `TokenStorage` Protocol to the agent's storage backend.
26
+ - `oauth_tokens` and `oauth_clients` tables on both SQLite and PostgreSQL backends (created idempotently at startup).
27
+ - `FrameworkContext` injection in `load_plugin` — providers that declare a `framework_ctx` parameter receive a shared object holding the bot token, Slack client, storage backend, and OAuth pending-flows registry. Existing providers are unaffected.
28
+ - `validate_oauth_env(tools_config)` consolidated startup check. Required env vars when at least one `mcp_http_oauth` provider is configured: `OAUTH_PUBLIC_URL`, `OAUTH_SECRET_KEY`. Optional: `OAUTH_BIND_HOST` (default `0.0.0.0`), `OAUTH_BIND_PORT` (default `8080`). Missing/malformed values produce a single, actionable error and refuse to start.
29
+ - Eager-auth pre-LLM hook in `SlackAgent` — each user's first message triggers OAuth setup before the LLM is invoked, so the LLM sees real tool lists rather than an empty/placeholder set.
30
+ - `make_tool_error(...)` helper plus `ERROR_*` and `RECOVERY_*` constants in `slack_agents.tools.base`. Uniform JSON schema for tool-error payloads in `ToolResult.content` so the LLM consuming a tool result can reason about errors (permission_denied / system_error / input_error / auth_setup_failed) uniformly. The `recovery` enum (retry / contact_admin / contact_support / abort) drives how the LLM should advise the user. See `docs/tools.md` "Tool error schema".
31
+ - LLM error classification in `slack/agent.py` — transient provider errors (`overloaded_error`, `rate_limit_error`, `api_error`, `timeout_error`) produce friendly user messages and log at WARNING (no traceback noise); configuration errors (`authentication_error`, `permission_error`, `not_found_error`, `invalid_request_error`) stay ERROR with full traceback.
32
+ - `cryptography` runtime dependency (HKDF + AES-GCM).
33
+ - `docs/oauth.md` — operator guide, scope-handling story, MCP SDK workarounds, troubleshooting (Trusted Hosts and Allowed Client Scopes policy gates, post-step-up permission denial).
34
+
35
+ ### Changed
36
+
37
+ - Every built-in tool error site (`mcp_http`, `mcp_http_oauth`, `canvas`, `user_context`, `file_exporter`, plus `agent_loop`'s unknown-tool fallback) now emits the unified structured-error JSON schema in `ToolResult.content` when `is_error=True`. Custom tools should use `make_tool_error(...)`.
38
+ - The Slack-user-facing message on unrecognized exceptions ("Sorry, I encountered an error processing your request.") is now the fallback only — known LLM-provider errors get specific messages instead.
39
+ - `docs/tools.md` — example uses `make_tool_error` for the unknown-tool branch; new "Tool error schema" section.
40
+
9
41
  ## [0.7.0] - 2026-04-14
10
42
 
11
43
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-slack-agents
3
- Version: 0.7.0
3
+ Version: 0.8.1
4
4
  Summary: A Python framework for deploying AI agents as Slack bots
5
5
  Project-URL: Homepage, https://github.com/CompareNetworks/python-slack-agents
6
6
  Project-URL: Repository, https://github.com/CompareNetworks/python-slack-agents
@@ -21,6 +21,7 @@ Requires-Dist: aiohttp<4,>=3.9
21
21
  Requires-Dist: aiosqlite<1,>=0.20
22
22
  Requires-Dist: anthropic<1,>=0.40
23
23
  Requires-Dist: asyncpg<1,>=0.30
24
+ Requires-Dist: cryptography<46,>=42
24
25
  Requires-Dist: fpdf2<3,>=2.8
25
26
  Requires-Dist: httpx<1,>=0.27
26
27
  Requires-Dist: mcp<2,>=1.0
@@ -63,7 +64,7 @@ Each agent is a directory with two files: a `config.yaml` and a `system_prompt.t
63
64
 
64
65
  **LLM providers** — Anthropic and OpenAI built in, plus any OpenAI-compatible API (Mistral, Groq, Together, Ollama, vLLM). Extend to any other provider by implementing a simple base class.
65
66
 
66
- **Tool calling with MCP** — Connect any [MCP server](https://modelcontextprotocol.io/) over HTTP. Tools are discovered automatically, executed in parallel, and filtered with regex patterns. No tool registration boilerplate.
67
+ **Tool calling with MCP** — Connect any [MCP server](https://modelcontextprotocol.io/) over HTTP. Tools are discovered automatically, executed in parallel, and filtered with regex patterns. No tool registration boilerplate. **OAuth-protected MCP servers are supported with per-Slack-user tokens** — see [docs/oauth.md](docs/oauth.md).
67
68
 
68
69
  **File handling** — Agents can read files your users upload (PDF, DOCX, XLSX, PPTX, CSV, images) and generate documents back (PDF, DOCX, XLSX, CSV, PPTX). All built in, no extra setup.
69
70
 
@@ -241,6 +242,18 @@ tools:
241
242
  - "get_document" # exact match works too
242
243
  ```
243
244
 
245
+ For MCP servers that require OAuth 2.1 (per-user authentication), use `mcp_http_oauth` — same auto-discovery, with each Slack user authenticating separately to the upstream service:
246
+
247
+ ```yaml
248
+ tools:
249
+ my-oauth-mcp:
250
+ type: slack_agents.tools.mcp_http_oauth
251
+ url: "https://my-server.example.com/mcp"
252
+ allowed_functions: [".*"]
253
+ ```
254
+
255
+ Tokens are persisted per Slack user, refresh tokens are AES-GCM-encrypted at rest, and an in-process callback listener handles the OAuth dance alongside the Slack Bolt connection. See [docs/oauth.md](https://github.com/CompareNetworks/python-slack-agents/blob/main/docs/oauth.md) for setup (env vars, tunnel for local dev, scope handling, troubleshooting).
256
+
244
257
  ## Project Structure
245
258
 
246
259
  Your overlay is a plain git repo — not a Python package. You edit configs, commit, and run; there's no
@@ -326,6 +339,7 @@ To create a Slack app, use the manifest in [`docs/slack-app-manifest.json`](http
326
339
  - [Setup](https://github.com/CompareNetworks/python-slack-agents/blob/main/docs/setup.md) — installation and Slack app creation
327
340
  - [Agents](https://github.com/CompareNetworks/python-slack-agents/blob/main/docs/agents.md) — creating and configuring agents
328
341
  - [Tools](https://github.com/CompareNetworks/python-slack-agents/blob/main/docs/tools.md) — MCP servers and custom tool providers
342
+ - [OAuth-protected MCP servers](https://github.com/CompareNetworks/python-slack-agents/blob/main/docs/oauth.md) — per-Slack-user OAuth for MCP servers requiring authentication
329
343
  - [LLM](https://github.com/CompareNetworks/python-slack-agents/blob/main/docs/llm.md) — supported providers and adding your own
330
344
  - [Storage](https://github.com/CompareNetworks/python-slack-agents/blob/main/docs/storage.md) — SQLite, PostgreSQL, and custom backends
331
345
  - [Access control](https://github.com/CompareNetworks/python-slack-agents/blob/main/docs/access-control.md) — controlling who can use an agent
@@ -17,7 +17,7 @@ Each agent is a directory with two files: a `config.yaml` and a `system_prompt.t
17
17
 
18
18
  **LLM providers** — Anthropic and OpenAI built in, plus any OpenAI-compatible API (Mistral, Groq, Together, Ollama, vLLM). Extend to any other provider by implementing a simple base class.
19
19
 
20
- **Tool calling with MCP** — Connect any [MCP server](https://modelcontextprotocol.io/) over HTTP. Tools are discovered automatically, executed in parallel, and filtered with regex patterns. No tool registration boilerplate.
20
+ **Tool calling with MCP** — Connect any [MCP server](https://modelcontextprotocol.io/) over HTTP. Tools are discovered automatically, executed in parallel, and filtered with regex patterns. No tool registration boilerplate. **OAuth-protected MCP servers are supported with per-Slack-user tokens** — see [docs/oauth.md](docs/oauth.md).
21
21
 
22
22
  **File handling** — Agents can read files your users upload (PDF, DOCX, XLSX, PPTX, CSV, images) and generate documents back (PDF, DOCX, XLSX, CSV, PPTX). All built in, no extra setup.
23
23
 
@@ -195,6 +195,18 @@ tools:
195
195
  - "get_document" # exact match works too
196
196
  ```
197
197
 
198
+ For MCP servers that require OAuth 2.1 (per-user authentication), use `mcp_http_oauth` — same auto-discovery, with each Slack user authenticating separately to the upstream service:
199
+
200
+ ```yaml
201
+ tools:
202
+ my-oauth-mcp:
203
+ type: slack_agents.tools.mcp_http_oauth
204
+ url: "https://my-server.example.com/mcp"
205
+ allowed_functions: [".*"]
206
+ ```
207
+
208
+ Tokens are persisted per Slack user, refresh tokens are AES-GCM-encrypted at rest, and an in-process callback listener handles the OAuth dance alongside the Slack Bolt connection. See [docs/oauth.md](https://github.com/CompareNetworks/python-slack-agents/blob/main/docs/oauth.md) for setup (env vars, tunnel for local dev, scope handling, troubleshooting).
209
+
198
210
  ## Project Structure
199
211
 
200
212
  Your overlay is a plain git repo — not a Python package. You edit configs, commit, and run; there's no
@@ -280,6 +292,7 @@ To create a Slack app, use the manifest in [`docs/slack-app-manifest.json`](http
280
292
  - [Setup](https://github.com/CompareNetworks/python-slack-agents/blob/main/docs/setup.md) — installation and Slack app creation
281
293
  - [Agents](https://github.com/CompareNetworks/python-slack-agents/blob/main/docs/agents.md) — creating and configuring agents
282
294
  - [Tools](https://github.com/CompareNetworks/python-slack-agents/blob/main/docs/tools.md) — MCP servers and custom tool providers
295
+ - [OAuth-protected MCP servers](https://github.com/CompareNetworks/python-slack-agents/blob/main/docs/oauth.md) — per-Slack-user OAuth for MCP servers requiring authentication
283
296
  - [LLM](https://github.com/CompareNetworks/python-slack-agents/blob/main/docs/llm.md) — supported providers and adding your own
284
297
  - [Storage](https://github.com/CompareNetworks/python-slack-agents/blob/main/docs/storage.md) — SQLite, PostgreSQL, and custom backends
285
298
  - [Access control](https://github.com/CompareNetworks/python-slack-agents/blob/main/docs/access-control.md) — controlling who can use an agent
@@ -0,0 +1,292 @@
1
+ # OAuth-protected MCP servers
2
+
3
+ `slack_agents.tools.mcp_http_oauth` connects to MCP servers that require OAuth 2.1
4
+ authentication, with **per-Slack-user tokens**: each user authenticates separately,
5
+ and the agent uses that user's access token when calling tools on their behalf.
6
+
7
+ This complements `slack_agents.tools.mcp_http`, which is for servers that issue
8
+ long-lived API keys you put in YAML headers. If your MCP server speaks the MCP
9
+ authorization spec (Dynamic Client Registration + auth-code + PKCE), use
10
+ `mcp_http_oauth`.
11
+
12
+ ## Configuration
13
+
14
+ ```yaml
15
+ tools:
16
+ my-mcp:
17
+ type: slack_agents.tools.mcp_http_oauth
18
+ url: "https://my-server.example.com/mcp"
19
+ allowed_functions: [".*"]
20
+ init_retries: [5, 10, 30] # optional
21
+ auth_timeout: 300 # optional, seconds, default 300
22
+ ```
23
+
24
+ Only `url` and `allowed_functions` are required. There is intentionally no
25
+ `client_id` / `client_secret` / `scopes` field — the provider performs Dynamic
26
+ Client Registration against the MCP server's authorization server, registers
27
+ with whatever scopes the server's PRM document advertises, and discovers
28
+ runtime scopes through standard 401/403 step-up challenges.
29
+
30
+ ## Required environment variables
31
+
32
+ These are validated at startup. If any `mcp_http_oauth` provider is configured
33
+ and any of these are missing or malformed, the agent refuses to start with a
34
+ single consolidated error message.
35
+
36
+ | Variable | Required | Default | Description |
37
+ |---|---|---|---|
38
+ | `OAUTH_PUBLIC_URL` | yes | — | Externally reachable base URL of this agent process. Must be `https://`, or `http://` with a loopback host (`localhost`, `127.0.0.1`, `[::1]`) for local dev. |
39
+ | `OAUTH_SECRET_KEY` | yes | — | Root key for HKDF; ≥32 bytes after base64 decode. Used to sign OAuth state tokens and encrypt refresh tokens at rest. |
40
+ | `OAUTH_BIND_HOST` | no | `0.0.0.0` | Interface the in-process callback listener binds to. |
41
+ | `OAUTH_BIND_PORT` | no | `8080` | TCP port for the callback listener. |
42
+
43
+ ### Generating `OAUTH_SECRET_KEY`
44
+
45
+ ```bash
46
+ openssl rand -base64 32
47
+ ```
48
+
49
+ or
50
+
51
+ ```bash
52
+ python3 -c "import secrets; print(secrets.token_urlsafe(32))"
53
+ ```
54
+
55
+ Treat this value like any other long-lived secret: keep it out of source
56
+ control, rotate it the same way you rotate database credentials. Rotating it
57
+ forces every user to re-authenticate but does not break the agent.
58
+
59
+ ## Local development
60
+
61
+ OAuth callbacks need a URL the user's browser can reach. For local dev use a
62
+ tunnel (ngrok, cloudflared, tailscale funnel, etc.):
63
+
64
+ ```bash
65
+ # Terminal 1 — start the tunnel pointing at your bind port:
66
+ ngrok http 8080
67
+ # → forwards https://abcd-1234.ngrok-free.app to localhost:8080
68
+
69
+ # Terminal 2 — set env vars and run the agent:
70
+ export OAUTH_PUBLIC_URL=https://abcd-1234.ngrok-free.app
71
+ export OAUTH_SECRET_KEY=$(openssl rand -base64 32)
72
+ slack-agents run agents/my-agent
73
+ ```
74
+
75
+ If you'd rather not use a tunnel, you can run with
76
+ `OAUTH_PUBLIC_URL=http://localhost:8080` — the validator allows loopback
77
+ addresses over plain HTTP per RFC 8252.
78
+
79
+ ## What a Slack user sees
80
+
81
+ 1. They ask the bot to do something that needs OAuth-protected tools.
82
+ 2. The bot replies with an ephemeral message in the same thread (visible only to
83
+ them) containing an "Authenticate" button.
84
+ 3. They click; the browser opens the upstream service's login page.
85
+ 4. They log in and click Allow.
86
+ 5. The browser shows "Authentication completed — you can close this tab and
87
+ return to Slack."
88
+ 6. Slack shows a brief "✅ Authenticated to *server*" ephemeral, the agent
89
+ picks up the new token, runs tool discovery, and the conversation continues
90
+ normally.
91
+
92
+ If they don't click within the configured `auth_timeout` (default 5 minutes),
93
+ the agent surfaces a "timed out — please try again" error and the tool call
94
+ ends. They can re-ask whenever they're ready and a fresh prompt appears.
95
+
96
+ If the upstream tool later requires additional permissions (e.g. they had read
97
+ access but are now trying to write), the same flow re-runs requesting the
98
+ broader scope. Most identity providers auto-collapse the consent screen if the
99
+ broader scope is a superset of what they've already approved.
100
+
101
+ If the user's account doesn't actually have the role the upstream needs (so
102
+ the IdP silently issues a token without the requested scope), the agent
103
+ detects this on the next tool call and surfaces a clear permission-denied
104
+ message naming the specific missing scope — rather than a generic error.
105
+
106
+ ## How scopes work
107
+
108
+ Three scope-related decisions happen at different times, with different
109
+ sources of truth. Knowing which is which makes troubleshooting much easier.
110
+
111
+ ### 1. DCR registration scope (one-time, per server)
112
+
113
+ When the agent first encounters an OAuth-protected MCP server, it does
114
+ Dynamic Client Registration with the server's authorization server. The
115
+ client registration declares **all the scopes this client could ever
116
+ legitimately request** — this is the catalog, not the per-call request.
117
+
118
+ The agent uses **PRM `scopes_supported`** as that catalog: it pre-fetches
119
+ `/.well-known/oauth-protected-resource` from the resource server and
120
+ registers with exactly that list. No heuristics, no extrapolation.
121
+
122
+ This means the **resource server's PRM document must advertise every scope
123
+ that any of its tools might ever require**, not just the default tier. If
124
+ PRM only advertises `mcp:foo:read` but a tool returns a 403 demanding
125
+ `mcp:foo:write`, the registered client was never permitted to request
126
+ `mcp:foo:write` and the step-up will fail with `invalid_scope`.
127
+
128
+ ### 2. Per-request authorize scope (every tool call)
129
+
130
+ For each authorize request to the IdP (initial auth and step-up alike), the
131
+ agent computes the union of three sources:
132
+
133
+ ```
134
+ authorize_scope =
135
+ {openid, offline_access} # OIDC protocol baseline (always)
136
+ ∪ scopes from the user's currently-cached token
137
+ ∪ scopes the server hinted in `WWW-Authenticate scope=`
138
+ ```
139
+
140
+ The OIDC baseline is added by the agent unconditionally — without `openid`
141
+ the IdP issues a non-OIDC token (no identity claims) and without
142
+ `offline_access` no refresh token is issued (forcing fresh auth on every
143
+ token expiry). The cached scopes preserve what's already been granted, so
144
+ step-up never accidentally narrows what the user has. The hint from
145
+ `WWW-Authenticate` is what the resource server *just* asked for, this call.
146
+
147
+ The resource server can be stateless: it can return either the cumulative
148
+ set the user now needs (`scope="mcp:foo:read mcp:foo:write"`) or just the
149
+ delta scope for this call (`scope="mcp:foo:write"`). The client merges with
150
+ its own state either way.
151
+
152
+ ### 3. What the token actually grants (decided by the IdP)
153
+
154
+ After the user consents, the IdP issues a token with whatever scopes their
155
+ roles actually permit — it may silently drop scopes the user can't have.
156
+ The agent compares the post-step-up token's scope against the server's
157
+ demand. If a required scope wasn't granted, the agent surfaces a clean
158
+ permission-denied error naming the specific missing scope — instead of
159
+ retrying forever or surfacing the upstream 403 verbatim.
160
+
161
+ ### Resource server expectations
162
+
163
+ For the agent to behave correctly out of the box, your MCP server should:
164
+
165
+ - **PRM `/.well-known/oauth-protected-resource`** should advertise every
166
+ scope its tools might require, including step-up scopes (e.g. read AND
167
+ write AND admin), not just the default tier.
168
+ - **401 responses** (no token at all) should include `scope=` with the
169
+ minimum needed to use the resource (typically the read-equivalent).
170
+ - **403 responses** (token with insufficient scope) should include
171
+ `WWW-Authenticate: Bearer error="insufficient_scope" scope="…"` per RFC
172
+ 9470, naming the scope(s) needed for this specific call. The server can
173
+ return either the cumulative set or the delta — the client tolerates both.
174
+ - **OIDC scopes** (`openid`, `offline_access`) don't need to appear in
175
+ either header — the client always adds them on its own.
176
+
177
+ ## Token storage
178
+
179
+ Tokens are persisted via the agent's normal storage backend (SQLite or
180
+ Postgres) in two new tables:
181
+
182
+ - `oauth_tokens` — per (user_id, server_id) access token + encrypted refresh
183
+ token, scopes, expiry.
184
+ - `oauth_clients` — per server_id Dynamic Client Registration result, shared
185
+ across all users connecting to that server through this agent.
186
+
187
+ Refresh tokens are AES-GCM-encrypted at rest using a subkey derived from
188
+ `OAUTH_SECRET_KEY` via HKDF. Access tokens are short-lived and stored
189
+ plaintext (still in the private DB).
190
+
191
+ ## Troubleshooting
192
+
193
+ **"Configuration error: ... OAUTH_PUBLIC_URL is not set"** — set the env vars
194
+ listed in the message and restart.
195
+
196
+ **"Authentication timed out"** — the user didn't click the link within
197
+ `auth_timeout`. They can re-ask the bot whenever they're ready.
198
+
199
+ **"<server> does not support dynamic client registration"** — the upstream
200
+ authorization server doesn't speak RFC 7591, or it has a Client Registration
201
+ Policy that rejects requests from your agent's host. Common Keycloak gates:
202
+
203
+ - *Trusted Hosts* policy — the realm admin must add your agent's
204
+ externally-reachable host to the trusted-hosts list.
205
+ - A CDN/WAF in front of the IdP — some Cloudflare bot-management rules block
206
+ anonymous DCR requests; the realm admin needs an exception for the
207
+ `/clients-registrations/openid-connect` endpoint.
208
+
209
+ This provider is DCR-only by design. Static pre-registered client credentials
210
+ are not currently supported.
211
+
212
+ **`invalid_scope` on step-up after a successful first auth** — the
213
+ DCR-registered client doesn't have the requested scope in its allowed-request
214
+ list, even though it would have been included in the registration request.
215
+ This is Keycloak's *Allowed Client Scopes* policy under
216
+ `Realm Settings → Client Registration → Anonymous Access Policies`: the
217
+ realm silently filters DCR registration scope to a permitted subset. The
218
+ realm admin must add the missing scope (e.g. `mcp:foo:write`) to that
219
+ policy. Verify what the realm actually registered for your client by querying
220
+ the local DB:
221
+
222
+ ```bash
223
+ sqlite3 /tmp/<agent>.slack-agents.db \
224
+ "SELECT json_extract(metadata_json, '\$.scope') FROM oauth_clients WHERE server_id='<server>';"
225
+ ```
226
+
227
+ If a scope is missing here, the policy filtered it out at DCR time.
228
+
229
+ **"This action cannot be run on `<server>`: your account does not have a role
230
+ granting the required scope"** — the IdP issued a token but silently dropped
231
+ the requested scope because the user's role doesn't include it. The user (or
232
+ their admin) needs to grant the missing scope at the role level. The agent
233
+ names the specific missing scope in the message.
234
+
235
+ **"You declined access"** — the user clicked Deny on the consent screen, or
236
+ the IdP returned `error=access_denied`. They can re-ask to retry.
237
+
238
+ **Tokens disappear after a key rotation** — expected. Rotating
239
+ `OAUTH_SECRET_KEY` invalidates all stored refresh tokens (the agent detects
240
+ this on the first read and deletes the row, then prompts for fresh auth on the
241
+ next call).
242
+
243
+ ## Implementation notes
244
+
245
+ - The in-process callback listener runs alongside the Slack Bolt connection
246
+ (same asyncio loop, same process). It only listens when at least one
247
+ `mcp_http_oauth` provider is configured.
248
+ - The listener exposes exactly two routes: `/oauth/start/{signed_state}` and
249
+ `/oauth/callback`. Anything else returns 404.
250
+ - OAuth state is signed (HMAC-SHA256) and includes a single-use nonce; replays
251
+ are rejected.
252
+ - Restarting the agent during a pending auth flow drops that flow — the user
253
+ re-asks and gets a fresh prompt. Persistent mid-flow recovery is intentionally
254
+ not implemented.
255
+
256
+ ### MCP SDK workarounds
257
+
258
+ The provider patches around four behaviors of the `mcp` Python SDK
259
+ (`mcp.client.auth`) at the time of writing. When the SDK addresses any of
260
+ these upstream, the corresponding shim can be removed:
261
+
262
+ 1. **Pre-DCR with full PRM scope set.** The SDK's `async_auth_flow`
263
+ overwrites `client_metadata.scope` with the runtime authorize scope
264
+ (`get_client_metadata_scopes`) *before* running DCR. If we let the SDK do
265
+ DCR, the registered client gets only "what's needed for the current
266
+ operation," not the full catalog, and step-up later fails with
267
+ `invalid_scope`. We do DCR ourselves with PRM's `scopes_supported`,
268
+ persist the result, and the SDK's `if not self.context.client_info: …
269
+ register …` branch is skipped.
270
+ 2. **Discovery on every fresh `OAuthClientProvider`.** The SDK's 403 step-up
271
+ path calls `_perform_authorization()` without first running protected-
272
+ resource discovery, so a freshly constructed provider falls back to
273
+ `urljoin(server_url, "/authorize")` (wrong when the AS is on a different
274
+ host). We pre-populate `oauth.context.protected_resource_metadata` and
275
+ `oauth_metadata` from a per-Provider cache.
276
+ 3. **`token_expiry_time` not propagated from storage.** The SDK's
277
+ `_initialize` loads `current_tokens` from storage but doesn't set
278
+ `token_expiry_time`, so `is_token_valid()` returns True for any cached
279
+ token regardless of actual expiry — meaning a stale token is sent at
280
+ restart, the server returns 401, the SDK skips the refresh-token branch
281
+ entirely, and the user is re-prompted. We force `_initialize` plus
282
+ `update_token_expiry(tokens)` after construction so refresh works
283
+ silently across restarts.
284
+ 4. **Scope merge on `WWW-Authenticate`.** The SDK uses the server's
285
+ `WWW-Authenticate scope=` value verbatim, dropping anything the cached
286
+ token already had and the OIDC baseline. We attach an httpx response
287
+ hook to every MCP request that augments the header in place to be the
288
+ union of `{openid, offline_access}` ∪ cached-token scopes ∪ server-hinted
289
+ scopes, so the SDK's verbatim use produces the correct cumulative set.
290
+
291
+ Each shim is annotated in the source with a comment explaining the SDK
292
+ behavior it works around.
@@ -13,7 +13,13 @@ Tool providers give the LLM callable tools. Extend `BaseToolProvider`:
13
13
 
14
14
  ```python
15
15
  # my_tools/calculator.py
16
- from slack_agents.tools.base import BaseToolProvider, ToolResult
16
+ from slack_agents.tools.base import (
17
+ ERROR_INPUT_ERROR,
18
+ RECOVERY_ABORT,
19
+ BaseToolProvider,
20
+ ToolResult,
21
+ make_tool_error,
22
+ )
17
23
 
18
24
  class Provider(BaseToolProvider):
19
25
  def __init__(self, allowed_functions: list[str]):
@@ -39,7 +45,13 @@ class Provider(BaseToolProvider):
39
45
  if name == "add":
40
46
  result = arguments["a"] + arguments["b"]
41
47
  return {"content": str(result), "is_error": False, "files": []}
42
- return {"content": f"Unknown tool: {name}", "is_error": True, "files": []}
48
+ return make_tool_error(
49
+ error=ERROR_INPUT_ERROR,
50
+ code="unknown_tool",
51
+ tool=name,
52
+ recovery=RECOVERY_ABORT,
53
+ message=f"Tool {name!r} is not provided by this calculator.",
54
+ )
43
55
  ```
44
56
 
45
57
  ### Key points
@@ -49,6 +61,7 @@ class Provider(BaseToolProvider):
49
61
  - `call_tool(name, arguments, user_context, storage)` returns a `ToolResult` (`{"content": str, "is_error": bool, "files": list[OutputFile]}`)
50
62
  - Files in the response are uploaded to Slack automatically
51
63
  - `initialize()` and `close()` are optional lifecycle hooks
64
+ - For error returns, use `make_tool_error(...)` so the LLM gets a structured payload it can interpret consistently. See [Tool error schema](#tool-error-schema) below.
52
65
 
53
66
  ## File Importer Providers
54
67
 
@@ -124,6 +137,82 @@ tools:
124
137
 
125
138
  All MCP tool providers are initialized in parallel at startup. If any provider fails to connect after exhausting its retries, the agent exits with an error.
126
139
 
140
+ ## MCP over HTTP with OAuth
141
+
142
+ `slack_agents.tools.mcp_http_oauth` is the OAuth-authenticated counterpart to
143
+ `mcp_http` — for MCP servers that require per-user OAuth 2.1 authentication
144
+ rather than a static API key. See [`docs/oauth.md`](oauth.md) for the full
145
+ guide. Minimal example:
146
+
147
+ ```yaml
148
+ tools:
149
+ my-mcp:
150
+ type: slack_agents.tools.mcp_http_oauth
151
+ url: "https://my-server.example.com/mcp"
152
+ allowed_functions: [".*"]
153
+ ```
154
+
155
+ This pulls in extra runtime requirements: `OAUTH_PUBLIC_URL`,
156
+ `OAUTH_SECRET_KEY`, and an in-process HTTP listener for OAuth callbacks. Read
157
+ the OAuth doc before configuring this in production.
158
+
159
+ ## Tool error schema
160
+
161
+ When a tool returns `is_error: True`, every built-in tool emits a JSON-encoded
162
+ payload in `content` matching this shape, so the LLM consuming the result can
163
+ reason about the failure uniformly. Custom tools should produce the same shape
164
+ via the `make_tool_error` helper.
165
+
166
+ ```json
167
+ {
168
+ "error": "<type>", // required: e.g. "permission_denied"
169
+ "code": "<subtype>", // optional: stable per-error sub-classification
170
+ "tool": "<tool name>", // optional: which tool was being called
171
+ "server": "<server id>", // optional: provider/server context
172
+ "message": "<human>", // required: human-readable summary
173
+ "recovery": "<action>", // required: see below
174
+ "details": { ... } // optional: free-form per-error-type
175
+ }
176
+ ```
177
+
178
+ **Top-level error types** (`error`):
179
+
180
+ | Constant | When to use |
181
+ |---|---|
182
+ | `ERROR_PERMISSION_DENIED` | Auth/scope/role refusal — user-level |
183
+ | `ERROR_SYSTEM_ERROR` | Operational/library/transient failure |
184
+ | `ERROR_AUTH_SETUP_FAILED` | Auth flow itself broke (timeout, prompt failure) |
185
+ | `ERROR_INPUT_ERROR` | Bad call / unknown tool / bad arguments |
186
+
187
+ **Recovery actions** (`recovery`):
188
+
189
+ | Constant | Meaning |
190
+ |---|---|
191
+ | `RECOVERY_RETRY` | Transient or user-recoverable; just try again |
192
+ | `RECOVERY_CONTACT_ADMIN` | Requires realm/IdP/account admin |
193
+ | `RECOVERY_CONTACT_SUPPORT` | Framework operator/dev needs to investigate logs |
194
+ | `RECOVERY_ABORT` | Nothing to do for this call (LLM may try a different tool) |
195
+
196
+ **Helper signature:**
197
+
198
+ ```python
199
+ from slack_agents.tools.base import make_tool_error # plus ERROR_*, RECOVERY_*
200
+
201
+ return make_tool_error(
202
+ error=ERROR_SYSTEM_ERROR, # required
203
+ message="Server returned 502.", # required
204
+ recovery=RECOVERY_RETRY, # required
205
+ code="upstream_502", # optional
206
+ tool="search_docs", # optional
207
+ server="my-mcp", # optional
208
+ details={"status": 502}, # optional, schema-less
209
+ )
210
+ ```
211
+
212
+ `details` is intentionally schema-less — each error type can carry whatever
213
+ structured fields the LLM benefits from seeing (missing scopes, exception
214
+ types, timestamps for support correlation, etc.).
215
+
127
216
  ## Configuration
128
217
 
129
218
  Both types are configured the same way in `config.yaml`: