python-slack-agents 0.8.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 (131) hide show
  1. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/CHANGELOG.md +12 -1
  2. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/PKG-INFO +1 -1
  3. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/pyproject.toml +1 -1
  4. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/oauth/storage.py +5 -3
  5. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/storage/base.py +10 -4
  6. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/storage/postgres.py +19 -7
  7. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/storage/postgres.sql +4 -2
  8. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/storage/sqlite.py +17 -7
  9. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/storage/sqlite.sql +4 -2
  10. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/tools/mcp_http_oauth.py +59 -5
  11. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_mcp_http_oauth.py +155 -0
  12. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_oauth_storage.py +76 -8
  13. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_storage_oauth.py +38 -4
  14. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/uv.lock +1 -1
  15. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/.dockerignore +0 -0
  16. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/.env.example +0 -0
  17. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/.github/workflows/ci.yml +0 -0
  18. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/.github/workflows/publish.yml +0 -0
  19. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/.gitignore +0 -0
  20. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/.pre-commit-config.yaml +0 -0
  21. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/AGENTS.md +0 -0
  22. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/CODE_OF_CONDUCT.md +0 -0
  23. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/CONTRIBUTING.md +0 -0
  24. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/LICENSE +0 -0
  25. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/README.md +0 -0
  26. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/SECURITY.md +0 -0
  27. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/agents/README.md +0 -0
  28. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/agents/docs-assistant/config.yaml +0 -0
  29. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/agents/docs-assistant/system_prompt.txt +0 -0
  30. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/agents/hello-world/config.yaml +0 -0
  31. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/agents/hello-world/system_prompt.txt +0 -0
  32. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/agents/kitchen-sink/config.yaml +0 -0
  33. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/agents/kitchen-sink/system_prompt.txt +0 -0
  34. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/access-control.md +0 -0
  35. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/agents.md +0 -0
  36. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/canvas.md +0 -0
  37. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/cli.md +0 -0
  38. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/deployment.md +0 -0
  39. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/llm.md +0 -0
  40. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/media/demo.gif +0 -0
  41. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/oauth.md +0 -0
  42. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/observability.md +0 -0
  43. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/private-repo.md +0 -0
  44. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/setup.md +0 -0
  45. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/slack-app-manifest.json +0 -0
  46. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/storage.md +0 -0
  47. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/tools.md +0 -0
  48. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/user-context.md +0 -0
  49. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/llms-full.txt +0 -0
  50. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/llms.txt +0 -0
  51. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/Dockerfile +0 -0
  52. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/__init__.py +0 -0
  53. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/access/__init__.py +0 -0
  54. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/access/allow_all.py +0 -0
  55. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/access/allow_list.py +0 -0
  56. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/access/base.py +0 -0
  57. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/agent_loop.py +0 -0
  58. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/cli/__init__.py +0 -0
  59. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/cli/build_docker.py +0 -0
  60. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/cli/export_conversations.py +0 -0
  61. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/cli/export_conversations_html.py +0 -0
  62. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/cli/export_usage.py +0 -0
  63. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/cli/export_usage_csv.py +0 -0
  64. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/cli/healthcheck.py +0 -0
  65. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/cli/init.py +0 -0
  66. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/cli/run.py +0 -0
  67. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/config.py +0 -0
  68. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/conversations.py +0 -0
  69. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/files.py +0 -0
  70. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/llm/__init__.py +0 -0
  71. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/llm/anthropic.py +0 -0
  72. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/llm/base.py +0 -0
  73. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/llm/openai.py +0 -0
  74. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/main.py +0 -0
  75. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/oauth/__init__.py +0 -0
  76. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/oauth/crypto.py +0 -0
  77. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/oauth/prompts.py +0 -0
  78. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/oauth/server.py +0 -0
  79. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/oauth/state.py +0 -0
  80. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/observability.py +0 -0
  81. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/py.typed +0 -0
  82. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/scripts/__init__.py +0 -0
  83. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/scripts/download_fonts.py +0 -0
  84. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/scripts/generate_llms_full.py +0 -0
  85. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/__init__.py +0 -0
  86. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/actions.py +0 -0
  87. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/agent.py +0 -0
  88. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/canvas_auth.py +0 -0
  89. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/canvases.py +0 -0
  90. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/files.py +0 -0
  91. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/format.py +0 -0
  92. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/streaming.py +0 -0
  93. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/streaming_formatter.py +0 -0
  94. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/tool_blocks.py +0 -0
  95. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/storage/__init__.py +0 -0
  96. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/tools/__init__.py +0 -0
  97. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/tools/base.py +0 -0
  98. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/tools/canvas.py +0 -0
  99. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/tools/canvas_importer.py +0 -0
  100. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/tools/file_exporter.py +0 -0
  101. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/tools/file_importer.py +0 -0
  102. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/tools/mcp_http.py +0 -0
  103. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/tools/user_context.py +0 -0
  104. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/__init__.py +0 -0
  105. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_access.py +0 -0
  106. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_agent_loop.py +0 -0
  107. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_canvas_auth.py +0 -0
  108. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_canvas_importer.py +0 -0
  109. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_cli.py +0 -0
  110. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_config.py +0 -0
  111. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_conversations.py +0 -0
  112. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_cost.py +0 -0
  113. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_export_documents.py +0 -0
  114. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_export_usage.py +0 -0
  115. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_file_extractors.py +0 -0
  116. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_format.py +0 -0
  117. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_init.py +0 -0
  118. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_llm_error_classification.py +0 -0
  119. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_llm_factory.py +0 -0
  120. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_load_plugin.py +0 -0
  121. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_mcp_client.py +0 -0
  122. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_oauth_crypto.py +0 -0
  123. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_oauth_integration.py +0 -0
  124. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_oauth_prompts.py +0 -0
  125. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_oauth_server.py +0 -0
  126. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_oauth_state.py +0 -0
  127. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_oauth_validation.py +0 -0
  128. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_openai_convert.py +0 -0
  129. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_overlay_integration.py +0 -0
  130. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_tool_blocks.py +0 -0
  131. {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_tool_errors.py +0 -0
@@ -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,17 @@ 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
+
9
20
  ## [0.8.0] - 2026-05-06
10
21
 
11
22
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-slack-agents
3
- Version: 0.8.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-slack-agents"
7
- version = "0.8.0"
7
+ version = "0.8.1"
8
8
  description = "A Python framework for deploying AI agents as Slack bots"
9
9
  authors = [{name = "Eric Pichon", email = "epichon@comparenetworks.com"}]
10
10
  readme = "README.md"
@@ -39,11 +39,13 @@ class DBTokenStorage:
39
39
  backend: BaseStorageProvider,
40
40
  user_id: str,
41
41
  server_id: str,
42
+ redirect_uri: str,
42
43
  token_key: bytes,
43
44
  ) -> None:
