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.
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/CHANGELOG.md +12 -1
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/PKG-INFO +1 -1
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/pyproject.toml +1 -1
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/oauth/storage.py +5 -3
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/storage/base.py +10 -4
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/storage/postgres.py +19 -7
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/storage/postgres.sql +4 -2
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/storage/sqlite.py +17 -7
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/storage/sqlite.sql +4 -2
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/tools/mcp_http_oauth.py +59 -5
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_mcp_http_oauth.py +155 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_oauth_storage.py +76 -8
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_storage_oauth.py +38 -4
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/uv.lock +1 -1
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/.dockerignore +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/.env.example +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/.github/workflows/ci.yml +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/.github/workflows/publish.yml +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/.gitignore +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/.pre-commit-config.yaml +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/AGENTS.md +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/CODE_OF_CONDUCT.md +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/CONTRIBUTING.md +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/LICENSE +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/README.md +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/SECURITY.md +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/agents/README.md +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/agents/docs-assistant/config.yaml +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/agents/docs-assistant/system_prompt.txt +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/agents/hello-world/config.yaml +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/agents/hello-world/system_prompt.txt +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/agents/kitchen-sink/config.yaml +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/agents/kitchen-sink/system_prompt.txt +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/access-control.md +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/agents.md +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/canvas.md +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/cli.md +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/deployment.md +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/llm.md +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/media/demo.gif +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/oauth.md +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/observability.md +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/private-repo.md +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/setup.md +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/slack-app-manifest.json +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/storage.md +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/tools.md +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/docs/user-context.md +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/llms-full.txt +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/llms.txt +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/Dockerfile +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/__init__.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/access/__init__.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/access/allow_all.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/access/allow_list.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/access/base.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/agent_loop.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/cli/__init__.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/cli/build_docker.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/cli/export_conversations.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/cli/export_conversations_html.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/cli/export_usage.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/cli/export_usage_csv.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/cli/healthcheck.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/cli/init.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/cli/run.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/config.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/conversations.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/files.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/llm/__init__.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/llm/anthropic.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/llm/base.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/llm/openai.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/main.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/oauth/__init__.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/oauth/crypto.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/oauth/prompts.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/oauth/server.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/oauth/state.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/observability.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/py.typed +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/scripts/__init__.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/scripts/download_fonts.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/scripts/generate_llms_full.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/__init__.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/actions.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/agent.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/canvas_auth.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/canvases.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/files.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/format.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/streaming.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/streaming_formatter.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/slack/tool_blocks.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/storage/__init__.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/tools/__init__.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/tools/base.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/tools/canvas.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/tools/canvas_importer.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/tools/file_exporter.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/tools/file_importer.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/tools/mcp_http.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/tools/user_context.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/__init__.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_access.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_agent_loop.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_canvas_auth.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_canvas_importer.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_cli.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_config.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_conversations.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_cost.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_export_documents.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_export_usage.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_file_extractors.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_format.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_init.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_llm_error_classification.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_llm_factory.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_load_plugin.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_mcp_client.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_oauth_crypto.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_oauth_integration.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_oauth_prompts.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_oauth_server.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_oauth_state.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_oauth_validation.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_openai_convert.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_overlay_integration.py +0 -0
- {python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/tests/test_tool_blocks.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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(
|
|
102
|
-
|
|
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."""
|
{python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/storage/postgres.py
RENAMED
|
@@ -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(
|
|
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,
|
|
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
|
+
)
|
{python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/storage/postgres.sql
RENAMED
|
@@ -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
|
|
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(
|
|
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
|
|
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
|
);
|
{python_slack_agents-0.8.0 → python_slack_agents-0.8.1}/src/slack_agents/tools/mcp_http_oauth.py
RENAMED
|
@@ -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=[
|
|
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": [
|
|
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
|
|
494
|
-
#
|
|
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
|