44
45
  self._backend = backend
45
46
  self._user_id = user_id
46
47
  self._server_id = server_id
48
+ self._redirect_uri = redirect_uri
47
49
  self._token_key = token_key
48
50
 
49
51
  async def get_tokens(self) -> OAuthToken | None:
@@ -102,14 +104,14 @@ class DBTokenStorage:
102
104
  await self._backend.put_oauth_token(self._user_id, self._server_id, row)
103
105
 
104
106
  async def get_client_info(self) -> OAuthClientInformationFull | None:
105
- row = await self._backend.get_oauth_client(self._server_id)
107
+ row = await self._backend.get_oauth_client(self._server_id, self._redirect_uri)
106
108
  if row is None:
107
109
  return None
108
110
  return OAuthClientInformationFull.model_validate_json(row.metadata_json)
109
111
 
110
112
  async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
111
113
  now = int(time.time())
112
- existing = await self._backend.get_oauth_client(self._server_id)
114
+ existing = await self._backend.get_oauth_client(self._server_id, self._redirect_uri)
113
115
  created_at = existing.created_at if existing else now
114
116
  row = OAuthClientRow(
115
117
  client_id=client_info.client_id,
@@ -119,4 +121,4 @@ class DBTokenStorage:
119
121
  created_at=created_at,
120
122
  updated_at=now,
121
123
  )
122
- await self._backend.put_oauth_client(self._server_id, row)
124
+ await self._backend.put_oauth_client(self._server_id, self._redirect_uri, row)
@@ -94,12 +94,18 @@ class BaseStorageProvider(ABC):
94
94
  """Delete the token row for ``(user_id, server_id)`` if present."""
95
95
 
96
96
  @abstractmethod
97
- async def get_oauth_client(self, server_id: str) -> OAuthClientRow | None:
98
- """Return the persisted dynamic-client-registration row for ``server_id``."""
97
+ async def get_oauth_client(self, server_id: str, redirect_uri: str) -> OAuthClientRow | None:
98
+ """Return the persisted DCR row for ``(server_id, redirect_uri)`` or None."""
99
99
 
100
100
  @abstractmethod
101
- async def put_oauth_client(self, server_id: str, row: OAuthClientRow) -> None:
102
- """Upsert the dynamic-client-registration row for ``server_id``."""
101
+ async def put_oauth_client(
102
+ self, server_id: str, redirect_uri: str, row: OAuthClientRow
103
+ ) -> None:
104
+ """Upsert the DCR row for ``(server_id, redirect_uri)``."""
105
+
106
+ @abstractmethod
107
+ async def delete_oauth_client(self, server_id: str, redirect_uri: str) -> None:
108
+ """Delete the DCR row for ``(server_id, redirect_uri)`` if present."""
103
109
 
104
110
  async def close(self) -> None:
105
111
  """Close connections and clean up resources."""
@@ -670,13 +670,14 @@ class Provider(BaseStorageProvider):
670
670
  server_id,
671
671
  )
672
672
 
673
- async def get_oauth_client(self, server_id: str) -> OAuthClientRow | None:
673
+ async def get_oauth_client(self, server_id: str, redirect_uri: str) -> OAuthClientRow | None:
674
674
  async with self._pool.acquire() as conn:
675
675
  row = await conn.fetchrow(
676
676
  "SELECT client_id, client_secret, metadata_json, "
677
677
  "authorization_server, created_at, updated_at "
678
- "FROM oauth_clients WHERE server_id=$1",
678
+ "FROM oauth_clients WHERE server_id=$1 AND redirect_uri=$2",
679
679
  server_id,
680
+ redirect_uri,
680
681
  )
681
682
  if row is None:
682
683
  return None
@@ -689,20 +690,23 @@ class Provider(BaseStorageProvider):
689
690
  updated_at=row["updated_at"],
690
691
  )
691
692
 
692
- async def put_oauth_client(self, server_id: str, row: OAuthClientRow) -> None:
693
+ async def put_oauth_client(
694
+ self, server_id: str, redirect_uri: str, row: OAuthClientRow
695
+ ) -> None:
693
696
  async with self._pool.acquire() as conn:
694
697
  await conn.execute(
695
698
  "INSERT INTO oauth_clients ("
696
- "server_id, client_id, client_secret, metadata_json, "
697
- "authorization_server, created_at, updated_at"
698
- ") VALUES ($1, $2, $3, $4, $5, $6, $7) "
699
- "ON CONFLICT (server_id) DO UPDATE SET "
699
+ "server_id, redirect_uri, client_id, client_secret, "
700
+ "metadata_json, authorization_server, created_at, updated_at"
701
+ ") VALUES ($1, $2, $3, $4, $5, $6, $7, $8) "
702
+ "ON CONFLICT (server_id, redirect_uri) DO UPDATE SET "
700
703
  "client_id=EXCLUDED.client_id, "
701
704
  "client_secret=EXCLUDED.client_secret, "
702
705
  "metadata_json=EXCLUDED.metadata_json, "
703
706
  "authorization_server=EXCLUDED.authorization_server, "
704
707
  "updated_at=EXCLUDED.updated_at",
705
708
  server_id,
709
+ redirect_uri,
706
710
  row.client_id,
707
711
  row.client_secret,
708
712
  row.metadata_json,
@@ -710,3 +714,11 @@ class Provider(BaseStorageProvider):
710
714
  row.created_at,
711
715
  row.updated_at,
712
716
  )
717
+
718
+ async def delete_oauth_client(self, server_id: str, redirect_uri: str) -> None:
719
+ async with self._pool.acquire() as conn:
720
+ await conn.execute(
721
+ "DELETE FROM oauth_clients WHERE server_id=$1 AND redirect_uri=$2",
722
+ server_id,
723
+ redirect_uri,
724
+ )
@@ -133,11 +133,13 @@ CREATE TABLE IF NOT EXISTS oauth_tokens (
133
133
  );
134
134
 
135
135
  CREATE TABLE IF NOT EXISTS oauth_clients (
136
- server_id TEXT NOT NULL PRIMARY KEY,
136
+ server_id TEXT NOT NULL,
137
+ redirect_uri TEXT NOT NULL,
137
138
  client_id TEXT NOT NULL,
138
139
  client_secret TEXT,
139
140
  metadata_json TEXT NOT NULL,
140
141
  authorization_server TEXT NOT NULL,
141
142
  created_at BIGINT NOT NULL,
142
- updated_at BIGINT NOT NULL
143
+ updated_at BIGINT NOT NULL,
144
+ PRIMARY KEY (server_id, redirect_uri)
143
145
  );
@@ -523,25 +523,27 @@ class Provider(BaseStorageProvider):
523
523
  )
524
524
  await self._db.commit()
525
525
 
526
- async def get_oauth_client(self, server_id: str) -> OAuthClientRow | None:
526
+ async def get_oauth_client(self, server_id: str, redirect_uri: str) -> OAuthClientRow | None:
527
527
  async with self._db.execute(
528
528
  "SELECT client_id, client_secret, metadata_json, "
529
529
  "authorization_server, created_at, updated_at "
530
- "FROM oauth_clients WHERE server_id=?",
531
- (server_id,),
530
+ "FROM oauth_clients WHERE server_id=? AND redirect_uri=?",
531
+ (server_id, redirect_uri),
532
532
  ) as cursor:
533
533
  row = await cursor.fetchone()
534
534
  if row is None:
535
535
  return None
536
536
  return OAuthClientRow(*row)
537
537
 
538
- async def put_oauth_client(self, server_id: str, row: OAuthClientRow) -> None:
538
+ async def put_oauth_client(
539
+ self, server_id: str, redirect_uri: str, row: OAuthClientRow
540
+ ) -> None:
539
541
  await self._db.execute(
540
542
  "INSERT INTO oauth_clients ("
541
- "server_id, client_id, client_secret, metadata_json, "
543
+ "server_id, redirect_uri, client_id, client_secret, metadata_json, "
542
544
  "authorization_server, created_at, updated_at"
543
- ") VALUES (?, ?, ?, ?, ?, ?, ?) "
544
- "ON CONFLICT(server_id) DO UPDATE SET "
545
+ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?) "
546
+ "ON CONFLICT(server_id, redirect_uri) DO UPDATE SET "
545
547
  "client_id=excluded.client_id, "
546
548
  "client_secret=excluded.client_secret, "
547
549
  "metadata_json=excluded.metadata_json, "
@@ -549,6 +551,7 @@ class Provider(BaseStorageProvider):
549
551
  "updated_at=excluded.updated_at",
550
552
  (
551
553
  server_id,
554
+ redirect_uri,
552
555
  row.client_id,
553
556
  row.client_secret,
554
557
  row.metadata_json,
@@ -558,3 +561,10 @@ class Provider(BaseStorageProvider):
558
561
  ),
559
562
  )
560
563
  await self._db.commit()
564
+
565
+ async def delete_oauth_client(self, server_id: str, redirect_uri: str) -> None:
566
+ await self._db.execute(
567
+ "DELETE FROM oauth_clients WHERE server_id=? AND redirect_uri=?",
568
+ (server_id, redirect_uri),
569
+ )
570
+ await self._db.commit()
@@ -86,11 +86,13 @@ CREATE TABLE IF NOT EXISTS oauth_tokens (
86
86
  );
87
87
 
88
88
  CREATE TABLE IF NOT EXISTS oauth_clients (
89
- server_id TEXT NOT NULL PRIMARY KEY,
89
+ server_id TEXT NOT NULL,
90
+ redirect_uri TEXT NOT NULL,
90
91
  client_id TEXT NOT NULL,
91
92
  client_secret TEXT,
92
93
  metadata_json TEXT NOT NULL,
93
94
  authorization_server TEXT NOT NULL,
94
95
  created_at INTEGER NOT NULL,
95
- updated_at INTEGER NOT NULL
96
+ updated_at INTEGER NOT NULL,
97
+ PRIMARY KEY (server_id, redirect_uri)
96
98
  );
@@ -317,6 +317,7 @@ class Provider(BaseToolProvider):
317
317
  self._public_url = (
318
318
  getattr(framework_ctx, "_public_url", None) or os.environ.get("OAUTH_PUBLIC_URL", "")
319
319
  ).rstrip("/")
320
+ self._redirect_uri = f"{self._public_url}/oauth/callback"
320
321
  # Tools are discovered eagerly via ensure_authenticated() when the user first
321
322
  # talks to the agent (or after restart, using the cached token). Until then
322
323
  # this stays empty.
@@ -377,6 +378,7 @@ class Provider(BaseToolProvider):
377
378
  backend=self._framework_ctx.storage,
378
379
  user_id=user_id,
379
380
  server_id=self._server_id,
381
+ redirect_uri=self._redirect_uri,
380
382
  token_key=self._token_key,
381
383
  )
382
384
  # Make sure the metadata cache is populated FIRST. We need it for the
@@ -408,7 +410,7 @@ class Provider(BaseToolProvider):
408
410
  exc_info=True,
409
411
  )
410
412
  client_metadata = OAuthClientMetadata(
411
- redirect_uris=[f"{self._public_url}/oauth/callback"],
413
+ redirect_uris=[self._redirect_uri],
412
414
  client_name=f"slack-agents/{self._framework_ctx.agent_name}",
413
415
  token_endpoint_auth_method="none",
414
416
  grant_types=["authorization_code", "refresh_token"],
@@ -471,12 +473,12 @@ class Provider(BaseToolProvider):
471
473
  Idempotent — does nothing if a client is already cached for this server.
472
474
  """
473
475
  backend = self._framework_ctx.storage
474
- if await backend.get_oauth_client(self._server_id) is not None:
476
+ if await backend.get_oauth_client(self._server_id, self._redirect_uri) is not None:
475
477
  return
476
478
  if self._cached_asm is None or self._cached_asm.registration_endpoint is None:
477
479
  return
478
480
  body: dict = {
479
- "redirect_uris": [f"{self._public_url}/oauth/callback"],
481
+ "redirect_uris": [self._redirect_uri],
480
482
  "client_name": f"slack-agents/{self._framework_ctx.agent_name}",
481
483
  "token_endpoint_auth_method": "none",
482
484
  "grant_types": ["authorization_code", "refresh_token"],
@@ -490,12 +492,13 @@ class Provider(BaseToolProvider):
490
492
  resp = await http.post(registration_url, json=body)
491
493
  resp.raise_for_status()
492
494
  client_info = OAuthClientInformationFull.model_validate_json(resp.content)
493
- # Persist via DBTokenStorage. Client info is keyed only by server_id
494
- # so the user_id passed here doesn't matter for this code path.
495
+ # Persist via DBTokenStorage. Client info is shared across users for
496
+ # this (server_id, redirect_uri); the user_id passed here is irrelevant.
495
497
  await DBTokenStorage(
496
498
  backend=backend,
497
499
  user_id="__system__",
498
500
  server_id=self._server_id,
501
+ redirect_uri=self._redirect_uri,
499
502
  token_key=self._token_key,
500
503
  ).set_client_info(client_info)
501
504
  registered_scope = getattr(client_info, "scope", None) or "(none)"
@@ -646,6 +649,7 @@ class Provider(BaseToolProvider):
646
649
  backend=self._framework_ctx.storage,
647
650
  user_id=user_id,
648
651
  server_id=self._server_id,
652
+ redirect_uri=self._redirect_uri,
649
653
  token_key=self._token_key,
650
654
  )
651
655
  had_token_before = await token_storage.get_tokens() is not None
@@ -713,6 +717,28 @@ class Provider(BaseToolProvider):
713
717
  try:
714
718
  return await self._call_mcp_with_token(user_conversation_context, tool_name, arguments)
715
719
  except Exception as e:
720
+ # Specific recovery: IdP rejected our authorize request because the
721
+ # client's registered redirect_uri doesn't match what we sent.
722
+ # Means the cached `oauth_clients` row's redirect_uri is stale —
723
+ # delete it so the next call re-registers, and surface a clear
724
+ # message naming the cause.
725
+ if _is_redirect_uri_mismatch(e):
726
+ await self._handle_redirect_uri_mismatch(user_id)
727
+ return make_tool_error(
728
+ error=ERROR_SYSTEM_ERROR,
729
+ code="redirect_uri_mismatch",
730
+ server=self._server_id,
731
+ tool=tool_name,
732
+ recovery=RECOVERY_RETRY,
733
+ message=(
734
+ "The agent's OAUTH_PUBLIC_URL doesn't match the "
735
+ "redirect_uri registered with the OAuth client at "
736
+ "the IdP. The cached client registration has been "
737
+ "cleared — the next call will register a fresh "
738
+ "client and prompt for re-auth."
739
+ ),
740
+ details={"redirect_uri": self._redirect_uri},
741
+ )
716
742
  denied = _find_user_authorization_denied(e)
717
743
  if denied is None:
718
744
  # No IdP-level denial signaled. Did we get a final 403 from the
@@ -754,6 +780,20 @@ class Provider(BaseToolProvider):
754
780
  user_id=user_id,
755
781
  )
756
782
 
783
+ async def _handle_redirect_uri_mismatch(self, user_id: str) -> None:
784
+ """Drop the cached `oauth_clients` row + the user's tokens after the
785
+ IdP rejected the registered redirect_uri. The next call re-registers
786
+ a fresh client and runs first-auth.
787
+ """
788
+ await self._framework_ctx.storage.delete_oauth_client(self._server_id, self._redirect_uri)
789
+ await self._framework_ctx.storage.delete_oauth_token(user_id, self._server_id)
790
+ logger.warning(
791
+ "oauth: cleared stale registration after IdP redirect_uri mismatch "
792
+ "(server=%s redirect_uri=%s)",
793
+ self._server_id,
794
+ self._redirect_uri,
795
+ )
796
+
757
797
  async def _cached_scope_set(self, user_id: str) -> set[str]:
758
798
  """Return the set of scopes on the user's currently-cached token, or
759
799
  an empty set if there's no cached token. Used to detect when a 403
@@ -765,6 +805,7 @@ class Provider(BaseToolProvider):
765
805
  backend=self._framework_ctx.storage,
766
806
  user_id=user_id,
767
807
  server_id=self._server_id,
808
+ redirect_uri=self._redirect_uri,
768
809
  token_key=self._token_key,
769
810
  ).get_tokens()
770
811
  if cached and cached.scope:
@@ -820,6 +861,7 @@ class Provider(BaseToolProvider):
820
861
  backend=self._framework_ctx.storage,
821
862
  user_id=user_id,
822
863
  server_id=self._server_id,
864
+ redirect_uri=self._redirect_uri,
823
865
  token_key=self._token_key,
824
866
  ).get_tokens()
825
867
  if cached and cached.scope:
@@ -995,6 +1037,18 @@ def _detect_missing_scopes(
995
1037
  return None
996
1038
 
997
1039
 
1040
+ def _is_redirect_uri_mismatch(exc: BaseException) -> bool:
1041
+ """Detect an IdP rejection of our authorize redirect_uri so the caller can
1042
+ drop the stale cached client registration. Matches Keycloak's "Invalid
1043
+ parameter: redirect_uri" and the RFC 6749 standard `redirect_uri_mismatch`.
1044
+ """
1045
+ for leaf in _flatten_exceptions(exc):
1046
+ msg = str(leaf).lower()
1047
+ if "invalid parameter: redirect_uri" in msg or "redirect_uri_mismatch" in msg:
1048
+ return True
1049
+ return False
1050
+
1051
+
998
1052
  def _message_from_tool_error(tr: ToolResult) -> str:
999
1053
  """Extract the human-readable `message` field from a structured tool-error
1000
1054
  ToolResult, for paths (like ensure_authenticated) that surface errors
@@ -179,6 +179,7 @@ class TestCallToolHappyPath:
179
179
  backend=framework_ctx.storage,
180
180
  user_id="U1",
181
181
  server_id="my-mcp",
182
+ redirect_uri=p._redirect_uri,
182
183
  token_key=p._token_key,
183
184
  )
184
185
  await store.set_tokens(
@@ -305,6 +306,7 @@ class TestScopeMergeHook:
305
306
  backend=framework_ctx.storage,
306
307
  user_id="U1",
307
308
  server_id="my-mcp",
309
+ redirect_uri=p._redirect_uri,
308
310
  token_key=p._token_key,
309
311
  ).set_tokens(
310
312
  OAuthToken(
@@ -406,6 +408,7 @@ class TestScopeNotGrantedDetection:
406
408
  backend=framework_ctx.storage,
407
409
  user_id="U1",
408
410
  server_id="my-mcp",
411
+ redirect_uri=p._redirect_uri,
409
412
  token_key=p._token_key,
410
413
  ).set_tokens(
411
414
  OAuthToken(
@@ -467,5 +470,157 @@ class TestScopeNotGrantedDetection:
467
470
  assert "offline_access" not in details["required_scopes"]
468
471
 
469
472
 
473
+ class TestRedirectUriMismatchDetection:
474
+ """The `_is_redirect_uri_mismatch` helper recognises the IdP's rejection of
475
+ a stale cached client registration so we can wipe and re-register on the
476
+ next call."""
477
+
478
+ def test_recognises_keycloak_invalid_parameter(self):
479
+ from slack_agents.tools.mcp_http_oauth import _is_redirect_uri_mismatch
480
+
481
+ exc = Exception("Invalid parameter: redirect_uri")
482
+ assert _is_redirect_uri_mismatch(exc) is True
483
+
484
+ def test_recognises_redirect_uri_mismatch_phrase(self):
485
+ from slack_agents.tools.mcp_http_oauth import _is_redirect_uri_mismatch
486
+
487
+ exc = Exception("error: redirect_uri_mismatch")
488
+ assert _is_redirect_uri_mismatch(exc) is True
489
+
490
+ def test_recognises_inside_exception_chain(self):
491
+ from slack_agents.tools.mcp_http_oauth import _is_redirect_uri_mismatch
492
+
493
+ try:
494
+ try:
495
+ raise ValueError("Invalid parameter: redirect_uri")
496
+ except ValueError as inner:
497
+ raise RuntimeError("oauth flow failed") from inner
498
+ except RuntimeError as outer:
499
+ assert _is_redirect_uri_mismatch(outer) is True
500
+
501
+ def test_unrelated_error_returns_false(self):
502
+ from slack_agents.tools.mcp_http_oauth import _is_redirect_uri_mismatch
503
+
504
+ assert _is_redirect_uri_mismatch(Exception("network down")) is False
505
+ # "redirect_uri" alone is not enough — needs a mismatch verb too.
506
+ assert _is_redirect_uri_mismatch(Exception("redirect_uri set")) is False
507
+
508
+
509
+ class TestRedirectUriMismatchRecovery:
510
+ """When `_handle_redirect_uri_mismatch` runs, the cached DCR row and the
511
+ user's tokens are both deleted so the next call re-registers and re-auths."""
512
+
513
+ async def test_deletes_client_row_and_user_tokens(self, framework_ctx):
514
+ from slack_agents.oauth.storage import DBTokenStorage
515
+ from slack_agents.storage.base import OAuthClientRow
516
+
517
+ p = McpHttpOAuthProvider(
518
+ url="https://srv.example.com/mcp",
519
+ allowed_functions=[".*"],
520
+ framework_ctx=framework_ctx,
521
+ server_id="my-mcp",
522
+ )
523
+ # Seed cached registration + a user token.
524
+ import time as _time
525
+
526
+ now = int(_time.time())
527
+ await framework_ctx.storage.put_oauth_client(
528
+ "my-mcp",
529
+ p._redirect_uri,
530
+ OAuthClientRow(
531
+ client_id="cid",
532
+ client_secret=None,
533
+ metadata_json="{}",
534
+ authorization_server="https://idp.example.com",
535
+ created_at=now,
536
+ updated_at=now,
537
+ ),
538
+ )
539
+ await DBTokenStorage(
540
+ backend=framework_ctx.storage,
541
+ user_id="U1",
542
+ server_id="my-mcp",
543
+ redirect_uri=p._redirect_uri,
544
+ token_key=p._token_key,
545
+ ).set_tokens(
546
+ OAuthToken(
547
+ access_token="t",
548
+ token_type="Bearer",
549
+ expires_in=3600,
550
+ refresh_token=None,
551
+ scope="",
552
+ )
553
+ )
554
+
555
+ await p._handle_redirect_uri_mismatch("U1")
556
+
557
+ assert await framework_ctx.storage.get_oauth_client("my-mcp", p._redirect_uri) is None
558
+ assert await framework_ctx.storage.get_oauth_token("U1", "my-mcp") is None
559
+
560
+ async def test_call_tool_returns_structured_error_on_mismatch(self, framework_ctx):
561
+ import json as _json
562
+
563
+ from slack_agents.oauth.storage import DBTokenStorage
564
+ from slack_agents.storage.base import OAuthClientRow
565
+
566
+ p = McpHttpOAuthProvider(
567
+ url="https://srv.example.com/mcp",
568
+ allowed_functions=[".*"],
569
+ framework_ctx=framework_ctx,
570
+ server_id="my-mcp",
571
+ )
572
+ # Seed registration + token so we can verify they get cleared.
573
+ import time as _time
574
+
575
+ now = int(_time.time())
576
+ await framework_ctx.storage.put_oauth_client(
577
+ "my-mcp",
578
+ p._redirect_uri,
579
+ OAuthClientRow("cid", None, "{}", "https://idp", now, now),
580
+ )
581
+ await DBTokenStorage(
582
+ backend=framework_ctx.storage,
583
+ user_id="U1",
584
+ server_id="my-mcp",
585
+ redirect_uri=p._redirect_uri,
586
+ token_key=p._token_key,
587
+ ).set_tokens(
588
+ OAuthToken(
589
+ access_token="t",
590
+ token_type="Bearer",
591
+ expires_in=3600,
592
+ refresh_token=None,
593
+ scope="",
594
+ )
595
+ )
596
+
597
+ with patch.object(
598
+ p,
599
+ "_call_mcp_with_token",
600
+ AsyncMock(side_effect=Exception("Invalid parameter: redirect_uri")),
601
+ ):
602
+ result = await p.call_tool(
603
+ tool_name="t",
604
+ arguments={},
605
+ user_conversation_context={
606
+ "user_id": "U1",
607
+ "channel_id": "C1",
608
+ "channel_name": "c",
609
+ "thread_id": None,
610
+ },
611
+ storage=framework_ctx.storage,
612
+ )
613
+
614
+ assert result["is_error"] is True
615
+ payload = _json.loads(result["content"])
616
+ assert payload["error"] == "system_error"
617
+ assert payload["code"] == "redirect_uri_mismatch"
618
+ assert payload["server"] == "my-mcp"
619
+ assert payload["details"]["redirect_uri"] == p._redirect_uri
620
+ # Stale state was cleared.
621
+ assert await framework_ctx.storage.get_oauth_client("my-mcp", p._redirect_uri) is None
622
+ assert await framework_ctx.storage.get_oauth_token("U1", "my-mcp") is None
623
+
624
+
470
625
  # Need this import at module level for the test above.
471
626
  import httpx # noqa: E402